refactor: modularize tenant settings and audit logs for reuse in superadmin dashboard and implement SearchBox component

This commit is contained in:
Yashwin 2026-04-28 16:58:51 +05:30
parent 7eeee08318
commit 87db482697
11 changed files with 445 additions and 659 deletions

View File

@ -0,0 +1,42 @@
import type { ReactElement } from 'react';
import { Search } from 'lucide-react';
import { useAppTheme } from '@/hooks/useAppTheme';
export interface SearchBoxProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
containerClassName?: string;
inputClassName?: string;
}
export const SearchBox = ({
value,
onChange,
placeholder = 'Search...',
containerClassName = 'relative w-full sm:w-64',
inputClassName = 'w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all bg-gray-50/30'
}: SearchBoxProps): ReactElement => {
const { primaryColor } = useAppTheme();
return (
<div className={containerClassName}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
<input
type="text"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
className={inputClassName}
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
);
};

View File

@ -38,4 +38,4 @@ export { FormSlider } from './FormSlider';
export { RichTextEditor } from './RichTextEditor'; export { RichTextEditor } from './RichTextEditor';
export { FileUploadModal } from './FileUploadModal'; export { FileUploadModal } from './FileUploadModal';
export type { FileUploadModalProps } from './FileUploadModal'; export type { FileUploadModalProps } from './FileUploadModal';
export { FileShareModal } from './FileShareModal'; export { FileShareModal } from './FileShareModal';export { SearchBox } from './SearchBox';

View File

@ -50,9 +50,11 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
const { primaryColor } = useAppTheme(); const { primaryColor } = useAppTheme();
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const { tenantId } = useAppSelector((state) => state.auth); const { tenantId, roles } = useAppSelector((state) => state.auth);
const navigate = useNavigate(); const navigate = useNavigate();
const auditLogPath = roles?.includes('super_admin') ? '/audit-logs' : '/tenant/audit-logs';
// Default to table variant for a more professional look // Default to table variant for a more professional look
const activeVariant = variant || 'table'; const activeVariant = variant || 'table';
@ -84,7 +86,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
size="sm" size="sm"
className="text-[11px] font-bold gap-1 h-7" className="text-[11px] font-bold gap-1 h-7"
style={{ color: primaryColor }} style={{ color: primaryColor }}
onClick={() => navigate('/tenant/audit-logs')} onClick={() => navigate(auditLogPath)}
> >
View All <ArrowRight className="w-3 h-3" /> View All <ArrowRight className="w-3 h-3" />
</Button> </Button>
@ -134,7 +136,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 text-[11px] gap-1.5 border-[rgba(0,0,0,0.08)] hover:bg-gray-50" className="h-8 text-[11px] gap-1.5 border-[rgba(0,0,0,0.08)] hover:bg-gray-50"
onClick={() => navigate('/tenant/audit-logs')} onClick={() => navigate(auditLogPath)}
> >
View All <ArrowRight className="w-3 h-3" /> View All <ArrowRight className="w-3 h-3" />
</Button> </Button>

View File

