Implemented rbac
This commit is contained in:
@@ -40,6 +40,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
|
||||
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
|
||||
@@ -70,6 +71,7 @@ type OutcomeFormValues = z.infer<typeof outcomeSchema>;
|
||||
export const BeneficiariesPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport } = useRbac();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
||||
@@ -574,23 +576,26 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="beneficiaries"
|
||||
data={sortedBens}
|
||||
columns={benExportColumns}
|
||||
filename={`project_beneficiaries_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Beneficiaries Register"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingBen(null);
|
||||
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
|
||||
setIsBenModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Register Beneficiary</span>
|
||||
</button>
|
||||
{canAdd('beneficiaries') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingBen(null);
|
||||
benForm.reset({ name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 });
|
||||
setIsBenModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Register Beneficiary</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
|
||||
@@ -759,24 +764,27 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="beneficiaries"
|
||||
data={sortedComps}
|
||||
columns={compExportColumns}
|
||||
filename={`compliance_requirements_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Compliance Requirements"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingComp(null);
|
||||
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
|
||||
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
|
||||
setIsCompModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Add Compliance Item</span>
|
||||
</button>
|
||||
{canAdd('beneficiaries') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingComp(null);
|
||||
setCompTargetProjectId(selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id ? String(projects[0].id) : '');
|
||||
compForm.reset({ title: '', requirements: '', status: ApprovalStatus.Pending });
|
||||
setIsCompModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Add Compliance Item</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1017,8 +1025,13 @@ export const BeneficiariesPage: React.FC = () => {
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
<option
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries')}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === ApprovalStatus.Approved && !canApprove('beneficiaries') ? ' (Requires Update Permission)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { ApprovalStatusBadge, PriorityBadge } from '../components/ui/StatusBadge';
|
||||
import { Issue, ChangeRequest, ApprovalStatus, ActivityStatus, Priority } from '../types';
|
||||
import {
|
||||
@@ -109,6 +110,7 @@ type ResolveChangeFormValues = z.infer<typeof resolveChangeSchema>;
|
||||
export const ChangeManagementPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport } = useRbac();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||
const [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues');
|
||||
@@ -722,23 +724,26 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="change_management"
|
||||
data={sortedIssues}
|
||||
columns={issueExportCols}
|
||||
filename={`project_issues_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Project Issues Register"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIssue(null);
|
||||
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
|
||||
setIsIssueModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-md shadow-amber-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log Issue</span>
|
||||
</button>
|
||||
{canAdd('change_management') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingIssue(null);
|
||||
issueForm.reset({ title: '', description: '', priority: Priority.Medium, assignedTo: '' });
|
||||
setIsIssueModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-amber-600 to-orange-600 text-white hover:from-amber-500 hover:to-orange-500 shadow-md shadow-amber-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log Issue</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -920,28 +925,31 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="change_management"
|
||||
data={sortedChanges}
|
||||
columns={changeExportCols}
|
||||
filename={`change_requests_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Change Requests Register"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingChange(null);
|
||||
changeForm.reset({
|
||||
title: '',
|
||||
justification: '',
|
||||
revisedBudget: 0,
|
||||
revisionDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
setIsChangeModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
||||
>
|
||||
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Request Change</span>
|
||||
</button>
|
||||
{canAdd('change_management') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingChange(null);
|
||||
changeForm.reset({
|
||||
title: '',
|
||||
justification: '',
|
||||
revisedBudget: 0,
|
||||
revisionDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
setIsChangeModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-slate-800 text-white hover:bg-slate-700 border border-slate-700 shadow-md transition cursor-pointer"
|
||||
>
|
||||
<GitPullRequest className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>Request Change</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1014,18 +1022,22 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Check className="w-3 h-3" /> Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<XCircle className="w-3 h-3" /> Reject
|
||||
</button>
|
||||
{canApprove('change_management') && (
|
||||
<button
|
||||
onClick={() => openResolveChange(cr, ApprovalStatus.Approved)}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-emerald-300 bg-emerald-950/50 hover:bg-emerald-900/60 border border-emerald-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Check className="w-3 h-3" /> Approve
|
||||
</button>
|
||||
)}
|
||||
{canAdd('change_management') && (
|
||||
<button
|
||||
onClick={() => openResolveChange(cr, ApprovalStatus.Rejected)}
|
||||
className="px-2.5 py-1 text-xs font-semibold text-rose-300 bg-rose-950/50 hover:bg-rose-900/60 border border-rose-700/50 rounded-lg transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<XCircle className="w-3 h-3" /> Reject
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1510,7 +1522,9 @@ export const ChangeManagementPage: React.FC = () => {
|
||||
>
|
||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||
<option value={ApprovalStatus.Review}>Under Active Review</option>
|
||||
<option value={ApprovalStatus.Approved}>Approved & Enacted</option>
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApprove('change_management')}>
|
||||
Approved & Enacted {!canApprove('change_management') ? '(Requires Update Permission)' : ''}
|
||||
</option>
|
||||
<option value={ApprovalStatus.Rejected}>Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,7 @@ import { useToast } from '../components/ui/Toast';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
|
||||
import { RulesSection } from '../components/governance/RulesSection';
|
||||
@@ -122,6 +123,7 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
||||
export const GovernancePage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { sub, user, claims } = useAuth();
|
||||
const { canAdd, canApprove, canExport } = useRbac();
|
||||
const currentUserId = sub || user?.id || '';
|
||||
const { success, error: toastError } = useToast();
|
||||
|
||||
@@ -808,7 +810,7 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
{/* Tab Actions */}
|
||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
|
||||
{activeTab === 'organisations' && (
|
||||
{activeTab === 'organisations' && canAdd('governance') && (
|
||||
<button
|
||||
onClick={openCreateOrg}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -817,7 +819,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Register Organisation</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'portfolios' && (
|
||||
{activeTab === 'portfolios' && canAdd('governance') && (
|
||||
<button
|
||||
onClick={openCreatePortfolio}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -826,7 +828,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Create Portfolio</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'programmes' && (
|
||||
{activeTab === 'programmes' && canAdd('governance') && (
|
||||
<button
|
||||
onClick={openCreateProgramme}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -835,7 +837,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Create Programme</span>
|
||||
</button>
|
||||
)}
|
||||
{(activeTab === 'instruments' || activeTab === 'instructions') && (
|
||||
{(activeTab === 'instruments' || activeTab === 'instructions') && canAdd('governance') && (
|
||||
<button
|
||||
onClick={() => setIsInstructionCreateTriggered(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -844,7 +846,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Add Funding Instrument</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'rules' && (
|
||||
{activeTab === 'rules' && canAdd('governance') && (
|
||||
<button
|
||||
onClick={() => setIsRuleCreateTriggered(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
@@ -973,6 +975,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Refresh Directory</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Organisation Master Registry"
|
||||
subtitle="Official directory of verified recipient and funder entities"
|
||||
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1108,6 +1111,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Refresh Portfolios</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Funder Portfolios Registry"
|
||||
subtitle="High-level funding groupings and thematic capital allocations"
|
||||
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1239,6 +1243,7 @@ export const GovernancePage: React.FC = () => {
|
||||
<span>Refresh Programmes</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="governance"
|
||||
title="Strategic Programmes Hierarchy"
|
||||
subtitle="Operational funding vehicles linked to funder portfolios"
|
||||
filename={`pfm-programmes-${new Date().toISOString().split('T')[0]}`}
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useToast } from '../components/ui/Toast';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import {
|
||||
Award,
|
||||
Budget,
|
||||
@@ -98,6 +99,7 @@ type DisbursementFormValues = z.infer<typeof disbursementSchema>;
|
||||
export const GrantsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport } = useRbac();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
|
||||
|
||||
@@ -666,7 +668,7 @@ export const GrantsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative z-10 self-start md:self-center">
|
||||
{activeTab === 'awards' && (
|
||||
{activeTab === 'awards' && canAdd('grant_financials') && (
|
||||
<button
|
||||
onClick={openCreateAward}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||
@@ -675,7 +677,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Register Approved Award</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'budgets' && (
|
||||
{activeTab === 'budgets' && canAdd('grant_financials') && (
|
||||
<button
|
||||
onClick={() => setIsBudgetModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||
@@ -684,7 +686,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Add Budget Line</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTab === 'disbursements' && (
|
||||
{activeTab === 'disbursements' && canAdd('grant_financials') && (
|
||||
<button
|
||||
onClick={openCreateDisbursement}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 rounded-xl shadow-lg shadow-emerald-600/25 transition cursor-pointer"
|
||||
@@ -802,6 +804,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Refresh Awards</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Grant Awards Registry"
|
||||
subtitle="Official directory of approved beneficiary funding allocations"
|
||||
filename={`pfm-awards-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -915,6 +918,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Refresh Budgets</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Project Budgets Registry"
|
||||
subtitle="Detailed statutory project budget allocations and category lines"
|
||||
filename={`pfm-budgets-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -998,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<Edit2 className="w-3 h-3" />
|
||||
<span>Update</span>
|
||||
</button>
|
||||
{b.status !== ApprovalStatus.Approved && (
|
||||
{b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateBudgetMutation.isPending}
|
||||
@@ -1057,6 +1061,7 @@ export const GrantsPage: React.FC = () => {
|
||||
<span>Refresh Disbursements</span>
|
||||
</button>
|
||||
<ExportButton
|
||||
claim="grant_financials"
|
||||
title="Disbursement Tranches Ledger"
|
||||
subtitle="Milestone-linked funding releases and payment authorizations"
|
||||
filename={`pfm-disbursements-${new Date().toISOString().split('T')[0]}`}
|
||||
@@ -1154,14 +1159,16 @@ export const GrantsPage: React.FC = () => {
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => approveMutation.mutate(d.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Approve Payment
|
||||
</button>
|
||||
{canApprove('grant_financials') && (
|
||||
<button
|
||||
onClick={() => approveMutation.mutate(d.id)}
|
||||
disabled={approveMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
Approve Payment
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1555,7 +1562,9 @@ export const GrantsPage: React.FC = () => {
|
||||
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
|
||||
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
|
||||
>
|
||||
<option value={ApprovalStatus.Approved}>Approved</option>
|
||||
<option value={ApprovalStatus.Approved} disabled={!canApprove('grant_financials')}>
|
||||
Approved {!canApprove('grant_financials') ? '(Requires Update Permission)' : ''}
|
||||
</option>
|
||||
<option value={ApprovalStatus.Pending}>Pending Review</option>
|
||||
<option value={ApprovalStatus.Rejected}>Rejected / Blocked</option>
|
||||
</select>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useAuth } from '../auth/AuthProvider';
|
||||
import { BrandLogo } from '../components/ui/BrandLogo';
|
||||
|
||||
export const LandingPage: React.FC = () => {
|
||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation } = useAuth();
|
||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading, isPendingActivation, roles } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleEnterConsole = async () => {
|
||||
@@ -192,6 +192,13 @@ export const LandingPage: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
|
||||
<span>Assigned Role:</span>
|
||||
<span className="font-semibold uppercase tracking-wider text-blue-400 bg-blue-500/10 border border-blue-500/25 px-1.5 py-0.5 rounded text-[10px] truncate max-w-[200px]">
|
||||
{user?.role || (roles.length > 0 ? roles[0] : (claims.role as string) || 'User')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guidance Message */}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import {
|
||||
Project,
|
||||
@@ -149,6 +150,7 @@ export const evaluateProjectScheduleRisk = (
|
||||
export const ProjectsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canExport } = useRbac();
|
||||
|
||||
// Filter & Search States
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -477,13 +479,15 @@ export const ProjectsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 flex-wrap relative z-10 self-start md:self-center">
|
||||
<button
|
||||
onClick={handleOpenCreateProject}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Project</span>
|
||||
</button>
|
||||
{canAdd('projects_milestones') && (
|
||||
<button
|
||||
onClick={handleOpenCreateProject}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Project</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -628,6 +632,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
|
||||
{/* Export Button */}
|
||||
<ExportButton
|
||||
claim="projects_milestones"
|
||||
title="Projects & Milestones Register"
|
||||
subtitle="Work breakdown structure, contractual milestones, and schedule performance"
|
||||
filename="projects-milestones-register"
|
||||
|
||||
@@ -38,6 +38,9 @@ import {
|
||||
User as UserIcon,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { CreateUserModal } from '../components/users/CreateUserModal';
|
||||
import { UserSecurityModal } from '../components/users/UserSecurityModal';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import {
|
||||
getUsers,
|
||||
getUser,
|
||||
@@ -97,6 +100,23 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
|
||||
|
||||
const { canView, canAdd, canExport } = useRbac();
|
||||
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
|
||||
const canViewInvites = canView('settings_invites');
|
||||
const canViewNotifications = canView('settings_notifications');
|
||||
const canViewAudit = canView('settings_auditlog');
|
||||
|
||||
const canAddUser = canAdd('settings_users') || canAdd('tenant_self_service');
|
||||
const canAddInvite = canAdd('settings_invites');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 'users' && !canViewUsers) {
|
||||
if (canViewInvites) setActiveTab('invites');
|
||||
else if (canViewNotifications) setActiveTab('notifications');
|
||||
else if (canViewAudit) setActiveTab('audit');
|
||||
}
|
||||
}, [activeTab, canViewUsers, canViewInvites, canViewNotifications, canViewAudit]);
|
||||
|
||||
// ==========================================
|
||||
// 1. User Directory State
|
||||
// ==========================================
|
||||
@@ -105,6 +125,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [userRoleFilter, setUserRoleFilter] = useState<string>('all');
|
||||
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
|
||||
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
|
||||
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
|
||||
|
||||
const userFilter: UserFilterRequest = {
|
||||
email: userEmailSearch.trim() || undefined,
|
||||
@@ -572,77 +593,85 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'users'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span>User Directory ({usersList.length})</span>
|
||||
<HelpTooltip
|
||||
title="User Directory"
|
||||
content="Authorized system users, role memberships, and account lifecycle states."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
{canViewUsers && (
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'users'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span>User Directory ({usersList.length})</span>
|
||||
<HelpTooltip
|
||||
title="User Directory"
|
||||
content="Authorized system users, role memberships, and account lifecycle states."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('invites')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'invites'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>Communications & Invites ({invitesList.length})</span>
|
||||
<HelpTooltip
|
||||
title="Communications Dispatch"
|
||||
content="Statutory onboarding invitations and applicant notification ledger."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
{canViewInvites && (
|
||||
<button
|
||||
onClick={() => setActiveTab('invites')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'invites'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>Communications & Invites ({invitesList.length})</span>
|
||||
<HelpTooltip
|
||||
title="Communications Dispatch"
|
||||
content="Statutory onboarding invitations and applicant notification ledger."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('notifications')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'notifications'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
<span>Gateways & Notifications ({fetchedNotifications.length})</span>
|
||||
<HelpTooltip
|
||||
title="Notification Gateways"
|
||||
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
{canViewNotifications && (
|
||||
<button
|
||||
onClick={() => setActiveTab('notifications')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'notifications'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
<span>Gateways & Notifications ({fetchedNotifications.length})</span>
|
||||
<HelpTooltip
|
||||
title="Notification Gateways"
|
||||
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('audit')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'audit'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
<span>Audit & Telemetry Ledger ({auditLogs.length})</span>
|
||||
<HelpTooltip
|
||||
title="Audit Trail"
|
||||
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
{canViewAudit && (
|
||||
<button
|
||||
onClick={() => setActiveTab('audit')}
|
||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
|
||||
activeTab === 'audit'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
<span>Audit & Telemetry Ledger ({auditLogs.length})</span>
|
||||
<HelpTooltip
|
||||
title="Audit Trail"
|
||||
content="Comprehensive immutable audit log of administrative actions, statutory approvals, grant disbursements, and security events."
|
||||
size="sm"
|
||||
placement="top"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ============================================================== */}
|
||||
@@ -725,12 +754,25 @@ export const SettingsPage: React.FC = () => {
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
{canAddUser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateUserModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
title="Provision a new tenant user with Hashids Hex ID and role assignment"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Provision User</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ExportButton
|
||||
title="User Directory & Access Register"
|
||||
subtitle="Canonical directory of authorized users and account states"
|
||||
filename={`pfm-users-directory-${new Date().toISOString().split('T')[0]}`}
|
||||
columns={userExportColumns}
|
||||
data={sortedUsers}
|
||||
claim="settings_users"
|
||||
/>
|
||||
|
||||
<button
|
||||
@@ -830,10 +872,11 @@ export const SettingsPage: React.FC = () => {
|
||||
setSelectedUserForDetail(u);
|
||||
setIsUserDetailModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
|
||||
title="Manage User Roles, Claims, Status, and Security Sessions"
|
||||
>
|
||||
<Eye className="w-3 h-3 text-blue-400" />
|
||||
Inspect
|
||||
<Shield className="w-3 h-3 text-blue-400" />
|
||||
Manage Access
|
||||
</button>
|
||||
|
||||
{/* Status switch actions */}
|
||||
@@ -948,15 +991,18 @@ export const SettingsPage: React.FC = () => {
|
||||
filename={`pfm-sent-invitations-${new Date().toISOString().split('T')[0]}`}
|
||||
columns={inviteExportColumns}
|
||||
data={sortedInvites}
|
||||
claim="settings_invites"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setIsSendInviteModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Dispatch Invitation</span>
|
||||
</button>
|
||||
{canAddInvite && (
|
||||
<button
|
||||
onClick={() => setIsSendInviteModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Dispatch Invitation</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1227,6 +1273,7 @@ export const SettingsPage: React.FC = () => {
|
||||
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
|
||||
columns={notificationExportColumns}
|
||||
data={filteredNotifications}
|
||||
claim="settings_notifications"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1493,6 +1540,7 @@ export const SettingsPage: React.FC = () => {
|
||||
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
|
||||
columns={auditExportColumns}
|
||||
data={filteredAuditLogs}
|
||||
claim="settings_auditlog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2092,73 +2140,24 @@ export const SettingsPage: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* Modal: User Inspection */}
|
||||
{/* Modal: User Administration & Role/Claim/Session Control */}
|
||||
{/* ============================================================== */}
|
||||
<Modal
|
||||
<UserSecurityModal
|
||||
isOpen={isUserDetailModalOpen}
|
||||
onClose={() => setIsUserDetailModalOpen(false)}
|
||||
title="User Access & Security Details"
|
||||
>
|
||||
{selectedUserForDetail && (
|
||||
<div className="space-y-4 text-xs">
|
||||
{loadingUserFreshDetail ? (
|
||||
<div className="p-6 text-center text-slate-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
|
||||
Fetching user profile details...
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
|
||||
<span className="text-white font-semibold text-xs">
|
||||
{userFreshDetail?.email || selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
|
||||
<span className="font-mono text-slate-300 text-xs break-all">
|
||||
{userFreshDetail?.id || selectedUserForDetail.id}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
|
||||
<span className="font-mono text-slate-200 text-xs">
|
||||
{userFreshDetail?.idNumber || selectedUserForDetail.idNumber || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">System Role:</span>
|
||||
<UserRoleBadge role={userFreshDetail?.role ?? selectedUserForDetail.role} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).className}`}>
|
||||
{getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
|
||||
<span className="font-mono text-slate-300">
|
||||
{(userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt) ? formatDate((userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt)!) : 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onClose={() => {
|
||||
setIsUserDetailModalOpen(false);
|
||||
setSelectedUserForDetail(null);
|
||||
}}
|
||||
user={selectedUserForDetail}
|
||||
/>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsUserDetailModalOpen(false)}
|
||||
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{/* ============================================================== */}
|
||||
{/* Modal: Provision Tenant User */}
|
||||
{/* ============================================================== */}
|
||||
<CreateUserModal
|
||||
isOpen={isCreateUserModalOpen}
|
||||
onClose={() => setIsCreateUserModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* Modal: Notification Payload & Data Inspection */}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
import { ApprovalStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { SiteVisit, ApprovalStatus } from '../types';
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ type SiteVisitFormValues = z.infer<typeof siteVisitSchema>;
|
||||
export const SiteVisitsPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { canAdd, canApprove, canExport } = useRbac();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -349,28 +351,31 @@ export const SiteVisitsPage: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
claim="site_visits"
|
||||
data={sortedVisits}
|
||||
columns={visitExportCols}
|
||||
filename={`site_visits_${new Date().toISOString().split('T')[0]}`}
|
||||
title="Site Visits Register"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingVisit(null);
|
||||
visitForm.reset({
|
||||
inspectorName: '',
|
||||
visitedAt: new Date().toISOString().split('T')[0],
|
||||
findings: '',
|
||||
status: ApprovalStatus.Pending,
|
||||
});
|
||||
setIsVisitModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-md shadow-emerald-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log Site Visit</span>
|
||||
</button>
|
||||
{canAdd('site_visits') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingVisit(null);
|
||||
visitForm.reset({
|
||||
inspectorName: '',
|
||||
visitedAt: new Date().toISOString().split('T')[0],
|
||||
findings: '',
|
||||
status: ApprovalStatus.Pending,
|
||||
});
|
||||
setIsVisitModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-500 hover:to-teal-500 shadow-md shadow-emerald-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Log Site Visit</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -505,8 +510,13 @@ export const SiteVisitsPage: React.FC = () => {
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||
>
|
||||
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
<option
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.value === ApprovalStatus.Approved && !canApprove('site_visits')}
|
||||
>
|
||||
{opt.label}
|
||||
{opt.value === ApprovalStatus.Approved && !canApprove('site_visits') ? ' (Requires Update Permission)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -38,7 +38,10 @@ import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
||||
import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters';
|
||||
import { CreateUserModal } from '../components/users/CreateUserModal';
|
||||
import { UserSecurityModal } from '../components/users/UserSecurityModal';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
import { useRbac } from '../auth/rbac';
|
||||
|
||||
const createTenantSchema = z.object({
|
||||
name: z.string().min(2, 'Tenant name must be at least 2 characters').max(100, 'Tenant name too long'),
|
||||
@@ -265,10 +268,177 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
|
||||
);
|
||||
};
|
||||
|
||||
const TenantUsersSubList: React.FC<{
|
||||
tenantId: number | string;
|
||||
tenantName?: string | null;
|
||||
onProvisionUser: (tenantId: number | string) => void;
|
||||
onManageUser: (user: User) => void;
|
||||
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
|
||||
const { data: usersDirectory = [], isLoading, refetch } = useQuery<User[]>({
|
||||
queryKey: ['tenant-users', tenantId],
|
||||
queryFn: () => getUsers(1, 200),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const tenantUsers = useMemo(() => {
|
||||
return usersDirectory.filter((u) => {
|
||||
if (u.tenant_id !== undefined && u.tenant_id !== null) {
|
||||
return String(u.tenant_id) === String(tenantId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [usersDirectory, tenantId]);
|
||||
|
||||
const { canAdd } = useRbac();
|
||||
const canProvision = canAdd('tenant_management') || canAdd('tenant_self_service') || canAdd('settings_users');
|
||||
|
||||
return (
|
||||
<div className="py-4 px-6 border-l-4 border-indigo-500 bg-linear-to-r from-indigo-950/20 via-slate-900/40 to-slate-950/60 space-y-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
|
||||
<UserIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-xs font-bold text-white tracking-tight">
|
||||
Authorized Users for {tenantName || `Tenant #${tenantId}`}
|
||||
</h4>
|
||||
<span className="text-[10px] font-semibold text-indigo-300 bg-indigo-500/20 px-2 py-0.5 rounded-full border border-indigo-500/30">
|
||||
{tenantUsers.length} Users
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Users provisioned under Tenant #{tenantId} partition boundary. Manage roles, claims, and session security without DB access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 text-slate-400 hover:text-white rounded hover:bg-slate-800 transition cursor-pointer"
|
||||
title="Refresh user directory"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-indigo-400' : ''}`} />
|
||||
</button>
|
||||
{canProvision && (
|
||||
<button
|
||||
onClick={() => onProvisionUser(tenantId)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-500 hover:to-indigo-500 shadow-md shadow-blue-600/25 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Provision User for Tenant</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-800/80 bg-slate-900/40">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-900/80 border-b border-slate-800/80 text-slate-400 text-[11px] uppercase tracking-wider font-semibold">
|
||||
<th className="py-2 px-3">Email Address</th>
|
||||
<th className="py-2 px-3">User UUID</th>
|
||||
<th className="py-2 px-3">Id Number (Hex)</th>
|
||||
<th className="py-2 px-3">Role</th>
|
||||
<th className="py-2 px-3">Status</th>
|
||||
<th className="py-2 px-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-center text-slate-500">Loading tenant users...</td>
|
||||
</tr>
|
||||
) : tenantUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-center text-slate-500">No users provisioned under Tenant #{tenantId}.</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenantUsers.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-slate-800/30 transition">
|
||||
<td className="py-2 px-3 text-white font-medium">{u.email || '—'}</td>
|
||||
<td className="py-2 px-3 font-mono text-slate-400 text-[11px]">{u.id}</td>
|
||||
<td className="py-2 px-3 font-mono text-slate-300 text-[11px]">{u.idNumber || '—'}</td>
|
||||
<td className="py-2 px-3 text-slate-300">{u.role || 'Standard User'}</td>
|
||||
<td className="py-2 px-3"><ActivityStatusBadge status={u.status} /></td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<button
|
||||
onClick={() => onManageUser(u)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 rounded-lg transition cursor-pointer"
|
||||
>
|
||||
<ShieldCheck className="w-3 h-3 text-blue-400" />
|
||||
Manage Access & Roles
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TenantExpandedPanel: React.FC<{
|
||||
tenantId: number | string;
|
||||
tenantName?: string | null;
|
||||
onProvisionUser: (tenantId: number | string) => void;
|
||||
onManageUser: (user: User) => void;
|
||||
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
|
||||
const [activeTab, setActiveTab] = useState<'orgs' | 'users'>('orgs');
|
||||
|
||||
return (
|
||||
<div className="bg-slate-950/80 border-b border-slate-800">
|
||||
<div className="px-6 pt-3 flex gap-2 border-b border-slate-800/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('orgs')}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-t-lg transition flex items-center gap-1.5 cursor-pointer ${
|
||||
activeTab === 'orgs'
|
||||
? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
<span>Scoped Organisations</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-t-lg transition flex items-center gap-1.5 cursor-pointer ${
|
||||
activeTab === 'users'
|
||||
? 'bg-indigo-600/20 text-indigo-400 border-b-2 border-indigo-500'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
<span>Tenant Users & Administration</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'orgs' ? (
|
||||
<TenantOrganisationsSubList tenantId={tenantId} tenantName={tenantName} />
|
||||
) : (
|
||||
<TenantUsersSubList
|
||||
tenantId={tenantId}
|
||||
tenantName={tenantName}
|
||||
onProvisionUser={onProvisionUser}
|
||||
onManageUser={onManageUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TenantManagementPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { tenantId: currentTenantId, tenantName: currentTenantName } = useAuth();
|
||||
const { tenantId: currentTenantId, tenantName: currentTenantName, canManageTenants } = useAuth();
|
||||
const { canAdd, isUnrestricted, canApprove } = useRbac();
|
||||
const canCreateTenant = canManageTenants || (canAdd('tenant_management') && isUnrestricted);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
@@ -279,6 +449,10 @@ export const TenantManagementPage: React.FC = () => {
|
||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
|
||||
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
|
||||
const [createUserTenantId, setCreateUserTenantId] = useState<number | string | null>(null);
|
||||
const [securityUser, setSecurityUser] = useState<User | null>(null);
|
||||
const [isSecurityModalOpen, setIsSecurityModalOpen] = useState(false);
|
||||
|
||||
// Query Tenants
|
||||
const {
|
||||
@@ -460,15 +634,18 @@ export const TenantManagementPage: React.FC = () => {
|
||||
data={sortedData}
|
||||
columns={exportColumns}
|
||||
label="Export Tenants"
|
||||
claim="tenant_management"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Tenant</span>
|
||||
</button>
|
||||
{canCreateTenant && (
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Tenant</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -740,11 +917,22 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded Sub-List of Organisations using getTenantOrganisations */}
|
||||
{/* Expanded Sub-List of Organisations and Users */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-slate-950/70 border-b border-slate-800/80">
|
||||
<td colSpan={7} className="p-0">
|
||||
<TenantOrganisationsSubList tenantId={tenant.id} tenantName={tenant.name} />
|
||||
<TenantExpandedPanel
|
||||
tenantId={tenant.id}
|
||||
tenantName={tenant.name}
|
||||
onProvisionUser={(tId) => {
|
||||
setCreateUserTenantId(tId);
|
||||
setIsCreateUserModalOpen(true);
|
||||
}}
|
||||
onManageUser={(u) => {
|
||||
setSecurityUser(u);
|
||||
setIsSecurityModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -987,6 +1175,26 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Provision User Modal */}
|
||||
<CreateUserModal
|
||||
isOpen={isCreateUserModalOpen}
|
||||
onClose={() => {
|
||||
setIsCreateUserModalOpen(false);
|
||||
setCreateUserTenantId(null);
|
||||
}}
|
||||
defaultTenantId={createUserTenantId}
|
||||
/>
|
||||
|
||||
{/* User Security & Administration Modal */}
|
||||
<UserSecurityModal
|
||||
isOpen={isSecurityModalOpen}
|
||||
onClose={() => {
|
||||
setIsSecurityModalOpen(false);
|
||||
setSecurityUser(null);
|
||||
}}
|
||||
user={securityUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user