refactor: modularize tenant settings and audit logs for reuse in superadmin dashboard and implement SearchBox component
This commit is contained in:
parent
7eeee08318
commit
87db482697
42
src/components/shared/SearchBox.tsx
Normal file
42
src/components/shared/SearchBox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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[]>([]);
|
||||||
@ -21,57 +28,61 @@ export const StatsGrid = () => {
|
|||||||
{
|
{
|
||||||
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:
|
||||||
|
data.totalTenants > 0
|
||||||
? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate`
|
? `${Math.round((data.activeTenants / data.totalTenants) * 100)}% Rate`
|
||||||
: '0% Rate',
|
: "0% Rate",
|
||||||
variant: 'green',
|
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:
|
||||||
|
data.registeredModules > 0
|
||||||
? `${Math.round((data.healthyModules / data.registeredModules) * 100)}% Uptime`
|
? `${Math.round((data.healthyModules / data.registeredModules) * 100)}% Uptime`
|
||||||
: '0% Uptime',
|
: "0% Uptime",
|
||||||
variant: data.healthyModules === data.registeredModules && data.registeredModules > 0
|
variant:
|
||||||
? 'green'
|
data.healthyModules === data.registeredModules &&
|
||||||
: 'gray',
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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]" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search logs & metadata..."
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={setSearch}
|
||||||
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"
|
placeholder="Search logs & metadata..."
|
||||||
onFocus={(e) => {
|
containerClassName="relative w-full md:w-64"
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,7 +369,8 @@ 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
|
||||||
|
if (!customTenantId || customTenantId === authTenantId) {
|
||||||
dispatch(
|
dispatch(
|
||||||
updateTheme({
|
updateTheme({
|
||||||
logo_file_path: logoFilePath,
|
logo_file_path: logoFilePath,
|
||||||
@ -373,6 +380,7 @@ const Settings = (): ReactElement => {
|
|||||||
accent_color: accentColor,
|
accent_color: accentColor,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update local tenant state
|
// Update local tenant state
|
||||||
setTenant({
|
setTenant({
|
||||||
@ -431,15 +439,8 @@ const Settings = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Layout
|
<div className="flex flex-col gap-6 w-full h-full">
|
||||||
currentPage="Settings"
|
|
||||||
pageHeader={{
|
|
||||||
title: "Settings",
|
|
||||||
description: "Manage your tenant settings",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user