feat: implement electronic signature management module with PIN configuration and audit logging
This commit is contained in:
parent
8111adfc6e
commit
ae72eebcea
@ -17,6 +17,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Bot,
|
Bot,
|
||||||
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -235,6 +236,11 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
label: "Audit Logs",
|
label: "Audit Logs",
|
||||||
path: "/tenant/audit-logs",
|
path: "/tenant/audit-logs",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
label: "E-Signatures",
|
||||||
|
path: "/tenant/electronic-signatures",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
|
|||||||
707
src/pages/tenant/ElectronicSignatures.tsx
Normal file
707
src/pages/tenant/ElectronicSignatures.tsx
Normal file
@ -0,0 +1,707 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
FilterDropdown,
|
||||||
|
StatusBadge,
|
||||||
|
SearchBox,
|
||||||
|
type Column,
|
||||||
|
PrimaryButton,
|
||||||
|
Modal,
|
||||||
|
FormField,
|
||||||
|
GradientStatCard,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { ShieldCheck, History, Key, BarChart3, Info, CheckCircle2, AlertTriangle, Calendar } from "lucide-react";
|
||||||
|
import { electronicSignatureService } from "@/services/electronic-signature-service";
|
||||||
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
|
||||||
|
export interface SignatureLog {
|
||||||
|
id: string;
|
||||||
|
signer: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
resource: {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
action: string;
|
||||||
|
meaning: string;
|
||||||
|
auth_method: string;
|
||||||
|
signed_at: string;
|
||||||
|
is_valid: boolean;
|
||||||
|
workflow?: {
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
required: number;
|
||||||
|
} | null;
|
||||||
|
verification: {
|
||||||
|
signature_hash: string;
|
||||||
|
ip_address: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string | null): string => {
|
||||||
|
if (!dateString) return "N/A";
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionVariant = (action: string): "success" | "failure" | "info" | "process" => {
|
||||||
|
const upper = action.toUpperCase();
|
||||||
|
if (upper === "APPROVE" || upper === "RELEASE") return "success";
|
||||||
|
if (upper === "REJECT" || upper === "WITHDRAW") return "failure";
|
||||||
|
if (upper === "REVIEW" || upper === "ACKNOWLEDGE") return "process";
|
||||||
|
return "info"; // AUTHOR, VERIFY
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinUpdateSchema = z.object({
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
pin: z.string().regex(/^\d{4,6}$/, "PIN must be between 4 and 6 digits"),
|
||||||
|
confirmPin: z.string().min(1, "Confirm PIN is required"),
|
||||||
|
}).refine((data) => data.pin === data.confirmPin, {
|
||||||
|
message: "PINs do not match",
|
||||||
|
path: ["confirmPin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
type PinUpdateFormData = z.infer<typeof pinUpdateSchema>;
|
||||||
|
|
||||||
|
const ElectronicSignatures = (): ReactElement => {
|
||||||
|
const { primaryColor } = useAppTheme();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
const canViewStats = hasPermission("audit_logs", "read");
|
||||||
|
const [activeTab, setActiveTab] = useState<"logs" | "pin" | "stats">("logs");
|
||||||
|
|
||||||
|
// History / Logs State
|
||||||
|
const [logs, setLogs] = useState<SignatureLog[]>([]);
|
||||||
|
const [isLoadingLogs, setIsLoadingLogs] = useState<boolean>(true);
|
||||||
|
const [logsError, setLogsError] = useState<string | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const [limit, setLimit] = useState<number>(10);
|
||||||
|
const [totalLogs, setTotalLogs] = useState<number>(0);
|
||||||
|
const [totalPages, setTotalPages] = useState<number>(1);
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
const [actionFilter, setActionFilter] = useState<string | null>(null);
|
||||||
|
const [selectedLog, setSelectedLog] = useState<SignatureLog | null>(null);
|
||||||
|
|
||||||
|
// Manage Credentials Form State
|
||||||
|
const [isUpdatingCredentials, setIsUpdatingCredentials] = useState(false);
|
||||||
|
|
||||||
|
// Statistics State
|
||||||
|
const [stats, setStats] = useState<any>(null);
|
||||||
|
const [isLoadingStats, setIsLoadingStats] = useState<boolean>(false);
|
||||||
|
const [statsError, setStatsError] = useState<string | null>(null);
|
||||||
|
const [startDate, setStartDate] = useState<string>(
|
||||||
|
new Date(new Date().getFullYear(), 0, 1).toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
const [endDate, setEndDate] = useState<string>(
|
||||||
|
new Date(new Date().getFullYear(), 11, 31).toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
// react-hook-form setup
|
||||||
|
const { register, handleSubmit, reset, formState: { errors } } = useForm<PinUpdateFormData>({
|
||||||
|
resolver: zodResolver(pinUpdateSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
pin: "",
|
||||||
|
confirmPin: "",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch signature history logs
|
||||||
|
const fetchLogs = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoadingLogs(true);
|
||||||
|
setLogsError(null);
|
||||||
|
const res = await electronicSignatureService.getHistory({
|
||||||
|
limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
// Handle pagination structure
|
||||||
|
const signatureRecords = res.data?.signatures || res.data || [];
|
||||||
|
setLogs(signatureRecords);
|
||||||
|
const count = res.data?.total || signatureRecords.length;
|
||||||
|
setTotalLogs(count);
|
||||||
|
setTotalPages(Math.ceil(count / limit));
|
||||||
|
} else {
|
||||||
|
setLogsError("Failed to fetch signature logs");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setLogsError(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load signature logs"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingLogs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch statistics
|
||||||
|
const fetchStats = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoadingStats(true);
|
||||||
|
setStatsError(null);
|
||||||
|
const res = await electronicSignatureService.getStatistics({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
setStats(res.data);
|
||||||
|
} else {
|
||||||
|
setStatsError("Failed to load signature statistics");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatsError(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load signature statistics"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingStats(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "logs") {
|
||||||
|
void fetchLogs();
|
||||||
|
} else if (activeTab === "stats" && canViewStats) {
|
||||||
|
void fetchStats();
|
||||||
|
}
|
||||||
|
}, [activeTab, currentPage, limit, canViewStats]);
|
||||||
|
|
||||||
|
// Handle PIN update
|
||||||
|
const handleUpdatePin = async (data: PinUpdateFormData): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsUpdatingCredentials(true);
|
||||||
|
await electronicSignatureService.setPin({
|
||||||
|
password: data.password,
|
||||||
|
pin: data.pin,
|
||||||
|
});
|
||||||
|
showToast.success("Electronic signature PIN updated successfully");
|
||||||
|
reset();
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast.error(
|
||||||
|
err?.response?.data?.error?.message || "Failed to update PIN"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingCredentials(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter logs locally based on search & action
|
||||||
|
const filteredLogs = logs.filter((log) => {
|
||||||
|
const matchesSearch =
|
||||||
|
!search ||
|
||||||
|
log.resource?.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
log.meaning.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
log.id.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
log.signer?.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
log.signer?.email?.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesAction = !actionFilter || log.action === actionFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesAction;
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<SignatureLog>[] = [
|
||||||
|
{
|
||||||
|
key: "signed_at",
|
||||||
|
label: "Timestamp",
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">
|
||||||
|
{formatDate(log.signed_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "action",
|
||||||
|
label: "Action",
|
||||||
|
render: (log) => (
|
||||||
|
<StatusBadge variant={getActionVariant(log.action)}>
|
||||||
|
{log.action}
|
||||||
|
</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "meaning",
|
||||||
|
label: "Meaning",
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#475569] block max-w-xs truncate" title={log.meaning}>
|
||||||
|
{log.meaning}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "resource_name",
|
||||||
|
label: "Resource Name",
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#0f1724] block max-w-xs truncate" title={log.resource?.name || ""}>
|
||||||
|
{log.resource?.name || log.resource?.id}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "is_valid",
|
||||||
|
label: "Status",
|
||||||
|
render: (log) => (
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${log.is_valid ? "bg-green-50 text-green-700 border border-green-200" : "bg-red-50 text-red-700 border border-red-200"}`}>
|
||||||
|
{log.is_valid ? "Active / Compliant" : "Invalidated"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
|
render: (log) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedLog(log);
|
||||||
|
}}
|
||||||
|
className="text-xs font-medium hover:underline cursor-pointer"
|
||||||
|
style={{ color: primaryColor }}
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate actual total signatures from action stats to avoid API group count overrides
|
||||||
|
const totalSignatureCount: number = stats?.by_action
|
||||||
|
? Object.keys(stats.by_action).reduce((sum: number, key: string) => sum + (parseInt(stats.by_action[key], 10) || 0), 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="E-Signatures"
|
||||||
|
pageHeader={{
|
||||||
|
title: "Electronic Signatures",
|
||||||
|
description: canViewStats
|
||||||
|
? "FDA 21 CFR Part 11 compliant electronic signature management and compliance reporting."
|
||||||
|
: "FDA 21 CFR Part 11 compliant view of your personal electronic signature records.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6 w-full h-full">
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex border-b border-gray-200/80 gap-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("logs")}
|
||||||
|
className={`flex items-center gap-2 pb-3 text-xs md:text-sm font-semibold border-b-2 transition-colors cursor-pointer ${
|
||||||
|
activeTab === "logs"
|
||||||
|
? ""
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-900"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: activeTab === "logs" ? primaryColor : "transparent",
|
||||||
|
color: activeTab === "logs" ? primaryColor : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
{canViewStats ? "Signature Logs" : "My Signatures"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("pin")}
|
||||||
|
className={`flex items-center gap-2 pb-3 text-xs md:text-sm font-semibold border-b-2 transition-colors cursor-pointer ${
|
||||||
|
activeTab === "pin"
|
||||||
|
? ""
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-900"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: activeTab === "pin" ? primaryColor : "transparent",
|
||||||
|
color: activeTab === "pin" ? primaryColor : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
Manage Credentials
|
||||||
|
</button>
|
||||||
|
{canViewStats && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("stats")}
|
||||||
|
className={`flex items-center gap-2 pb-3 text-xs md:text-sm font-semibold border-b-2 transition-colors cursor-pointer ${
|
||||||
|
activeTab === "stats"
|
||||||
|
? ""
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-900"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: activeTab === "stats" ? primaryColor : "transparent",
|
||||||
|
color: activeTab === "stats" ? primaryColor : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
Compliance Stats
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content: Logs */}
|
||||||
|
{activeTab === "logs" && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SearchBox
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder={canViewStats ? "Search logs..." : "Search my signatures..."}
|
||||||
|
containerClassName="relative w-full md:w-64"
|
||||||
|
/>
|
||||||
|
<FilterDropdown
|
||||||
|
label="Action"
|
||||||
|
options={[
|
||||||
|
{ value: "APPROVE", label: "APPROVE" },
|
||||||
|
{ value: "REJECT", label: "REJECT" },
|
||||||
|
{ value: "REVIEW", label: "REVIEW" },
|
||||||
|
{ value: "AUTHOR", label: "AUTHOR" },
|
||||||
|
{ value: "VERIFY", label: "VERIFY" },
|
||||||
|
{ value: "RELEASE", label: "RELEASE" },
|
||||||
|
{ value: "ACKNOWLEDGE", label: "ACKNOWLEDGE" },
|
||||||
|
{ value: "WITHDRAW", label: "WITHDRAW" },
|
||||||
|
]}
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(val) => setActionFilter(val as string | null)}
|
||||||
|
placeholder="All Actions"
|
||||||
|
/>
|
||||||
|
{(search || actionFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
setActionFilter(null);
|
||||||
|
}}
|
||||||
|
className="text-xs hover:underline cursor-pointer font-semibold"
|
||||||
|
style={{ color: primaryColor }}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={filteredLogs}
|
||||||
|
keyExtractor={(log) => log.id}
|
||||||
|
isLoading={isLoadingLogs}
|
||||||
|
error={logsError}
|
||||||
|
emptyMessage={canViewStats ? "No electronic signatures recorded yet." : "You have not recorded any electronic signatures yet."}
|
||||||
|
onRowClick={(log) => setSelectedLog(log)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{totalLogs > limit && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
totalItems={totalLogs}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={(lim) => {
|
||||||
|
setLimit(lim);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content: Manage PIN */}
|
||||||
|
{activeTab === "pin" && (
|
||||||
|
<div className="bg-white border border-gray-200/80 rounded-lg p-6 max-w-xl mx-auto w-full shadow-sm animate-in fade-in slide-in-from-bottom-2">
|
||||||
|
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-gray-100">
|
||||||
|
<Key className="w-5 h-5" style={{ color: primaryColor }} />
|
||||||
|
<h3 className="text-base font-semibold text-gray-900">
|
||||||
|
Configure Electronic Signature PIN
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mb-5 leading-normal">
|
||||||
|
Set or update your 4-6 digit compliance PIN. The PIN is combined with your account password during signature verification steps to ensure regulatory compliance with 21 CFR Part 11.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(handleUpdatePin)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
label="Login Password"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter login password to confirm identity"
|
||||||
|
error={errors.password?.message}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
label="New Signature PIN"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="Enter 4-6 digit PIN"
|
||||||
|
error={errors.pin?.message}
|
||||||
|
{...register("pin")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormField
|
||||||
|
label="Confirm Signature PIN"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="Re-enter PIN"
|
||||||
|
error={errors.confirmPin?.message}
|
||||||
|
{...register("confirmPin")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
disabled={isUpdatingCredentials}
|
||||||
|
className="px-4 py-2 text-xs font-semibold"
|
||||||
|
>
|
||||||
|
{isUpdatingCredentials ? "Updating PIN..." : "Update Signature PIN"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Content: Statistics */}
|
||||||
|
{activeTab === "stats" && canViewStats && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Filter Row */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-4 bg-white border border-gray-200/80 p-4 rounded-lg shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-gray-700 mr-2">
|
||||||
|
<Calendar className="w-4 h-4" style={{ color: primaryColor }} />
|
||||||
|
Date Range:
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-gray-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-gray-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => void fetchStats()}
|
||||||
|
disabled={isLoadingStats}
|
||||||
|
className="w-full sm:w-auto px-4 py-2 text-xs font-semibold"
|
||||||
|
>
|
||||||
|
{isLoadingStats ? "Analyzing..." : "Apply Range"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statsError ? (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-md flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||||
|
<span className="text-sm text-red-700">{statsError}</span>
|
||||||
|
</div>
|
||||||
|
) : isLoadingStats ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-sm text-gray-500">Generating compliance statistics...</span>
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Statistics Cards using GradientStatCard */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||||
|
<GradientStatCard
|
||||||
|
icon={ShieldCheck}
|
||||||
|
value={totalSignatureCount || stats.total_signatures || 0}
|
||||||
|
label="Total Signatures"
|
||||||
|
/>
|
||||||
|
<GradientStatCard
|
||||||
|
icon={Key}
|
||||||
|
value={stats.unique_signers || 0}
|
||||||
|
label="Unique Signers"
|
||||||
|
/>
|
||||||
|
<GradientStatCard
|
||||||
|
icon={CheckCircle2}
|
||||||
|
value={stats.unique_resources || 0}
|
||||||
|
label="Unique Resources"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution Grid */}
|
||||||
|
<div className="bg-white border border-gray-200/80 rounded-xl p-6 shadow-sm">
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 mb-5 pb-2 border-b border-gray-100 flex items-center gap-1.5">
|
||||||
|
<BarChart3 className="w-4 h-4" style={{ color: primaryColor }} />
|
||||||
|
Signature Action Distribution
|
||||||
|
</h3>
|
||||||
|
{stats.by_action && Object.keys(stats.by_action).length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(stats.by_action).map(([action, count]: [string, any]) => {
|
||||||
|
const total = totalSignatureCount || 1;
|
||||||
|
const percentage = total > 0 ? Math.round((Number(count) / Number(total)) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={action} className="space-y-1">
|
||||||
|
<div className="flex justify-between items-center text-xs font-semibold">
|
||||||
|
<span className="text-gray-700 flex items-center gap-1.5">
|
||||||
|
<StatusBadge variant={getActionVariant(action)}>{action}</StatusBadge>
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{count} ({percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-100 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${percentage}%`, backgroundColor: primaryColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400 italic block text-center py-6">
|
||||||
|
No distribution data found for this period.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400 italic block text-center py-6">
|
||||||
|
Please apply a date range to generate statistics.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log Details Modal */}
|
||||||
|
{selectedLog && (
|
||||||
|
<Modal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => setSelectedLog(null)}
|
||||||
|
title="Electronic Signature Audit Record"
|
||||||
|
description="Detailed metadata for compliance audit verification."
|
||||||
|
maxWidth="2xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 text-xs">
|
||||||
|
{/* Header info */}
|
||||||
|
<div className="flex justify-between items-center border-b border-gray-100 pb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block">Signature ID</span>
|
||||||
|
<code className="text-gray-900 font-mono font-semibold select-all">
|
||||||
|
{selectedLog.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block text-right">Verification Status</span>
|
||||||
|
<span className={`inline-block font-semibold px-2 py-0.5 rounded-full ${selectedLog.is_valid ? "bg-green-50 text-green-700 border border-green-200" : "bg-red-50 text-red-700 border border-red-200"}`}>
|
||||||
|
{selectedLog.is_valid ? "Compliant & Verified" : "Invalidated"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core details */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-0.5">Signer</span>
|
||||||
|
<span className="text-gray-900 font-semibold">
|
||||||
|
{selectedLog.signer
|
||||||
|
? `${selectedLog.signer.name} (${selectedLog.signer.email})`
|
||||||
|
: "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-0.5">Signed At</span>
|
||||||
|
<span className="text-gray-900 font-semibold">
|
||||||
|
{formatDate(selectedLog.signed_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-0.5">Action Code</span>
|
||||||
|
<span className="text-gray-900 font-semibold">
|
||||||
|
<StatusBadge variant={getActionVariant(selectedLog.action)}>{selectedLog.action}</StatusBadge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-0.5">Auth Method</span>
|
||||||
|
<span className="text-gray-900 font-semibold font-mono uppercase">
|
||||||
|
{selectedLog.auth_method}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document/Resource details */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200/50 rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-1 text-[11px] font-bold text-gray-700 border-b border-gray-200 pb-1">
|
||||||
|
<Info className="w-3.5 h-3.5 text-amber-600" />
|
||||||
|
Target Resource Context
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block text-[10px]">Resource Type</span>
|
||||||
|
<span className="text-gray-900 font-semibold">{selectedLog.resource?.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<span className="text-gray-400 block text-[10px]">Resource Name</span>
|
||||||
|
<span className="text-gray-900 font-semibold truncate block" title={selectedLog.resource?.name || ""}>
|
||||||
|
{selectedLog.resource?.name || "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block text-[10px]">Resource ID</span>
|
||||||
|
<code className="text-gray-700 font-mono text-[10px]">{selectedLog.resource?.id}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit details */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-0.5">Signature Meaning</span>
|
||||||
|
<p className="text-gray-900 font-medium bg-amber-50/50 border border-amber-100 p-2.5 rounded-md leading-relaxed">
|
||||||
|
"{selectedLog.meaning}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-0.5">Audit Trail Context</span>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 bg-gray-50 p-2.5 rounded-md font-mono text-[10px]">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block">IP Address</span>
|
||||||
|
<span className="text-gray-700">{selectedLog.verification?.ip_address || "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-0.5">SHA-256 Digital Fingerprint</span>
|
||||||
|
<code className="text-gray-700 font-mono break-all bg-gray-50 p-2 rounded block leading-normal">
|
||||||
|
{selectedLog.verification?.signature_hash}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElectronicSignatures;
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
type FileAttachmentItem,
|
type FileAttachmentItem,
|
||||||
} from "@/services/document-service";
|
} from "@/services/document-service";
|
||||||
import { workflowService } from "@/services/workflow-service";
|
import { workflowService } from "@/services/workflow-service";
|
||||||
|
import { electronicSignatureService } from "@/services/electronic-signature-service";
|
||||||
import type { DocumentDetail, DocumentVersion, DocumentSection, DocumentComment } from "@/types/document";
|
import type { DocumentDetail, DocumentVersion, DocumentSection, DocumentComment } from "@/types/document";
|
||||||
import type { WorkflowInstance } from "@/types/workflow";
|
import type { WorkflowInstance } from "@/types/workflow";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -111,6 +112,11 @@ const ViewDocument = (): ReactElement => {
|
|||||||
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
|
const [versionSelectedFileId, setVersionSelectedFileId] = useState("");
|
||||||
const [versionFileName, setVersionFileName] = useState("");
|
const [versionFileName, setVersionFileName] = useState("");
|
||||||
const [transitionComment, setTransitionComment] = useState("");
|
const [transitionComment, setTransitionComment] = useState("");
|
||||||
|
const [transitionPassword, setTransitionPassword] = useState("");
|
||||||
|
const [transitionPin, setTransitionPin] = useState("");
|
||||||
|
const [transitionSignatureAction, setTransitionSignatureAction] = useState<
|
||||||
|
'APPROVE' | 'REJECT' | 'REVIEW' | 'AUTHOR' | 'VERIFY' | 'RELEASE' | 'ACKNOWLEDGE' | 'WITHDRAW'
|
||||||
|
>("APPROVE");
|
||||||
const [selectedWorkflowAction, setSelectedWorkflowAction] =
|
const [selectedWorkflowAction, setSelectedWorkflowAction] =
|
||||||
useState<any>(null);
|
useState<any>(null);
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
@ -469,13 +475,58 @@ const ViewDocument = (): ReactElement => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedWorkflowAction.requires_signature) {
|
||||||
|
if (!transitionPassword.trim()) {
|
||||||
|
showToast.error("Password is required for electronic signature");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let signatureId: string | undefined = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsTransitioning(true);
|
setIsTransitioning(true);
|
||||||
|
|
||||||
|
if (selectedWorkflowAction.requires_signature) {
|
||||||
|
// 1. Check if signature is already available for this resource
|
||||||
|
try {
|
||||||
|
const sigsRes = await electronicSignatureService.getResourceSignatures("workflow_task", pendingTask.id);
|
||||||
|
const validSigs = sigsRes.data?.signatures || sigsRes.data || [];
|
||||||
|
const activeSig = validSigs.find((s: any) => s.is_valid);
|
||||||
|
if (activeSig) {
|
||||||
|
signatureId = activeSig.id;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to check existing signatures", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If no valid signature exists, create one
|
||||||
|
if (!signatureId) {
|
||||||
|
const signRes = await electronicSignatureService.sign({
|
||||||
|
resource_type: "workflow_task",
|
||||||
|
resource_id: pendingTask.id,
|
||||||
|
resource_name: `${document?.title || "Document"} - ${selectedWorkflowAction.name || selectedWorkflowAction.action}`,
|
||||||
|
action: transitionSignatureAction,
|
||||||
|
meaning: selectedWorkflowAction.signature_meaning || `I ${selectedWorkflowAction.action} this workflow step`,
|
||||||
|
password: transitionPassword,
|
||||||
|
pin: transitionPin || null,
|
||||||
|
auth_method: transitionPin ? "password_pin" : "password",
|
||||||
|
workflow_id: workflowInstance.id
|
||||||
|
});
|
||||||
|
signatureId = signRes.data?.id || signRes.data?.signature?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await workflowService.transition(workflowInstance.id, {
|
await workflowService.transition(workflowInstance.id, {
|
||||||
task_id: pendingTask.id,
|
task_id: pendingTask.id,
|
||||||
action: selectedWorkflowAction.action,
|
action: selectedWorkflowAction.action,
|
||||||
comments: transitionComment.trim() || undefined,
|
comments: transitionComment.trim() || undefined,
|
||||||
|
signature_id: signatureId,
|
||||||
|
signature_action: selectedWorkflowAction.requires_signature ? transitionSignatureAction : undefined,
|
||||||
|
password: selectedWorkflowAction.requires_signature ? transitionPassword : undefined,
|
||||||
|
pin: selectedWorkflowAction.requires_signature ? transitionPin : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
showToast.success(`Action "${selectedWorkflowAction.name}" completed`);
|
showToast.success(`Action "${selectedWorkflowAction.name}" completed`);
|
||||||
|
|
||||||
// Refresh workflow instance data
|
// Refresh workflow instance data
|
||||||
@ -485,6 +536,9 @@ const ViewDocument = (): ReactElement => {
|
|||||||
// Reset transition state
|
// Reset transition state
|
||||||
setSelectedWorkflowAction(null);
|
setSelectedWorkflowAction(null);
|
||||||
setTransitionComment("");
|
setTransitionComment("");
|
||||||
|
setTransitionPassword("");
|
||||||
|
setTransitionPin("");
|
||||||
|
setTransitionSignatureAction("APPROVE");
|
||||||
|
|
||||||
// Also refresh document data as it might have changed status
|
// Also refresh document data as it might have changed status
|
||||||
await refreshData();
|
await refreshData();
|
||||||
@ -1781,6 +1835,9 @@ const ViewDocument = (): ReactElement => {
|
|||||||
setShowWorkflowTracker(false);
|
setShowWorkflowTracker(false);
|
||||||
setSelectedWorkflowAction(null);
|
setSelectedWorkflowAction(null);
|
||||||
setTransitionComment("");
|
setTransitionComment("");
|
||||||
|
setTransitionPassword("");
|
||||||
|
setTransitionPin("");
|
||||||
|
setTransitionSignatureAction("APPROVE");
|
||||||
}}
|
}}
|
||||||
title="Workflow Status Tracker"
|
title="Workflow Status Tracker"
|
||||||
description="Track the progress of this document's approval workflow."
|
description="Track the progress of this document's approval workflow."
|
||||||
@ -1875,6 +1932,9 @@ const ViewDocument = (): ReactElement => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedWorkflowAction(null);
|
setSelectedWorkflowAction(null);
|
||||||
setTransitionComment("");
|
setTransitionComment("");
|
||||||
|
setTransitionPassword("");
|
||||||
|
setTransitionPin("");
|
||||||
|
setTransitionSignatureAction("APPROVE");
|
||||||
}}
|
}}
|
||||||
className="text-amber-500 hover:text-amber-700"
|
className="text-amber-500 hover:text-amber-700"
|
||||||
>
|
>
|
||||||
@ -1903,6 +1963,56 @@ const ViewDocument = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedWorkflowAction.requires_signature && (
|
||||||
|
<div className="space-y-3 border-t border-amber-200/55 pt-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-bold text-amber-800 mb-1.5 block">
|
||||||
|
Signature Action <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={transitionSignatureAction}
|
||||||
|
onChange={(e) => setTransitionSignatureAction(e.target.value as any)}
|
||||||
|
className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="APPROVE">APPROVE (Approve a record or document)</option>
|
||||||
|
<option value="REJECT">REJECT (Reject a record or document)</option>
|
||||||
|
<option value="REVIEW">REVIEW (Mark a record as reviewed)</option>
|
||||||
|
<option value="AUTHOR">AUTHOR (Sign as the author of a record)</option>
|
||||||
|
<option value="VERIFY">VERIFY (Verify the accuracy of a record)</option>
|
||||||
|
<option value="RELEASE">RELEASE (Authorize the release of a record)</option>
|
||||||
|
<option value="ACKNOWLEDGE">ACKNOWLEDGE (Acknowledge receipt and understanding)</option>
|
||||||
|
<option value="WITHDRAW">WITHDRAW (Withdraw a previous signature)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-bold text-amber-800 mb-1.5 block">
|
||||||
|
Password <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={transitionPassword}
|
||||||
|
onChange={(e) => setTransitionPassword(e.target.value)}
|
||||||
|
placeholder="Enter password to sign"
|
||||||
|
className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-bold text-amber-800 mb-1.5 block">
|
||||||
|
Signature PIN <span className="text-gray-500">(Optional if not set)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={transitionPin}
|
||||||
|
onChange={(e) => setTransitionPin(e.target.value)}
|
||||||
|
placeholder="Enter 4-6 digit PIN"
|
||||||
|
className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => void handleWorkflowTransition()}
|
onClick={() => void handleWorkflowTransition()}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TenantAIProviderCreate = lazy(
|
|||||||
);
|
);
|
||||||
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
||||||
const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy"));
|
const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy"));
|
||||||
|
const ElectronicSignatures = lazy(() => import("@/pages/tenant/ElectronicSignatures"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -111,6 +112,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/audit-logs",
|
path: "/tenant/audit-logs",
|
||||||
element: <LazyRoute component={AuditLogs} />,
|
element: <LazyRoute component={AuditLogs} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/electronic-signatures",
|
||||||
|
element: <LazyRoute component={ElectronicSignatures} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tenant/settings",
|
path: "/tenant/settings",
|
||||||
element: <LazyRoute component={Settings} />,
|
element: <LazyRoute component={Settings} />,
|
||||||
|
|||||||
66
src/services/electronic-signature-service.ts
Normal file
66
src/services/electronic-signature-service.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import apiClient from './api-client';
|
||||||
|
|
||||||
|
export interface CreateSignatureData {
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
resource_name?: string | null;
|
||||||
|
action: 'APPROVE' | 'REJECT' | 'REVIEW' | 'AUTHOR' | 'VERIFY' | 'RELEASE' | 'ACKNOWLEDGE' | 'WITHDRAW';
|
||||||
|
meaning?: string | null;
|
||||||
|
password?: string;
|
||||||
|
pin?: string | null;
|
||||||
|
auth_method?: 'password' | 'password_pin';
|
||||||
|
signature_order?: number | null;
|
||||||
|
required_signatures?: number | null;
|
||||||
|
workflow_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPinData {
|
||||||
|
password?: string;
|
||||||
|
pin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ElectronicSignatureService {
|
||||||
|
private readonly baseUrl = '/electronic-signatures';
|
||||||
|
|
||||||
|
async sign(data: CreateSignatureData): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.post(`${this.baseUrl}/sign`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify(signatureId: string): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.post(`${this.baseUrl}/verify`, { signature_id: signatureId });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPin(data: SetPinData): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.post(`${this.baseUrl}/pin`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResourceSignatures(resourceType: string, resourceId: string): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.get(`${this.baseUrl}/resource/${resourceType}/${resourceId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorkflowStatus(workflowId: string): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.get(`${this.baseUrl}/workflow/${workflowId}/status`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignature(id: string): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.get(`${this.baseUrl}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(params?: { limit?: number; offset?: number }): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.get(`${this.baseUrl}/history`, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatistics(params?: { start_date?: string; end_date?: string }): Promise<{ success: boolean; data: any }> {
|
||||||
|
const response = await apiClient.get(`${this.baseUrl}/statistics`, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const electronicSignatureService = new ElectronicSignatureService();
|
||||||
@ -108,7 +108,7 @@ class WorkflowService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async transition(instanceId: string, data: { task_id: string; action: string; comments?: string; signature_id?: string }): Promise<any> {
|
async transition(instanceId: string, data: { task_id: string; action: string; comments?: string; signature_id?: string; signature_action?: string; password?: string; pin?: string }): Promise<any> {
|
||||||
const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data);
|
const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user