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

View File

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

View File

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

View File

@ -11,7 +11,6 @@ import {
CreditCard,
Edit,
Settings,
Image as ImageIcon,
Building2,
BadgeCheck,
GitBranch,
@ -20,19 +19,18 @@ import { Layout } from "@/components/layout/Layout";
import {
StatusBadge,
DataTable,
Pagination,
WorkflowDefinitionsTable,
SuppliersTable,
type Column,
} from "@/components/shared";
import { UsersTable, RolesTable } from "@/components/superadmin";
import { tenantService } from "@/services/tenant-service";
import { auditLogService } from "@/services/audit-log-service";
import { moduleService } from "@/services/module-service";
import type { Tenant } from "@/types/tenant";
import type { AuditLog } from "@/types/audit-log";
import type { MyModule } from "@/types/module";
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 DesignationsTable from "@/components/superadmin/DesignationsTable";
@ -120,25 +118,6 @@ const TenantDetails = (): ReactElement => {
// 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
useEffect(() => {
const fetchTenant = async (): Promise<void> => {
@ -165,35 +144,6 @@ const TenantDetails = (): ReactElement => {
fetchTenant();
}, [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
const stats = useMemo(() => {
if (!tenant) return null;
@ -369,19 +319,12 @@ const TenantDetails = (): ReactElement => {
</div>
)}
{activeTab === "modules" && id && <ModulesTab tenantId={id} />}
{activeTab === "settings" && tenant && (
<SettingsTab tenant={tenant} />
{activeTab === "settings" && id && (
<TenantSettings customTenantId={id} hideLayout={true} />
)}
{activeTab === "license" && <LicenseTab tenant={tenant} />}
{activeTab === "audit-logs" && (
<AuditLogsTab
auditLogs={auditLogs}
isLoading={auditLogsLoading}
pagination={auditLogsPagination}
currentPage={auditLogsPage}
limit={auditLogsLimit}
onPageChange={setAuditLogsPage}
/>
{activeTab === "audit-logs" && id && (
<AuditLogs customTenantId={id} hideLayout={true} />
)}
{activeTab === "billing" && <BillingTab tenant={tenant} />}
</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
interface BillingTabProps {

View File

@ -9,6 +9,7 @@ import {
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from '@/components/shared';
// 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 { tenantService } from '@/services/tenant-service';
import type { Tenant } from '@/types/tenant';
// Helper function to get tenant initials
const getTenantInitials = (name: string): string => {
const words = name.trim().split(/\s+/);
@ -81,6 +81,10 @@ const Tenants = (): ReactElement => {
const [statusFilter, setStatusFilter] = 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
// const [viewModalOpen, setViewModalOpen] = useState<boolean>(false); // Commented out - using details page instead
// const [editModalOpen, setEditModalOpen] = useState<boolean>(false); // Commented out - using edit page instead
@ -93,12 +97,13 @@ const Tenants = (): ReactElement => {
page: number,
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null
sortBy: string[] | null = null,
searchQuery: string | null = null
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await tenantService.getAll(page, itemsPerPage, status, sortBy);
const response = await tenantService.getAll(page, itemsPerPage, status, sortBy, searchQuery);
if (response.success) {
setTenants(response.data);
setPagination(response.pagination);
@ -113,8 +118,17 @@ const Tenants = (): ReactElement => {
};
useEffect(() => {
fetchTenants(currentPage, limit, statusFilter, orderBy);
}, [currentPage, limit, statusFilter, orderBy]);
const timer = setTimeout(() => {
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
// 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">
{/* 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">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Search & Filters */}
<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 */}
<FilterDropdown
label="Status"

View File

@ -7,9 +7,10 @@ import {
Pagination,
FilterDropdown,
StatusBadge,
SearchBox,
type Column,
} from '@/components/shared';
import { Download, ArrowUpDown, Search } from 'lucide-react';
import { Download, ArrowUpDown } from 'lucide-react';
import { auditLogService } from '@/services/audit-log-service';
import { moduleService } from '@/services/module-service';
import type { AuditLog } from '@/types/audit-log';
@ -17,6 +18,11 @@ import { useAppTheme } from '@/hooks/useAppTheme';
import { PrimaryButton } from '@/components/shared';
import { useAppSelector } from '@/hooks/redux-hooks';
export interface AuditLogsProps {
customTenantId?: string;
hideLayout?: boolean;
}
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
@ -59,11 +65,12 @@ const getStatusColor = (status: number | null): string => {
return 'text-[#6b7280]';
};
const AuditLogs = (): ReactElement => {
const AuditLogs = ({ customTenantId, hideLayout = false }: AuditLogsProps = {}): ReactElement => {
const { primaryColor } = useAppTheme();
const roles = useAppSelector((state) => state.auth.roles);
const tenantId = useAppSelector((state) => state.auth.tenantId);
const isTenantAdmin = roles?.includes('tenant_admin');
const authTenantId = useAppSelector((state) => state.auth.tenantId);
const tenantId = customTenantId || authTenantId;
const isTenantAdmin = customTenantId ? true : roles?.includes('tenant_admin');
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
@ -376,16 +383,8 @@ const AuditLogs = (): ReactElement => {
</div>
);
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.',
}}
>
const content = (
<>
{/* 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">
{/* Table Header with Filters */}
@ -394,24 +393,12 @@ const AuditLogs = (): ReactElement => {
{/* Search and Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<div className="relative w-full md:w-64">
<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..."
<SearchBox
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';
}}
onChange={setSearch}
placeholder="Search logs & metadata..."
containerClassName="relative w-full md:w-64"
/>
</div>
{isTenantAdmin && (
<>
@ -420,12 +407,26 @@ const AuditLogs = (): ReactElement => {
label="Action"
options={[
{ value: 'LOGIN', label: 'LOGIN' },
{ value: 'LOGOUT', label: 'LOGOUT' },
{ value: 'CREATE', label: 'CREATE' },
{ value: 'UPDATE', label: 'UPDATE' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'SUBMIT', label: 'SUBMIT' },
{ value: 'APPROVE', label: 'APPROVE' },
{ 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}
onChange={(value) => {
@ -583,6 +584,24 @@ const AuditLogs = (): ReactElement => {
auditLogId={selectedAuditLogId}
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>
);
};

View File

@ -15,8 +15,14 @@ const getBaseUrlWithProtocol = (): string => {
return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
};
const Settings = (): ReactElement => {
const tenantId = useAppSelector((state) => state.auth.tenantId);
export interface SettingsProps {
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 [isLoading, setIsLoading] = useState<boolean>(true);
@ -363,7 +369,8 @@ const Settings = (): ReactElement => {
if (response.success) {
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(
updateTheme({
logo_file_path: logoFilePath,
@ -373,6 +380,7 @@ const Settings = (): ReactElement => {
accent_color: accentColor,
}),
);
}
// Update local tenant state
setTenant({
@ -431,15 +439,8 @@ const Settings = (): ReactElement => {
);
}
return (
<Layout
currentPage="Settings"
pageHeader={{
title: "Settings",
description: "Manage your tenant settings",
}}
>
<div className="flex flex-col gap-6">
const content = (
<div className="flex flex-col gap-6 w-full h-full">
{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>
@ -707,6 +708,21 @@ const Settings = (): ReactElement => {
</div>
</div>
</div>
);
if (hideLayout) {
return content;
}
return (
<Layout
currentPage="Settings"
pageHeader={{
title: "Settings",
description: "Manage your tenant settings",
}}
>
{content}
</Layout>
);
};

View File

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

View File

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