diff --git a/GEMINI.md b/GEMINI.md index c2e85e3..9eccc89 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -90,6 +90,7 @@ PostFundManagement/ ### 2.2 Frontend Projects (React 19 / TypeScript) #### **`PostFundManagement.ConsoleUI`** (Internal Staff / Portfolio Administration Portal) +* **Package Manager**: **Yarn** (`yarn.lock` is authoritative; `npm` must not be used) * **Runtime & Bundler**: React 19.0.1, TypeScript 5.8.2, Vite 6.2.3 * **Styling**: Tailwind CSS v4.1.14 (`@tailwindcss/vite`, `@tailwindcss/postcss`) * **Routing**: React Router DOM v7.18.3 @@ -102,6 +103,7 @@ PostFundManagement/ * **Target Users**: Funding Administrators, Programme Managers, Finance Officers, M&E Officers, Risk Managers, System Administrators, Executive Leadership. #### **`PostFundManagement.PartnerUI`** (External Recipient / Grantee Portal) +* **Package Manager**: **Yarn** (`yarn.lock` is authoritative; `npm` must not be used) * **Runtime & Bundler**: React 19.0.1, TypeScript 7.0.2, Vite 8.3.0 * **Styling & Effects**: Tailwind CSS v4.3.3, `motion` 12.23.24, `ogl` 1.0.11 (WebGL visual elements like MoltenMetal, BorderGlow, SpecularButton) * **Routing**: React Router DOM v7.18.4 @@ -479,6 +481,15 @@ When adding or modifying code in this codebase, strictly follow these standards: 5. **Frontend State & API Sync**: * Use TanStack Query hooks for server caching, optimistic updates, and cache invalidation. * Forms must use `react-hook-form` with `zod` schemas enforcing mandatory fields at input time. +6. **Frontend Package Management (Yarn Mandatory)**: + * **Always use `yarn` instead of `npm`** across all frontend projects (`PostFundManagement.ConsoleUI` and `PostFundManagement.PartnerUI`). + * Never run `npm install`, `npm run`, or create/modify `package-lock.json`. + * Standard commands: + * `yarn install` or `yarn` to install dependencies. + * `yarn add ` / `yarn add -D ` to add dependencies. + * `yarn build` to trigger production builds. + * `yarn dev` to launch local development servers. + * `yarn test` for executing frontend tests. --- diff --git a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs index 15e610c..9a9798c 100644 --- a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs @@ -28,7 +28,6 @@ public class GetUsersEndpoint : IEndpoint .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status500InternalServerError) - .WithTags(EndpointTags.Users) - .CacheOutput(); + .WithTags(EndpointTags.Users); } -} \ No newline at end of file +} diff --git a/PostFundManagement.Application/Services/Governance/GovernanceService.cs b/PostFundManagement.Application/Services/Governance/GovernanceService.cs index a8ea813..265a2bf 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceService.cs @@ -208,9 +208,9 @@ public sealed class GovernanceService(IDbContextFactory co linkedUser!.IdNumber = request.IdNumber.Clean(); } - var userUpdated = await context.SaveChangesAsync(cancellationToken) > 0; + await context.SaveChangesAsync(cancellationToken); - return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess && userUpdated, UpdateError.Create("Failed to update organisation")); + return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess, UpdateError.Create("Failed to update organisation")); })); public async Task CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) => diff --git a/PostFundManagement.Application/Services/Governance/Records.cs b/PostFundManagement.Application/Services/Governance/Records.cs index a8f3d7c..8700b08 100644 --- a/PostFundManagement.Application/Services/Governance/Records.cs +++ b/PostFundManagement.Application/Services/Governance/Records.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Domain; +using PostFundManagement.Domain; namespace PostFundManagement.Application.Services.Governance; @@ -37,7 +37,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId, [Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string RegistrationNo, [Required][EmailAddress][MaxLength(LabelLength)] string Email, - [Required][MaxLength(LabelLength)] string IdNumber, + [MaxLength(LabelLength)] string? IdNumber, [Required] Guid OrganisationUser, [Required] OrganisationType Type, [Required] ActivityStatus Status); @@ -45,7 +45,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId, public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string RegistrationNo, [Required][EmailAddress][MaxLength(LabelLength)] string Email, - [Required][MaxLength(LabelLength)] string IdNumber, + [MaxLength(LabelLength)] string? IdNumber, [Required] Guid OrganisationUser, [Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany); diff --git a/PostFundManagement.Application/Services/Users/UserCacheService.cs b/PostFundManagement.Application/Services/Users/UserCacheService.cs index cee62f5..03d4890 100644 --- a/PostFundManagement.Application/Services/Users/UserCacheService.cs +++ b/PostFundManagement.Application/Services/Users/UserCacheService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; @@ -151,30 +151,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach })); public async Task> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) => - await Result.Try((Func>>)(async () => - { - if (!await featureManager.IsEnabledAsync(ServiceCachingFeature)) - return await service.GetUsersAsync(pagination, request, cancellationToken); - - var cacheKey = HashService.ToMd5Hash($"getusers-{pagination.Page}-{pagination.PageSize}-" + - $"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value; - - var cached = await cache.GetStringAsync(cacheKey, cancellationToken); - - if (string.IsNullOrWhiteSpace(cached)) - { - var usersResult = await service.GetUsersAsync(pagination, request, cancellationToken); - - if (usersResult.IsFailed) return usersResult; - - await cache.SetStringAsync(cacheKey, usersResult.Value.ToJson(), - cacheOptions, cancellationToken); - - return usersResult; - } - - return Result.Ok(cached.FromJson()); - })); + await service.GetUsersAsync(pagination, request, cancellationToken); public async Task LogoutUserAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) => await service.LogoutUserAsync(targetUserId, userId, cancellationToken); diff --git a/PostFundManagement.ConsoleUI/package.json b/PostFundManagement.ConsoleUI/package.json index 27b413f..33debf2 100644 --- a/PostFundManagement.ConsoleUI/package.json +++ b/PostFundManagement.ConsoleUI/package.json @@ -40,6 +40,8 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", "postcss": "^8.5.28", diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx index 2ab69e6..bdf88a7 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx @@ -82,7 +82,7 @@ export function ExportButton({ columns, data, tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, - userEmail: user?.profile?.email || (claims as any)?.email || '', + userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', }); }; @@ -96,7 +96,7 @@ export function ExportButton({ columns, data, tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, - userEmail: user?.profile?.email || (claims as any)?.email || '', + userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', }); }; diff --git a/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx b/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx index cfad194..fded33c 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx @@ -2,14 +2,15 @@ import React, { ReactNode, useEffect } from 'react'; import { X } from 'lucide-react'; import { HelpTooltip } from './HelpTooltip'; -interface ModalProps { +export interface ModalProps { isOpen: boolean; onClose: () => void; title: string; subtitle?: string; helpText?: ReactNode; children: ReactNode; - maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full'; + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full' | string; } export const Modal: React.FC = ({ @@ -20,6 +21,7 @@ export const Modal: React.FC = ({ helpText, children, maxWidth = 'lg', + size, }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -43,13 +45,20 @@ export const Modal: React.FC = ({ if (!isOpen) return null; - const maxWidthClass = { + const effectiveSize = (size || maxWidth) as string; + const maxWidthClassMap: Record = { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl', - }[maxWidth]; + '3xl': 'max-w-3xl', + '4xl': 'max-w-4xl', + '5xl': 'max-w-5xl', + '6xl': 'max-w-6xl', + full: 'max-w-full', + }; + const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg'; return (
diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ObfuscatedIdNumber.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ObfuscatedIdNumber.tsx new file mode 100644 index 0000000..bb82008 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/ui/ObfuscatedIdNumber.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { Eye, EyeOff, Copy, Check } from 'lucide-react'; +import { useToast } from './Toast'; + +export interface ObfuscatedIdNumberProps { + idNumber?: string | null; + className?: string; + allowToggle?: boolean; +} + +export function maskIdNumber(id: string): string { + const clean = id ? id.trim() : ''; + if (clean.length <= 4) { + return '••••'; + } + if (clean.length <= 8) { + return `${clean.slice(0, 2)}••••${clean.slice(-2)}`; + } + // SA ID (13 digits) or standard national IDs: keep first 6 (DOB), mask middle 5, show last 2 + return `${clean.slice(0, 6)}•••••${clean.slice(-2)}`; +} + +export function ObfuscatedIdNumber({ + idNumber, + className = '', + allowToggle = true, +}: ObfuscatedIdNumberProps): React.ReactElement { + const [isRevealed, setIsRevealed] = useState(false); + const [copied, setCopied] = useState(false); + const { success } = useToast(); + + if (!idNumber) { + return -; + } + + const rawId: string = idNumber; + + const handleCopy = async (e: React.MouseEvent): Promise => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(rawId); + setCopied(true); + success('Copied to Clipboard', 'ID Number copied.'); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard fallback + } + }; + + const handleToggle = (e: React.MouseEvent): void => { + e.stopPropagation(); + setIsRevealed((prev) => !prev); + }; + + const displayedText = isRevealed ? rawId : maskIdNumber(rawId); + + return ( +
+ {displayedText} + + {allowToggle && ( + + )} + + +
+ ); +} diff --git a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx index 4c95cec..e288d7b 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx @@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react'; import { Modal } from '../ui/Modal'; import { useToast } from '../ui/Toast'; -import { createUser, getSystemRoles, assignUserRole, getTenants } from '../../services/api'; +import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api'; import { useAuth } from '../../auth/AuthProvider'; import { useRbac } from '../../auth/rbac'; import { CreateUserRequest, Role, Tenant } from '../../types'; @@ -136,8 +136,22 @@ export const CreateUserModal: React.FC = ({ await createUser(values.tenantId, values.idNumber.trim(), payload); - // If an initial role was chosen and we have a newly generated user, - // invalidate query cache to refresh directory + if (values.initialRoleId) { + try { + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + const freshUsers = await getUsers(1, 1000, { email: values.email.trim() }); + const createdUser = freshUsers.find( + (u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase() + ); + if (createdUser) { + await assignUserRole(createdUser.id, values.initialRoleId); + } + } catch { + // Graceful fallback + } + } + return values; }, onSuccess: (values) => { diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx index b091721..787a0ed 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -32,6 +32,7 @@ import { getSystemClaims, } from '../../services/api'; import { HelpTooltip } from '../ui/HelpTooltip'; +import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber'; import { decodeIdNumberHex } from '../../utils/hashids'; import { User, Role, Claim, ActivityStatus } from '../../types'; import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters'; @@ -298,10 +299,14 @@ export const UserSecurityModal: React.FC = ({
Id Number: -
- {effectiveUser.idNumber || '—'} +
+ {decodedId && decodedId !== effectiveUser.idNumber && ( - (Decoded: {decodedId}) +
+ (Decoded: + + ) +
)}
diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index b755163..504ca92 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -21,6 +21,11 @@ import { FileText, Scale, Calendar, + UserPlus, + ChevronDown, + ChevronUp, + CheckCircle2, + Loader2, } from 'lucide-react'; import { getOrganisations, @@ -50,6 +55,10 @@ import { getProgrammeValue, getUsers, getUser, + createUser, + assignUserRole, + getSystemRoles, + getCurrentUserTenantId, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; @@ -72,6 +81,8 @@ import { OrganisationType, ActivityStatus, ApprovalStatus, + Role, + CreateUserRequest, CreateOrganisationRequest, CreatePortfolioRequest, CreateProgrammeRequest, @@ -96,8 +107,7 @@ const organisationSchema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters'), registrationNo: z.string().min(4, 'Registration number is mandatory'), email: z.string().email('Enter a valid email address'), - idNumber: z.string().min(4, 'Government issued ID number is required'), - organisationUser: z.string().min(1, 'Please select a linked user'), + organisationUser: z.string().min(1, 'Please select a linked executive or manager user'), type: z.coerce.number().min(1).max(27), status: z.coerce.number().optional(), }); @@ -232,6 +242,107 @@ export const GovernancePage: React.FC = () => { staleTime: 5 * 60 * 1000, }); + const { data: systemRoles = [] } = useQuery({ + queryKey: ['system-roles'], + queryFn: getSystemRoles, + enabled: isOrgModalOpen, + }); + + const assignableRolesForOrg = React.useMemo(() => { + return systemRoles.filter( + (r) => r.name.toLowerCase() !== 'admin' && r.id !== '1' + ); + }, [systemRoles]); + + const executiveAndManagerUsers = React.useMemo(() => { + return usersDirectory.filter((u) => { + const role = String(u.role || '').toLowerCase(); + return role === 'executive' || role === 'manager'; + }); + }, [usersDirectory]); + + // Quick Add Executive User Subform state + const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false); + const [quickUserEmail, setQuickUserEmail] = useState(''); + const [quickUserName, setQuickUserName] = useState(''); + const [quickUserPhone, setQuickUserPhone] = useState(''); + const [quickUserIdNumber, setQuickUserIdNumber] = useState(''); + const [quickUserRoleId, setQuickUserRoleId] = useState(''); + const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false); + const [quickUserError, setQuickUserError] = useState(null); + + React.useEffect(() => { + if (!quickUserRoleId && assignableRolesForOrg.length > 0) { + const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive'); + if (execRole) setQuickUserRoleId(execRole.id); + else setQuickUserRoleId(assignableRolesForOrg[0].id); + } + }, [assignableRolesForOrg, quickUserRoleId]); + + const handleQuickAddUser = async () => { + setQuickUserError(null); + if (!quickUserEmail || !quickUserEmail.includes('@')) { + setQuickUserError('Please provide a valid email address.'); + return; + } + if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) { + setQuickUserError('Government issued ID number is required (min 4 characters).'); + return; + } + + setIsSubmittingQuickUser(true); + try { + const tenantId = getCurrentUserTenantId() || 1; + const payload: CreateUserRequest = { + email: quickUserEmail.trim().toLowerCase(), + userName: quickUserName.trim() || quickUserEmail.split('@')[0], + emailConfirmed: true, + phoneNumber: quickUserPhone.trim() || null, + phoneNumberConfirmed: false, + LockoutEnabled: false, + twoFactorEnabled: false, + accessFailedCount: 0, + }; + + await createUser(tenantId, quickUserIdNumber.trim(), payload); + + // Invalidate queries and fetch fresh user list immediately + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + const freshUsers = await getUsers(1, 200); + + const createdUser = freshUsers.find( + (u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase() + ); + + if (createdUser && quickUserRoleId) { + try { + await assignUserRole(createdUser.id, quickUserRoleId); + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + } catch { + // Handled gracefully + } + orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); + } else if (createdUser) { + orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); + } + + success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`); + setQuickUserEmail(''); + setQuickUserName(''); + setQuickUserPhone(''); + setQuickUserIdNumber(''); + setIsQuickAddUserOpen(false); + } catch (err: any) { + const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.'; + setQuickUserError(errorDetail); + toastError('Failed to create user', err); + } finally { + setIsSubmittingQuickUser(false); + } + }; + const { data: organisations = [], isLoading: loadingOrgs, @@ -427,7 +538,6 @@ export const GovernancePage: React.FC = () => { name: '', registrationNo: '', email: '', - idNumber: '', organisationUser: '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, @@ -463,7 +573,6 @@ export const GovernancePage: React.FC = () => { name: data.name, registrationNo: data.registrationNo, email: data.email, - idNumber: data.idNumber, organisationUser: data.organisationUser, type: Number(data.type), status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active, @@ -473,7 +582,6 @@ export const GovernancePage: React.FC = () => { name: data.name, registrationNo: data.registrationNo, email: data.email, - idNumber: data.idNumber, organisationUser: data.organisationUser, type: Number(data.type), }); @@ -553,24 +661,23 @@ export const GovernancePage: React.FC = () => { const openCreateOrg = () => { setEditingOrg(null); - const defaultUserId = usersDirectory[0]?.id || sub || currentUserId || ''; - const defaultUser = usersDirectory.find((u) => u.id === defaultUserId); + const defaultUser = executiveAndManagerUsers[0]; orgForm.reset({ name: '', registrationNo: '', email: '', - idNumber: defaultUser?.idNumber || '', - organisationUser: defaultUserId, + organisationUser: defaultUser?.id || '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, }); + setIsQuickAddUserOpen(false); + setQuickUserError(null); setIsOrgModalOpen(true); }; const openEditOrg = (org: Organisation) => { setEditingOrg(org); - // 1. Resolve target linked user ID synchronously from the organisation model const targetUserId = org.userId || (org as any).UserId || @@ -579,46 +686,24 @@ export const GovernancePage: React.FC = () => { org.createdBy || ''; - // 2. Fast O(1) in-memory match from cached users directory const matchedUser = targetUserId ? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase()) : null; - // 3. Resolve idNumber: prefer matchedUser's idNumber, or org's idNumber if any, otherwise blank '' - const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || (org as any).IdNumber || ''; const selectedUserId = matchedUser?.id || targetUserId || ''; - // 4. Perform assignment immediately to form state orgForm.reset({ name: org.name || '', registrationNo: org.registrationNo || '', email: org.email || '', - idNumber: resolvedIdNumber, organisationUser: selectedUserId, type: org.type || OrganisationType.Enterprise, status: org.status ?? ActivityStatus.Active, }); - // 5. Instantly open modal with fully populated data (no lag, no progressive pop-in) + setIsQuickAddUserOpen(false); + setQuickUserError(null); setIsOrgModalOpen(true); - - // 6. Non-blocking background sync if target user wasn't in cache or org has missing data - if (targetUserId && !matchedUser) { - getUser(targetUserId).then((fetchedUser) => { - if (fetchedUser) { - queryClient.setQueryData(['users-directory'], (old = []) => { - if (old.some((u) => u.id === fetchedUser.id)) return old; - return [fetchedUser, ...old]; - }); - if (fetchedUser.idNumber && !orgForm.getValues('idNumber')) { - orgForm.setValue('idNumber', fetchedUser.idNumber, { shouldValidate: true }); - } - if (!orgForm.getValues('organisationUser')) { - orgForm.setValue('organisationUser', fetchedUser.id, { shouldValidate: true }); - } - } - }).catch(() => {}); - } }; const openCreatePortfolio = () => { @@ -1477,66 +1562,6 @@ export const GovernancePage: React.FC = () => {
-
-
-
- - -
- - {orgForm.formState.errors.organisationUser && ( -

{orgForm.formState.errors.organisationUser.message}

- )} -
- -
-
- - -
- - {orgForm.formState.errors.idNumber && ( -

{orgForm.formState.errors.idNumber.message}

- )} -
-
-
@@ -1561,6 +1586,164 @@ export const GovernancePage: React.FC = () => { )}
+
+
+
+ + +
+ {!editingOrg && ( + + )} +
+ + {orgForm.formState.errors.organisationUser && ( +

{orgForm.formState.errors.organisationUser.message}

+ )} + {executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && ( +

+ No Executive or Manager user found. Create one below to continue. +

+ )} +
+
+ + {/* Inline Collapsible Subform to Create Executive User */} + {isQuickAddUserOpen && !editingOrg && ( +
+
+ + + Quick Create Executive / Manager User + + Mapped to tenant automatically +
+ + {quickUserError && ( +
+ {quickUserError} +
+ )} + +
+
+ + setQuickUserEmail(e.target.value)} + placeholder="executive@organisation.org.za" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserName(e.target.value)} + placeholder="e.g. Sipho Ndlovu" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserPhone(e.target.value)} + placeholder="+27 82 123 4567" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserIdNumber(e.target.value)} + placeholder="13-digit ID or Passport" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + +
+
+ +
+ + +
+
+ )} + {editingOrg && (
@@ -1586,7 +1769,6 @@ export const GovernancePage: React.FC = () => {
)} -
+ + {/* Pagination Controls */} + {totalUsers > 0 && ( +
+
+ + Showing {Math.min((userPage - 1) * userPageSize + 1, totalUsers)} to{' '} + {Math.min(userPage * userPageSize, totalUsers)} of{' '} + {totalUsers} users + +
+ +
+
+ Per page: + +
+ +
+ + + + {userPage} / {totalPages} + + + +
+
+
+ )}
)} diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index 657e352..50087e9 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -227,7 +227,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr name: payload.name, registrationNo: payload.registrationNo, email: payload.email, - idNumber: payload.idNumber, + idNumber: payload.idNumber || null, organisationUser: payload.organisationUser, type: payload.type !== undefined ? Number(payload.type) : 5, }); diff --git a/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts index abd6786..a3cde2c 100644 --- a/PostFundManagement.ConsoleUI/src/types.ts +++ b/PostFundManagement.ConsoleUI/src/types.ts @@ -372,7 +372,7 @@ export interface CreateOrganisationRequest { name: string; registrationNo: string; email: string; - idNumber: string; + idNumber?: string | null; organisationUser: string; type: OrganisationType; } @@ -382,7 +382,7 @@ export interface UpdateOrganisationRequest { name: string; registrationNo: string; email: string; - idNumber: string; + idNumber?: string | null; organisationUser: string; type: OrganisationType; status?: ActivityStatus; diff --git a/PostFundManagement.ConsoleUI/yarn.lock b/PostFundManagement.ConsoleUI/yarn.lock index 96bcd86..925672d 100644 --- a/PostFundManagement.ConsoleUI/yarn.lock +++ b/PostFundManagement.ConsoleUI/yarn.lock @@ -928,6 +928,18 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== +"@types/react-dom@^19.3.0": + version "19.3.0" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.3.0.tgz#90294a9266efd928a5266f2110d60d5dba5015d2" + integrity sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q== + +"@types/react@^19.3.0": + version "19.3.0" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.3.0.tgz#3b3ce8d8c1627fb5ccd4dadd57ef445c5a153b77" + integrity sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg== + dependencies: + csstype "^3.2.2" + "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -1198,6 +1210,11 @@ css-line-break@^2.1.0: dependencies: utrie "^1.0.2" +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" diff --git a/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs b/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs index 082b0a4..029af00 100644 --- a/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs +++ b/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Abstractions; namespace PostFundManagement.Infrastructure.Database.Services; @@ -17,18 +17,18 @@ public sealed class TenantContextService : ITenantContextService public TenantContextService(IHttpContextAccessor httpContextAccessor) { var user = httpContextAccessor.HttpContext?.User; - - IsAdmin = user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true; - ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true; - - ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin; - - HasTenantFilter = !IsAdmin; var tenantIdClaimVal = user?.FindFirst("tenant_id")?.Value ?? user?.FindFirst("TenantId")?.Value; TenantId = long.TryParse(tenantIdClaimVal, out var id) ? id : 0L; var tenantNameClaimVal = user?.FindFirst("tenant")?.Value ?? user?.FindFirst("Tenant")?.Value; TenantName = !string.IsNullOrWhiteSpace(tenantNameClaimVal) ? tenantNameClaimVal : null; + + IsAdmin = (user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true) && TenantId == 1; + ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true; + + ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin; + + HasTenantFilter = !IsAdmin; } -} \ No newline at end of file +}