@ -1,8 +1,15 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { Building2, CheckCircle2, Users, TrendingUp, Package, Heart } from 'lucide-react'; import {
import { StatCard } from './StatCard'; Building2,
import type { StatCardData } from '@/types/dashboard'; CheckCircle2,
import { dashboardService } from '@/services/dashboard-service'; // Users,
// TrendingUp,
Package,
Heart,
} from "lucide-react";
import { StatCard } from "./StatCard";
import type { StatCardData } from "@/types/dashboard";
import { dashboardService } from "@/services/dashboard-service";
export const StatsGrid = () => { export const StatsGrid = () => {
const [statsData, setStatsData] = useState<StatCardData[]>([]); const [statsData, setStatsData] = useState<StatCardData[]>([]);
@ -18,60 +25,64 @@ export const StatsGrid = () => {
const { data } = response; const { data } = response;
const mappedStats: StatCardData[] = [ const mappedStats: StatCardData[] = [
{ {
icon: Building2, icon: Building2,
value: data.totalTenants, value: data.totalTenants,
label: 'Total Tenants', label: "Total Tenants",
badge: { text: `${data.activeTenants} active`, variant: 'green' }, badge: { text: `${data.activeTenants} active`, variant: "green" },
}, },
{ {
icon: CheckCircle2, icon: CheckCircle2,
value: data.activeTenants, value: data.activeTenants,
label: 'Active Tenants', label: "Active Tenants",
badge: { badge: {
text: data.totalTenants > 0 text:
? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate` data.totalTenants > 0
: '0% Rate', ? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate`
variant: 'green', : "0% Rate",
variant: "green",
}, },
}, },
{ // {
icon: Users, // icon: Users,
value: data.totalUsers, // value: data.totalUsers,
label: 'Total Users', // label: "Total Users",
badge: { text: 'All users', variant: 'gray' }, // badge: { text: "All users", variant: "gray" },
}, // },
{ // {
icon: TrendingUp, // icon: TrendingUp,
value: data.activeSessions, // value: data.activeSessions,
label: 'Active Sessions', // label: "Active Sessions",
badge: { text: 'Live now', variant: 'gray' }, // badge: { text: "Live now", variant: "gray" },
}, // },
{ {
icon: Package, icon: Package,
value: data.registeredModules, value: data.registeredModules,
label: 'Registered Modules', label: "Registered Modules",
badge: { text: 'Total', variant: 'gray' }, badge: { text: "Total", variant: "gray" },
}, },
{ {
icon: Heart, icon: Heart,
value: data.healthyModules, value: data.healthyModules,
label: 'Healthy Modules', label: "Healthy Modules",
badge: { badge: {
text: data.registeredModules > 0 text:
? `${Math.round((data.healthyModules / data.registeredModules) * 100)}% Uptime` data.registeredModules > 0
: '0% Uptime', ? `${Math.round((data.healthyModules / data.registeredModules) * 100)}% Uptime`
variant: data.healthyModules === data.registeredModules && data.registeredModules > 0 : "0% Uptime",
? 'green' variant:
: 'gray', data.healthyModules === data.registeredModules &&
data.registeredModules > 0
? "green"
: "gray",
}, },
}, },
]; ];
setStatsData(mappedStats); setStatsData(mappedStats);
} catch (err) { } catch (err) {
console.error('Failed to fetch dashboard statistics:', err); console.error("Failed to fetch dashboard statistics:", err);
setError('Failed to load statistics. Please try again.'); setError("Failed to load statistics. Please try again.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from "react";
import type { ReactElement } from 'react'; import type { ReactElement } from "react";
import { Layout } from '@/components/layout/Layout'; import { Layout } from "@/components/layout/Layout";
import { import {
ViewAuditLogModal, ViewAuditLogModal,
DataTable, DataTable,
@ -8,63 +8,89 @@ import {
FilterDropdown, FilterDropdown,
StatusBadge, StatusBadge,
type Column, type Column,
} from '@/components/shared'; } from "@/components/shared";
import { Download, ArrowUpDown, Search, ChevronDown, SlidersHorizontal } from 'lucide-react'; import {
import { auditLogService } from '@/services/audit-log-service'; Download,
import { tenantService } from '@/services/tenant-service'; ArrowUpDown,
import type { AuditLog } from '@/types/audit-log'; Search,
import type { Tenant } from '@/types/tenant'; ChevronDown,
import { cn } from '@/lib/utils'; SlidersHorizontal,
import { useAppTheme } from '@/hooks/useAppTheme'; } from "lucide-react";
import { auditLogService } from "@/services/audit-log-service";
import { tenantService } from "@/services/tenant-service";
import type { AuditLog } from "@/types/audit-log";
import type { Tenant } from "@/types/tenant";
import { cn } from "@/lib/utils";
import { useAppTheme } from "@/hooks/useAppTheme";
// Helper function to format date // Helper function to format date
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('en-US', { return date.toLocaleDateString("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: 'numeric', year: "numeric",
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
}); });
}; };
// Helper function to get action badge variant // Helper function to get action badge variant
const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => { const getActionVariant = (
action: string,
): "success" | "failure" | "info" | "process" => {
const lowerAction = action.toLowerCase(); const lowerAction = action.toLowerCase();
if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success'; if (lowerAction.includes("create") || lowerAction.includes("register"))
if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info'; return "success";
if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure'; if (
if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process'; lowerAction.includes("update") ||
return 'info'; lowerAction.includes("version_update") ||
lowerAction.includes("login")
)
return "info";
if (lowerAction.includes("delete") || lowerAction.includes("deregister"))
return "failure";
if (
lowerAction.includes("read") ||
lowerAction.includes("get") ||
lowerAction.includes("status_change")
)
return "process";
return "info";
}; };
// Helper function to get method badge variant // Helper function to get method badge variant
const getMethodVariant = (method: string | null): 'success' | 'failure' | 'info' | 'process' => { const getMethodVariant = (
if (!method) return 'info'; method: string | null,
): "success" | "failure" | "info" | "process" => {
if (!method) return "info";
const upperMethod = method.toUpperCase(); const upperMethod = method.toUpperCase();
if (upperMethod === 'GET') return 'success'; if (upperMethod === "GET") return "success";
if (upperMethod === 'POST') return 'info'; if (upperMethod === "POST") return "info";
if (upperMethod === 'PUT' || upperMethod === 'PATCH') return 'process'; if (upperMethod === "PUT" || upperMethod === "PATCH") return "process";
if (upperMethod === 'DELETE') return 'failure'; if (upperMethod === "DELETE") return "failure";
return 'info'; return "info";
}; };
// Helper function to get status badge color based on response status // Helper function to get status badge color based on response status
const getStatusColor = (status: number | null): string => { const getStatusColor = (status: number | null): string => {
if (!status) return 'text-[#6b7280]'; if (!status) return "text-[#6b7280]";
if (status >= 200 && status < 300) return 'text-[#10b981]'; if (status >= 200 && status < 300) return "text-[#10b981]";
if (status >= 300 && status < 400) return 'text-[#f59e0b]'; if (status >= 300 && status < 400) return "text-[#f59e0b]";
if (status >= 400) return 'text-[#ef4444]'; if (status >= 400) return "text-[#ef4444]";
return 'text-[#6b7280]'; return "text-[#6b7280]";
}; };
const AuditLogs = (): ReactElement => { const AuditLogs = (): ReactElement => {
const { primaryColor } = useAppTheme(); const { primaryColor } = useAppTheme();
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]); const [tenants, setTenants] = useState<Tenant[]>([]);
const [modules, setModules] = useState<Array<{ id: string; name: string }>>([]); const [modules, setModules] = useState<Array<{ id: string; name: string }>>(
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]); [],
);
const [resourceTypes, setResourceTypes] = useState<
{ value: string; label: string }[]
>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -89,23 +115,28 @@ const AuditLogs = (): ReactElement => {
const [tenantFilter, setTenantFilter] = useState<string | null>(null); const [tenantFilter, setTenantFilter] = useState<string | null>(null);
const [methodFilter, setMethodFilter] = useState<string | null>(null); const [methodFilter, setMethodFilter] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState<string | null>(null); const [actionFilter, setActionFilter] = useState<string | null>(null);
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null); const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(
null,
);
const [moduleFilter, setModuleFilter] = useState<string | null>(null); const [moduleFilter, setModuleFilter] = useState<string | null>(null);
const [startDate, setStartDate] = useState<string>(''); const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>(''); const [endDate, setEndDate] = useState<string>("");
const [orderBy, setOrderBy] = useState<string[] | null>(null); const [orderBy, setOrderBy] = useState<string[] | null>(null);
const [search, setSearch] = useState<string>(''); const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>(''); const [debouncedSearch, setDebouncedSearch] = useState<string>("");
const [showMoreFilters, setShowMoreFilters] = useState<boolean>(false); const [showMoreFilters, setShowMoreFilters] = useState<boolean>(false);
const hasExtraFilters = useMemo( const hasExtraFilters = useMemo(
() => Boolean(moduleFilter || methodFilter || startDate || endDate || orderBy), () =>
[moduleFilter, methodFilter, startDate, endDate, orderBy] Boolean(moduleFilter || methodFilter || startDate || endDate || orderBy),
[moduleFilter, methodFilter, startDate, endDate, orderBy],
); );
// View modal // View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null); const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(
null,
);
// Fetch tenants on mount for the selector // Fetch tenants on mount for the selector
useEffect(() => { useEffect(() => {
@ -116,7 +147,7 @@ const AuditLogs = (): ReactElement => {
setTenants(response.data); setTenants(response.data);
} }
} catch (err) { } catch (err) {
console.error('Failed to load tenants:', err); console.error("Failed to load tenants:", err);
} }
}; };
@ -126,12 +157,12 @@ const AuditLogs = (): ReactElement => {
if (response.success) { if (response.success) {
const options = response.data.map((rt: any) => ({ const options = response.data.map((rt: any) => ({
value: rt.value, value: rt.value,
label: rt.label label: rt.label,
})); }));
setResourceTypes(options); setResourceTypes(options);
} }
} catch (err) { } catch (err) {
console.error('Failed to load resource types:', err); console.error("Failed to load resource types:", err);
} }
}; };
@ -142,7 +173,7 @@ const AuditLogs = (): ReactElement => {
setModules(response.data || []); setModules(response.data || []);
} }
} catch (err) { } catch (err) {
console.error('Failed to load modules:', err); console.error("Failed to load modules:", err);
} }
}; };
@ -174,16 +205,18 @@ const AuditLogs = (): ReactElement => {
endDate: endDate || null, endDate: endDate || null,
search: debouncedSearch || null, search: debouncedSearch || null,
}, },
orderBy orderBy,
); );
if (response.success) { if (response.success) {
setAuditLogs(response.data); setAuditLogs(response.data);
setPagination(response.pagination); setPagination(response.pagination);
} else { } else {
setError('Failed to load audit logs'); setError("Failed to load audit logs");
} }
} catch (err: any) { } catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load audit logs'); setError(
err?.response?.data?.error?.message || "Failed to load audit logs",
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -201,29 +234,44 @@ const AuditLogs = (): ReactElement => {
// Fetch audit logs on mount and when pagination/filters change // Fetch audit logs on mount and when pagination/filters change
useEffect(() => { useEffect(() => {
fetchAuditLogs(); fetchAuditLogs();
}, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, moduleFilter, startDate, endDate, orderBy, debouncedSearch]); }, [
currentPage,
limit,
tenantFilter,
methodFilter,
actionFilter,
resourceTypeFilter,
moduleFilter,
startDate,
endDate,
orderBy,
debouncedSearch,
]);
// Handle Export // Handle Export
const handleExport = async (): Promise<void> => { const handleExport = async (): Promise<void> => {
try { try {
const response = await auditLogService.export({ const response = await auditLogService.export({
format: 'json', format: "json",
startDate: startDate || undefined, startDate: startDate || undefined,
endDate: endDate || undefined, endDate: endDate || undefined,
tenantId: tenantFilter || undefined tenantId: tenantFilter || undefined,
}); });
if (response.success) { if (response.success) {
const blob = new Blob([JSON.stringify(response.data.records, null, 2)], { type: 'application/json' }); const blob = new Blob(
[JSON.stringify(response.data.records, null, 2)],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = `platform-audit-logs-${new Date().toISOString().split('T')[0]}.json`; link.download = `platform-audit-logs-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
} catch (err: any) { } catch (err: any) {
alert('Export failed: ' + err.message); alert("Export failed: " + err.message);
} }
}; };
@ -242,41 +290,45 @@ const AuditLogs = (): ReactElement => {
// Define table columns // Define table columns
const columns: Column<AuditLog>[] = [ const columns: Column<AuditLog>[] = [
{ {
key: 'created_at', key: "created_at",
label: 'Timestamp', label: "Timestamp",
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">{formatDate(log.created_at)}</span> <span className="text-sm font-normal text-[#0f1724] whitespace-nowrap">
{formatDate(log.created_at)}
</span>
), ),
mobileLabel: 'Time', mobileLabel: "Time",
}, },
{ {
key: 'tenant', key: "tenant",
label: 'Tenant', label: "Tenant",
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#0f1724]"> <span className="text-sm font-normal text-[#0f1724]">
{log.tenant ? log.tenant.name : 'N/A'} {log.tenant ? log.tenant.name : "N/A"}
</span> </span>
), ),
}, },
{ {
key: 'resource_type', key: "resource_type",
label: 'Resource Type', label: "Resource Type",
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">{log.resource_type}</span> <span className="text-sm font-normal text-[#0f1724]">
{log.resource_type}
</span>
), ),
}, },
{ {
key: 'module', key: "module",
label: 'Module', label: "Module",
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#475569]"> <span className="text-sm font-normal text-[#475569]">
{log.module?.name || 'Platform'} {log.module?.name || "Platform"}
</span> </span>
), ),
}, },
{ {
key: 'action', key: "action",
label: 'Action', label: "Action",
render: (log) => ( render: (log) => (
<StatusBadge variant={getActionVariant(log.action)}> <StatusBadge variant={getActionVariant(log.action)}>
{log.action} {log.action}
@ -284,36 +336,40 @@ const AuditLogs = (): ReactElement => {
), ),
}, },
{ {
key: 'user', key: "user",
label: 'User', label: "User",
render: (log) => ( render: (log) => (
<span className="text-sm font-normal text-[#0f1724]"> <span className="text-sm font-normal text-[#0f1724]">
{log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')} {log.user
? `${log.user.first_name} ${log.user.last_name}`
: log.user_email || "N/A"}
</span> </span>
), ),
}, },
{ {
key: 'request_method', key: "request_method",
label: 'Method', label: "Method",
render: (log) => ( render: (log) => (
<StatusBadge variant={getMethodVariant(log.request_method)}> <StatusBadge variant={getMethodVariant(log.request_method)}>
{log.request_method ? log.request_method.toUpperCase() : 'N/A'} {log.request_method ? log.request_method.toUpperCase() : "N/A"}
</StatusBadge> </StatusBadge>
), ),
}, },
{ {
key: 'response_status', key: "response_status",
label: 'Status', label: "Status",
render: (log) => ( render: (log) => (
<span className={`text-sm font-normal ${getStatusColor(log.response_status)}`}> <span
{log.response_status || 'N/A'} className={`text-sm font-normal ${getStatusColor(log.response_status)}`}
>
{log.response_status || "N/A"}
</span> </span>
), ),
}, },
{ {
key: 'actions', key: "actions",
label: 'Actions', label: "Actions",
align: 'right', align: "right",
render: (log) => ( render: (log) => (
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@ -333,8 +389,12 @@ const AuditLogs = (): ReactElement => {
<div className="p-4"> <div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3"> <div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{log.resource_type}</h3> <h3 className="text-sm font-medium text-[#0f1724] truncate">
<p className="text-xs text-[#9aa6b2] mb-1">{log.tenant ? log.tenant.name : 'System'}</p> {log.resource_type}
</h3>
<p className="text-xs text-[#9aa6b2] mb-1">
{log.tenant ? log.tenant.name : "System"}
</p>
<div className="mt-1"> <div className="mt-1">
<StatusBadge variant={getActionVariant(log.action)}> <StatusBadge variant={getActionVariant(log.action)}>
{log.action} {log.action}
@ -352,22 +412,30 @@ const AuditLogs = (): ReactElement => {
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3 text-xs">
<div> <div>
<span className="text-[#9aa6b2]">Timestamp:</span> <span className="text-[#9aa6b2]">Timestamp:</span>
<p className="text-[#0f1724] font-normal mt-1">{formatDate(log.created_at)}</p> <p className="text-[#0f1724] font-normal mt-1">
{formatDate(log.created_at)}
</p>
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">Module:</span> <span className="text-[#9aa6b2]">Module:</span>
<p className="text-[#0f1724] font-normal mt-1">{log.module?.name || 'Platform'}</p> <p className="text-[#0f1724] font-normal mt-1">
{log.module?.name || "Platform"}
</p>
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">User:</span> <span className="text-[#9aa6b2]">User:</span>
<p className="text-[#0f1724] font-normal mt-1"> <p className="text-[#0f1724] font-normal mt-1">
{log.user ? `${log.user.first_name} ${log.user.last_name}` : (log.user_email || 'N/A')} {log.user
? `${log.user.first_name} ${log.user.last_name}`
: log.user_email || "N/A"}
</p> </p>
</div> </div>
<div> <div>
<span className="text-[#9aa6b2]">Status:</span> <span className="text-[#9aa6b2]">Status:</span>
<p className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}> <p
{log.response_status || 'N/A'} className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}
>
{log.response_status || "N/A"}
</p> </p>
</div> </div>
</div> </div>
@ -378,8 +446,9 @@ const AuditLogs = (): ReactElement => {
<Layout <Layout
currentPage="Audit Logs" currentPage="Audit Logs"
pageHeader={{ pageHeader={{
title: 'Platform Audit Logs', title: "Platform Audit Logs",
description: 'Global monitoring of all actions, logins, and changes across all tenants in the platform.', description:
"Global monitoring of all actions, logins, and changes across all tenants in the platform.",
}} }}
> >
{/* Table Container */} {/* Table Container */}
@ -404,7 +473,7 @@ const AuditLogs = (): ReactElement => {
{/* Tenant Selector */} {/* Tenant Selector */}
<FilterDropdown <FilterDropdown
label="Tenant" label="Tenant"
options={tenants.map(t => ({ value: t.id, label: t.name }))} options={tenants.map((t) => ({ value: t.id, label: t.name }))}
value={tenantFilter} value={tenantFilter}
onChange={(value) => { onChange={(value) => {
setTenantFilter(value as string | null); setTenantFilter(value as string | null);
@ -417,11 +486,14 @@ const AuditLogs = (): ReactElement => {
<FilterDropdown <FilterDropdown
label="Action" label="Action"
options={[ options={[
{ value: 'LOGIN', label: 'LOGIN' }, { value: "LOGIN", label: "LOGIN" },
{ value: 'CREATE', label: 'CREATE' }, { value: "LOGOUT", label: "LOGOUT" },
{ value: 'UPDATE', label: 'UPDATE' }, { value: "CREATE", label: "CREATE" },
{ value: 'DELETE', label: 'DELETE' }, { value: "UPDATE", label: "UPDATE" },
{ value: 'SUBMIT', label: 'SUBMIT' }, { value: "DELETE", label: "DELETE" },
{ value: "SUBMIT", label: "SUBMIT" },
{ value: "APPROVE", label: "APPROVE" },
{ value: "REJECT", label: "REJECT" },
]} ]}
value={actionFilter} value={actionFilter}
onChange={(value) => { onChange={(value) => {
@ -448,10 +520,10 @@ const AuditLogs = (): ReactElement => {
type="button" type="button"
onClick={() => setShowMoreFilters((open) => !open)} onClick={() => setShowMoreFilters((open) => !open)}
className={cn( className={cn(
'inline-flex items-center gap-1.5 h-10 px-3 rounded-md text-sm font-medium border bg-white transition-colors', "inline-flex items-center gap-1.5 h-10 px-3 rounded-md text-sm font-medium border bg-white transition-colors",
showMoreFilters || hasExtraFilters showMoreFilters || hasExtraFilters
? 'border-[rgba(8,76,200,0.35)] text-[#0f1724]' ? "border-[rgba(8,76,200,0.35)] text-[#0f1724]"
: 'border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30' : "border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30",
)} )}
style={ style={
showMoreFilters || hasExtraFilters showMoreFilters || hasExtraFilters
@ -463,8 +535,8 @@ const AuditLogs = (): ReactElement => {
More filters More filters
<ChevronDown <ChevronDown
className={cn( className={cn(
'w-3.5 h-3.5 shrink-0 opacity-70 transition-transform', "w-3.5 h-3.5 shrink-0 opacity-70 transition-transform",
showMoreFilters && 'rotate-180' showMoreFilters && "rotate-180",
)} )}
/> />
</button> </button>
@ -501,10 +573,10 @@ const AuditLogs = (): ReactElement => {
<FilterDropdown <FilterDropdown
label="Method" label="Method"
options={[ options={[
{ value: 'GET', label: 'GET' }, { value: "GET", label: "GET" },
{ value: 'POST', label: 'POST' }, { value: "POST", label: "POST" },
{ value: 'PUT', label: 'PUT' }, { value: "PUT", label: "PUT" },
{ value: 'DELETE', label: 'DELETE' }, { value: "DELETE", label: "DELETE" },
]} ]}
value={methodFilter} value={methodFilter}
onChange={(value) => { onChange={(value) => {
@ -517,8 +589,8 @@ const AuditLogs = (): ReactElement => {
<FilterDropdown <FilterDropdown
label="Sort" label="Sort"
options={[ options={[
{ value: ['created_at', 'desc'], label: 'Newest First' }, { value: ["created_at", "desc"], label: "Newest First" },
{ value: ['created_at', 'asc'], label: 'Oldest First' }, { value: ["created_at", "asc"], label: "Oldest First" },
]} ]}
value={orderBy} value={orderBy}
onChange={(value) => { onChange={(value) => {
@ -533,7 +605,9 @@ const AuditLogs = (): ReactElement => {
<div className="flex flex-wrap items-center gap-4 md:gap-6"> <div className="flex flex-wrap items-center gap-4 md:gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">Start Date:</span> <span className="text-xs font-medium text-[#475569]">
Start Date:
</span>
<input <input
type="date" type="date"
value={startDate} value={startDate}
@ -545,7 +619,9 @@ const AuditLogs = (): ReactElement => {
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">End Date:</span> <span className="text-xs font-medium text-[#475569]">
End Date:
</span>
<input <input
type="date" type="date"
value={endDate} value={endDate}
@ -560,19 +636,27 @@ const AuditLogs = (): ReactElement => {
</div> </div>
)} )}
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || moduleFilter || methodFilter || search || orderBy) && ( {(startDate ||
endDate ||
tenantFilter ||
actionFilter ||
resourceTypeFilter ||
moduleFilter ||
methodFilter ||
search ||
orderBy) && (
<div className="flex justify-end pt-1"> <div className="flex justify-end pt-1">
<button <button
onClick={() => { onClick={() => {
setStartDate(''); setStartDate("");
setEndDate(''); setEndDate("");
setTenantFilter(null); setTenantFilter(null);
setActionFilter(null); setActionFilter(null);
setResourceTypeFilter(null); setResourceTypeFilter(null);
setModuleFilter(null); setModuleFilter(null);
setMethodFilter(null); setMethodFilter(null);
setOrderBy(null); setOrderBy(null);
setSearch(''); setSearch("");
setShowMoreFilters(false); setShowMoreFilters(false);
setCurrentPage(1); setCurrentPage(1);
}} }}

View File

@ -11,7 +11,6 @@ import {
CreditCard, CreditCard,
Edit, Edit,
Settings, Settings,
Image as ImageIcon,
Building2, Building2,
BadgeCheck, BadgeCheck,
GitBranch, GitBranch,
@ -20,19 +19,18 @@ import { Layout } from "@/components/layout/Layout";
import { import {
StatusBadge, StatusBadge,
DataTable, DataTable,
Pagination,
WorkflowDefinitionsTable, WorkflowDefinitionsTable,
SuppliersTable, SuppliersTable,
type Column, type Column,
} from "@/components/shared"; } from "@/components/shared";
import { UsersTable, RolesTable } from "@/components/superadmin"; import { UsersTable, RolesTable } from "@/components/superadmin";
import { tenantService } from "@/services/tenant-service"; import { tenantService } from "@/services/tenant-service";
import { auditLogService } from "@/services/audit-log-service";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import type { Tenant } from "@/types/tenant"; import type { Tenant } from "@/types/tenant";
import type { AuditLog } from "@/types/audit-log";
import type { MyModule } from "@/types/module"; import type { MyModule } from "@/types/module";
import { formatDate } from "@/utils/format-date"; import { formatDate } from "@/utils/format-date";
import AuditLogs from "@/pages/tenant/AuditLogs";
import TenantSettings from "@/pages/tenant/Settings";
import DepartmentsTable from "@/components/superadmin/DepartmentsTable"; import DepartmentsTable from "@/components/superadmin/DepartmentsTable";
import DesignationsTable from "@/components/superadmin/DesignationsTable"; import DesignationsTable from "@/components/superadmin/DesignationsTable";
@ -120,25 +118,6 @@ const TenantDetails = (): ReactElement => {
// Modules tab state - using assignedModules from tenant response // Modules tab state - using assignedModules from tenant response
// Audit logs tab state
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [auditLogsLoading, setAuditLogsLoading] = useState<boolean>(false);
const [auditLogsPage, setAuditLogsPage] = useState<number>(1);
const [auditLogsLimit] = useState<number>(10);
const [auditLogsPagination, setAuditLogsPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: 10,
total: 0,
totalPages: 1,
hasMore: false,
});
// Fetch tenant details // Fetch tenant details
useEffect(() => { useEffect(() => {
const fetchTenant = async (): Promise<void> => { const fetchTenant = async (): Promise<void> => {
@ -165,35 +144,6 @@ const TenantDetails = (): ReactElement => {
fetchTenant(); fetchTenant();
}, [id]); }, [id]);
// Fetch audit logs for this tenant
const fetchAuditLogs = async (): Promise<void> => {
if (!id) return;
try {
setAuditLogsLoading(true);
const response = await auditLogService.getAll(
auditLogsPage,
auditLogsLimit,
{ tenant_id: id },
["created_at", "DESC"]
);
if (response.success) {
setAuditLogs(response.data);
setAuditLogsPagination(response.pagination);
}
} catch (err: any) {
console.error("Failed to load audit logs:", err);
} finally {
setAuditLogsLoading(false);
}
};
// Fetch data when tab changes
useEffect(() => {
if (activeTab === "audit-logs" && id) {
fetchAuditLogs();
}
}, [activeTab, id, auditLogsPage]);
// Calculate stats for overview // Calculate stats for overview
const stats = useMemo(() => { const stats = useMemo(() => {
if (!tenant) return null; if (!tenant) return null;
@ -369,19 +319,12 @@ const TenantDetails = (): ReactElement => {
</div> </div>
)} )}
{activeTab === "modules" && id && <ModulesTab tenantId={id} />} {activeTab === "modules" && id && <ModulesTab tenantId={id} />}
{activeTab === "settings" && tenant && ( {activeTab === "settings" && id && (
<SettingsTab tenant={tenant} /> <TenantSettings customTenantId={id} hideLayout={true} />
)} )}
{activeTab === "license" && <LicenseTab tenant={tenant} />} {activeTab === "license" && <LicenseTab tenant={tenant} />}
{activeTab === "audit-logs" && ( {activeTab === "audit-logs" && id && (
<AuditLogsTab <AuditLogs customTenantId={id} hideLayout={true} />
auditLogs={auditLogs}
isLoading={auditLogsLoading}
pagination={auditLogsPagination}
currentPage={auditLogsPage}
limit={auditLogsLimit}
onPageChange={setAuditLogsPage}
/>
)} )}
{activeTab === "billing" && <BillingTab tenant={tenant} />} {activeTab === "billing" && <BillingTab tenant={tenant} />}
</div> </div>
@ -684,362 +627,6 @@ const LicenseTab = ({ tenant: _tenant }: LicenseTabProps): ReactElement => {
); );
}; };
// Audit Logs Tab Component
interface AuditLogsTabProps {
auditLogs: AuditLog[];
isLoading: boolean;
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
};
currentPage: number;
limit: number;
onPageChange: (page: number) => void;
}
const AuditLogsTab = ({
auditLogs,
isLoading,
pagination,
currentPage,
limit,
onPageChange,
}: AuditLogsTabProps): ReactElement => {
const columns: Column<AuditLog>[] = [
{
key: "action",
label: "Action",
render: (log) => (
<span className="text-sm font-medium text-[#0f1724]">{log.action}</span>
),
},
{
key: "resource_type",
label: "Resource",
render: (log) => (
<span className="text-sm font-normal text-[#6b7280]">
{log.resource_type}
</span>
),
},
{
key: "user",
label: "User",
render: (log) => (
<span className="text-sm font-normal text-[#6b7280]">
{log.user ? `${log.user.first_name} ${log.user.last_name}` : "System"}
</span>
),
},
{
key: "request_method",
label: "Method",
render: (log) => (
<span className="text-sm font-normal text-[#6b7280]">
{log.request_method || "N/A"}
</span>
),
},
{
key: "response_status",
label: "Status",
render: (log) => (
<span
className={`text-sm font-medium ${
log.response_status &&
log.response_status >= 200 &&
log.response_status < 300
? "text-green-600"
: log.response_status && log.response_status >= 400
? "text-red-600"
: "text-gray-600"
}`}
>
{log.response_status || "N/A"}
</span>
),
},
{
key: "created_at",
label: "Date",
render: (log) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(log.created_at)}
</span>
),
},
];
return (
<div className="flex flex-col gap-4">
{/* <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#0f1724]">Audit Logs</h3>
</div> */}
<DataTable
columns={columns}
data={auditLogs}
keyExtractor={(log) => log.id}
isLoading={isLoading}
/>
{pagination.totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={onPageChange}
onLimitChange={() => {}}
/>
)}
</div>
);
};
// Settings Tab Component
interface SettingsTabProps {
tenant: Tenant;
}
const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [primaryColor, setPrimaryColor] = useState<string>("#112868");
const [secondaryColor, setSecondaryColor] = useState<string>("#23DCE1");
const [accentColor, setAccentColor] = useState<string>("#084CC8");
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (2MB max)
if (file.size > 2 * 1024 * 1024) {
alert("Logo file size must be less than 2MB");
return;
}
// Validate file type
const validTypes = [
"image/png",
"image/svg+xml",
"image/jpeg",
"image/jpg",
];
if (!validTypes.includes(file.type)) {
alert("Logo must be PNG, SVG, or JPG format");
return;
}
setLogoFile(file);
}
};
const handleFaviconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (500KB max)
if (file.size > 500 * 1024) {
alert("Favicon file size must be less than 500KB");
return;
}
// Validate file type
const validTypes = [
"image/x-icon",
"image/png",
"image/vnd.microsoft.icon",
];
if (!validTypes.includes(file.type)) {
alert("Favicon must be ICO or PNG format");
return;
}
setFaviconFile(file);
}
};
return (
<div className="flex flex-col gap-6">
{/* Branding Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">
{/* Section Header */}
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-[#0f1724]">Branding</h3>
<p className="text-sm font-normal text-[#9ca3af]">
Customize logo, favicon, and colors for this tenant experience.
</p>
</div>
{/* Logo and Favicon Upload */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Company Logo
</label>
<label
htmlFor="logo-upload"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className="text-sm font-medium text-[#0f1724]">
Upload Logo
</span>
<span className="text-xs font-normal text-[#9ca3af]">
PNG, SVG, JPG up to 2MB.
</span>
</div>
<input
id="logo-upload"
type="file"
accept="image/png,image/svg+xml,image/jpeg,image/jpg"
onChange={handleLogoChange}
className="hidden"
/>
</label>
{logoFile && (
<div className="text-xs text-[#6b7280] mt-1">
Selected: {logoFile.name}
</div>
)}
{/* <img src={tenant.logo} alt="" /> */}
</div>
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Favicon
</label>
<label
htmlFor="favicon-upload"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className="text-sm font-medium text-[#0f1724]">
Upload Favicon
</span>
<span className="text-xs font-normal text-[#9ca3af]">
ICO or PNG up to 500KB.
</span>
</div>
<input
id="favicon-upload"
type="file"
accept="image/x-icon,image/png,image/vnd.microsoft.icon"
onChange={handleFaviconChange}
className="hidden"
/>
</label>
{faviconFile && (
<div className="text-xs text-[#6b7280] mt-1">
Selected: {faviconFile.name}
</div>
)}
</div>
</div>
{/* Primary Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Primary Color
</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: primaryColor }}
/>
<div className="flex-1">
<input
type="text"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent"
placeholder="#112868"
/>
</div>
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for navigation, headers, and key actions.
</p>
</div>
{/* Secondary and Accent Colors */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Secondary Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Secondary Color
</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: secondaryColor }}
/>
<div className="flex-1">
<input
type="text"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent"
placeholder="#23DCE1"
/>
</div>
<input
type="color"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for highlights and supporting elements.
</p>
</div>
{/* Accent Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Accent Color
</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: accentColor }}
/>
<div className="flex-1">
<input
type="text"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent"
placeholder="#084CC8"
/>
</div>
<input
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for alerts and special notices.
</p>
</div>
</div>
</div>
</div>
);
};
// Billing Tab Component // Billing Tab Component
interface BillingTabProps { interface BillingTabProps {

View File

@ -9,6 +9,7 @@ import {
DataTable, DataTable,
Pagination, Pagination,
FilterDropdown, FilterDropdown,
SearchBox,
type Column, type Column,
} from '@/components/shared'; } from '@/components/shared';
// Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead) // Note: NewTenantModal, ViewTenantModal, EditTenantModal are now in @/components/superadmin (commented out - using wizard/details/edit pages instead)
@ -16,7 +17,6 @@ import { Plus, ArrowUpDown } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { tenantService } from '@/services/tenant-service'; import { tenantService } from '@/services/tenant-service';
import type { Tenant } from '@/types/tenant'; import type { Tenant } from '@/types/tenant';
// Helper function to get tenant initials // Helper function to get tenant initials
const getTenantInitials = (name: string): string => { const getTenantInitials = (name: string): string => {
const words = name.trim().split(/\s+/); const words = name.trim().split(/\s+/);
@ -81,6 +81,10 @@ const Tenants = (): ReactElement => {
const [statusFilter, setStatusFilter] = useState<string | null>(null); const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null); const [orderBy, setOrderBy] = useState<string[] | null>(null);
// Search state
const [search, setSearch] = useState<string>('');
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
// View, Edit, Delete modals // View, Edit, Delete modals
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead // const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead // const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead
@ -93,12 +97,13 @@ const Tenants = (): ReactElement => {
page: number, page: number,
itemsPerPage: number, itemsPerPage: number,
status: string | null = null, status: string | null = null,
sortBy: string[] | null = null sortBy: string[] | null = null,
searchQuery: string | null = null
): Promise<void> => { ): Promise<void> => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const response = await tenantService.getAll(page, itemsPerPage, status, sortBy); const response = await tenantService.getAll(page, itemsPerPage, status, sortBy, searchQuery);
if (response.success) { if (response.success) {
setTenants(response.data); setTenants(response.data);
setPagination(response.pagination); setPagination(response.pagination);
@ -113,8 +118,17 @@ const Tenants = (): ReactElement => {
}; };
useEffect(() => { useEffect(() => {
fetchTenants(currentPage, limit, statusFilter, orderBy); const timer = setTimeout(() => {
}, [currentPage, limit, statusFilter, orderBy]); setDebouncedSearch(search);
// We only reset to first page if we are actively searching.
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
fetchTenants(currentPage, limit, statusFilter, orderBy, debouncedSearch);
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch]);
// Commented out - using wizard instead // Commented out - using wizard instead
// const handleCreateTenant = async (data: { // const handleCreateTenant = async (data: {
@ -329,8 +343,15 @@ const Tenants = (): ReactElement => {
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */} {/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */} {/* Search & Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search tenants..."
/>
{/* Status Filter */} {/* Status Filter */}
<FilterDropdown <FilterDropdown
label="Status" label="Status"

View File

@ -7,9 +7,10 @@ import {
Pagination, Pagination,
FilterDropdown, FilterDropdown,
StatusBadge, StatusBadge,
SearchBox,
type Column, type Column,
} from '@/components/shared'; } from '@/components/shared';
import { Download, ArrowUpDown, Search } from 'lucide-react'; import { Download, ArrowUpDown } from 'lucide-react';
import { auditLogService } from '@/services/audit-log-service'; import { auditLogService } from '@/services/audit-log-service';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import type { AuditLog } from '@/types/audit-log'; import type { AuditLog } from '@/types/audit-log';
@ -17,6 +18,11 @@ import { useAppTheme } from '@/hooks/useAppTheme';
import { PrimaryButton } from '@/components/shared'; import { PrimaryButton } from '@/components/shared';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
export interface AuditLogsProps {
customTenantId?: string;
hideLayout?: boolean;
}
// Helper function to format date // Helper function to format date
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
const date = new Date(dateString); const date = new Date(dateString);
@ -59,11 +65,12 @@ const getStatusColor = (status: number | null): string => {
return 'text-[#6b7280]'; return 'text-[#6b7280]';
}; };
const AuditLogs = (): ReactElement => { const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}): ReactElement => {
const { primaryColor } = useAppTheme(); const { primaryColor } = useAppTheme();
const roles = useAppSelector((state) => state.auth.roles); const roles = useAppSelector((state) => state.auth.roles);
const tenantId = useAppSelector((state) => state.auth.tenantId); const authTenantId = useAppSelector((state) => state.auth.tenantId);
const isTenantAdmin = roles?.includes('tenant_admin'); const tenantId = customTenantId || authTenantId;
const isTenantAdmin = customTenantId ? true : roles?.includes('tenant_admin');
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]); const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
@ -376,16 +383,8 @@ const AuditLogs = (): ReactElement => {
</div> </div>
); );
return ( const content = (
<Layout <>
currentPage="Audit Logs"
pageHeader={{
title: isTenantAdmin ? 'System Audit Logs' : 'My Activity',
description: isTenantAdmin
? 'Monitor all activities and changes across the quality platform.'
: 'View a chronological history of your own actions on the platform.',
}}
>
{/* Table Container */} {/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */} {/* Table Header with Filters */}
@ -394,24 +393,12 @@ const AuditLogs = (): ReactElement => {
{/* Search and Filters */} {/* Search and Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{/* Global Search */} {/* Global Search */}
<div className="relative w-full md:w-64"> <SearchBox
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" /> value={search}
<input onChange={setSearch}
type="text" placeholder="Search logs & metadata..."
placeholder="Search logs & metadata..." containerClassName="relative w-full md:w-64"
value={search} />
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 transition-all bg-gray-50/30"
onFocus={(e) => {
e.currentTarget.style.borderColor = primaryColor;
e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
{isTenantAdmin && ( {isTenantAdmin && (
<> <>
@ -420,12 +407,26 @@ const AuditLogs = (): ReactElement => {
label="Action" label="Action"
options={[ options={[
{ value: 'LOGIN', label: 'LOGIN' }, { value: 'LOGIN', label: 'LOGIN' },
{ value: 'LOGOUT', label: 'LOGOUT' },
{ value: 'CREATE', label: 'CREATE' }, { value: 'CREATE', label: 'CREATE' },
{ value: 'UPDATE', label: 'UPDATE' }, { value: 'UPDATE', label: 'UPDATE' },
{ value: 'DELETE', label: 'DELETE' }, { value: 'DELETE', label: 'DELETE' },
{ value: 'SUBMIT', label: 'SUBMIT' }, { value: 'SUBMIT', label: 'SUBMIT' },
{ value: 'APPROVE', label: 'APPROVE' }, { value: 'APPROVE', label: 'APPROVE' },
{ value: 'REJECT', label: 'REJECT' }, { value: 'REJECT', label: 'REJECT' },
// {value: 'PUBLISH', label: 'PUBLISH'},
// {value: 'ARCHIVE', label: 'ARCHIVE'},
// {value: 'CHECKOUT', label: 'CHECKOUT'},
// {value: 'CHECKIN', label: 'CHECKIN'},
// {value: 'TRANSITION', label: 'TRANSITION'},
// {value: 'CANCEL', label: 'CANCEL'},
// {value: 'COMPLETE', label: 'COMPLETE'},
// {value: 'ACTIVATE', label: 'ACTIVATE'},
// {value: 'REVOKE', label: 'REVOKE'},
// {value: 'STATUS', label: 'STATUS'},
// {value: 'VOID', label: 'VOID'},
// {value: 'SEND', label: 'SEND'},
// {value: 'API_CALL', label: 'API_CALL'},
]} ]}
value={actionFilter} value={actionFilter}
onChange={(value) => { onChange={(value) => {
@ -583,6 +584,24 @@ const AuditLogs = (): ReactElement => {
auditLogId={selectedAuditLogId} auditLogId={selectedAuditLogId}
onLoadAuditLog={loadAuditLog} onLoadAuditLog={loadAuditLog}
/> />
</>
);
if (hideLayout) {
return content;
}
return (
<Layout
currentPage="Audit Logs"
pageHeader={{
title: isTenantAdmin ? 'System Audit Logs' : 'My Activity',
description: isTenantAdmin
? 'Monitor all activities and changes across the quality platform.'
: 'View a chronological history of your own actions on the platform.',
}}
>
{content}
</Layout> </Layout>
); );
}; };

View File

@ -15,8 +15,14 @@ const getBaseUrlWithProtocol = (): string => {
return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
}; };
const Settings = (): ReactElement => { export interface SettingsProps {
const tenantId = useAppSelector((state) => state.auth.tenantId); customTenantId?: string;
hideLayout?: boolean;
}
const Settings = ({ customTenantId, hideLayout = false }: SettingsProps = {}): ReactElement => {
const authTenantId = useAppSelector((state) => state.auth.tenantId);
const tenantId = customTenantId || authTenantId;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
@ -363,16 +369,18 @@ const Settings = (): ReactElement => {
if (response.success) { if (response.success) {
showToast.success("Settings updated successfully"); showToast.success("Settings updated successfully");
// Update theme in Redux // Update theme in Redux for the active session, if not editing a different tenant context
dispatch( if (!customTenantId || customTenantId === authTenantId) {
updateTheme({ dispatch(
logo_file_path: logoFilePath, updateTheme({
favicon_file_path: faviconFilePath, logo_file_path: logoFilePath,
primary_color: primaryColor, favicon_file_path: faviconFilePath,
secondary_color: secondaryColor, primary_color: primaryColor,
accent_color: accentColor, secondary_color: secondaryColor,
}), accent_color: accentColor,
); }),
);
}
// Update local tenant state // Update local tenant state
setTenant({ setTenant({
@ -431,22 +439,15 @@ const Settings = (): ReactElement => {
); );
} }
return ( const content = (
<Layout <div className="flex flex-col gap-6 w-full h-full">
currentPage="Settings" {error && (
pageHeader={{ <div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
title: "Settings", <p className="text-sm text-[#ef4444]">{error}</p>
description: "Manage your tenant settings", </div>
}} )}
>
<div className="flex flex-col gap-6">
{error && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</div>
)}
{/* Branding Section */} {/* Branding Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">
{/* Section Header */} {/* Section Header */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -707,6 +708,21 @@ const Settings = (): ReactElement => {
</div> </div>
</div> </div>
</div> </div>
);
if (hideLayout) {
return content;
}
return (
<Layout
currentPage="Settings"
pageHeader={{
title: "Settings",
description: "Manage your tenant settings",
}}
>
{content}
</Layout> </Layout>
); );
}; };

View File

@ -3,8 +3,8 @@ import apiClient from './api-client';
export interface DashboardStatistics { export interface DashboardStatistics {
totalTenants: number; totalTenants: number;
activeTenants: number; activeTenants: number;
totalUsers: number; // totalUsers: number;
activeSessions: number; // activeSessions: number;
registeredModules: number; registeredModules: number;
healthyModules: number; healthyModules: number;
} }

View File

@ -72,7 +72,8 @@ export const tenantService = {
page: number = 1, page: number = 1,
limit: number = 20, limit: number = 20,
status?: string | null, status?: string | null,
orderBy?: string[] | null orderBy?: string[] | null,
search?: string | null
): Promise<TenantsResponse> => { ): Promise<TenantsResponse> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('page', String(page)); params.append('page', String(page));
@ -85,6 +86,9 @@ export const tenantService = {
params.append('orderBy[]', orderBy[0]); params.append('orderBy[]', orderBy[0]);
params.append('orderBy[]', orderBy[1]); params.append('orderBy[]', orderBy[1]);
} }
if (search) {
params.append('search', search);
}
const response = await apiClient.get<TenantsResponse>(`/tenants?${params.toString()}`); const response = await apiClient.get<TenantsResponse>(`/tenants?${params.toString()}`);
return response.data; return response.data;
}, },