1033 lines
49 KiB
TypeScript
1033 lines
49 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import {
|
|
Users,
|
|
Plus,
|
|
ShieldCheck,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
Search,
|
|
Filter,
|
|
CheckSquare,
|
|
Edit2,
|
|
FolderKanban,
|
|
Target,
|
|
Award,
|
|
Sparkles,
|
|
MapPin,
|
|
FileCheck,
|
|
} from 'lucide-react';
|
|
import {
|
|
getProjects,
|
|
getProjectBeneficiaries,
|
|
createBeneficiary,
|
|
updateBeneficiary,
|
|
getBeneficiaryOutcomes,
|
|
createOutcome,
|
|
getProjectComplianceItems,
|
|
createComplianceItem,
|
|
updateComplianceItem,
|
|
} from '../services/api';
|
|
import { Modal } from '../components/ui/Modal';
|
|
import { useToast } from '../components/ui/Toast';
|
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|
import { ExportButton } from '../components/ui/ExportButton';
|
|
import { ExportColumn } from '../utils/exportUtils';
|
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
|
import { Beneficiary, ComplianceItem, Outcome, ApprovalStatus } from '../types';
|
|
import { getApprovalStatusBadge, formatDate, APPROVAL_STATUS_OPTIONS } from '../lib/formatters';
|
|
|
|
const beneficiarySchema = z.object({
|
|
name: z.string().min(2, 'Beneficiary name required'),
|
|
category: z.string().min(2, 'Category required'),
|
|
location: z.string().min(2, 'Location required'),
|
|
targetCount: z.coerce.number().min(1, 'Target count must be at least 1'),
|
|
reachedCount: z.coerce.number().min(0, 'Reached count must be 0 or more'),
|
|
});
|
|
|
|
const complianceSchema = z.object({
|
|
title: z.string().min(3, 'Compliance title required'),
|
|
requirements: z.string().min(5, 'Requirements detail required'),
|
|
status: z.coerce.number().optional(),
|
|
});
|
|
|
|
const outcomeSchema = z.object({
|
|
name: z.string().min(3, 'Outcome name required'),
|
|
description: z.string().min(5, 'Description required'),
|
|
});
|
|
|
|
type BeneficiaryFormValues = z.infer<typeof beneficiarySchema>;
|
|
type ComplianceFormValues = z.infer<typeof complianceSchema>;
|
|
type OutcomeFormValues = z.infer<typeof outcomeSchema>;
|
|
|
|
export const BeneficiariesPage: React.FC = () => {
|
|
const queryClient = useQueryClient();
|
|
const { success, error: toastError } = useToast();
|
|
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string>('all');
|
|
const [activeTab, setActiveTab] = useState<'beneficiaries' | 'compliance'>('beneficiaries');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
|
const [compStatusFilter, setCompStatusFilter] = useState('all');
|
|
|
|
// Modal states
|
|
const [isBenModalOpen, setIsBenModalOpen] = useState(false);
|
|
const [editingBen, setEditingBen] = useState<Beneficiary | null>(null);
|
|
|
|
const [isCompModalOpen, setIsCompModalOpen] = useState(false);
|
|
const [editingComp, setEditingComp] = useState<ComplianceItem | null>(null);
|
|
|
|
const [selectedBenForDetail, setSelectedBenForDetail] = useState<Beneficiary | null>(null);
|
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
|
|
|
const [isOutcomeModalOpen, setIsOutcomeModalOpen] = useState(false);
|
|
|
|
// Queries
|
|
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: () => getProjects(),
|
|
});
|
|
|
|
// Query beneficiaries
|
|
const effectiveProjectId = selectedProjectId !== 'all' ? selectedProjectId : projects[0]?.id;
|
|
|
|
const {
|
|
data: beneficiaries = [],
|
|
isLoading: loadingBens,
|
|
refetch: refetchBens,
|
|
isFetching: fetchingBens,
|
|
} = useQuery({
|
|
queryKey: ['beneficiaries', effectiveProjectId],
|
|
queryFn: () => (effectiveProjectId ? getProjectBeneficiaries(effectiveProjectId) : Promise.resolve([])),
|
|
enabled: !!effectiveProjectId,
|
|
});
|
|
|
|
// Query compliance items
|
|
const {
|
|
data: complianceItems = [],
|
|
isLoading: loadingComps,
|
|
refetch: refetchComps,
|
|
isFetching: fetchingComps,
|
|
} = useQuery({
|
|
queryKey: ['complianceItems', effectiveProjectId],
|
|
queryFn: () => (effectiveProjectId ? getProjectComplianceItems(effectiveProjectId) : Promise.resolve([])),
|
|
enabled: !!effectiveProjectId,
|
|
});
|
|
|
|
// Query outcomes for selected beneficiary
|
|
const { data: outcomes = [], refetch: refetchOutcomes } = useQuery({
|
|
queryKey: ['outcomes', selectedBenForDetail?.id],
|
|
queryFn: () => (selectedBenForDetail?.id ? getBeneficiaryOutcomes(selectedBenForDetail.id) : Promise.resolve([])),
|
|
enabled: !!selectedBenForDetail?.id,
|
|
});
|
|
|
|
// Forms
|
|
const benForm = useForm<BeneficiaryFormValues>({
|
|
resolver: zodResolver(beneficiarySchema),
|
|
defaultValues: { name: '', category: 'Youth & Skills', location: 'National', targetCount: 100, reachedCount: 0 },
|
|
});
|
|
|
|
const compForm = useForm<ComplianceFormValues>({
|
|
resolver: zodResolver(complianceSchema),
|
|
defaultValues: { title: '', requirements: '', status: ApprovalStatus.Pending },
|
|
});
|
|
|
|
const outcomeForm = useForm<OutcomeFormValues>({
|
|
resolver: zodResolver(outcomeSchema),
|
|
defaultValues: { name: '', description: '' },
|
|
});
|
|
|
|
// Mutations
|
|
const createBenMutation = useMutation({
|
|
mutationFn: (vals: BeneficiaryFormValues) => {
|
|
if (!effectiveProjectId) throw new Error('No project selected');
|
|
return createBeneficiary({
|
|
projectId: effectiveProjectId,
|
|
name: vals.name,
|
|
location: vals.location,
|
|
category: vals.category,
|
|
targetCount: vals.targetCount,
|
|
reachedCount: vals.reachedCount,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
|
setIsBenModalOpen(false);
|
|
benForm.reset();
|
|
success('Beneficiary Registered', 'New project beneficiary profile successfully created.');
|
|
},
|
|
onError: (err: any) => toastError('Registration Failed', err.message || 'Unable to save beneficiary.'),
|
|
});
|
|
|
|
const updateBenMutation = useMutation({
|
|
mutationFn: (vals: BeneficiaryFormValues) => {
|
|
if (!editingBen) throw new Error('No beneficiary selected');
|
|
return updateBeneficiary({
|
|
beneficiaryId: editingBen.id,
|
|
name: vals.name,
|
|
location: vals.location,
|
|
category: vals.category,
|
|
targetCount: vals.targetCount,
|
|
reachedCount: vals.reachedCount,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['beneficiaries'] });
|
|
setIsBenModalOpen(false);
|
|
setEditingBen(null);
|
|
benForm.reset();
|
|
success('Beneficiary Updated', 'Beneficiary details and targets updated.');
|
|
},
|
|
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update beneficiary.'),
|
|
});
|
|
|
|
const createCompMutation = useMutation({
|
|
mutationFn: (vals: ComplianceFormValues) => {
|
|
if (!effectiveProjectId) throw new Error('No project selected');
|
|
return createComplianceItem({ projectId: effectiveProjectId, title: vals.title, requirements: vals.requirements });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
|
|
setIsCompModalOpen(false);
|
|
compForm.reset();
|
|
success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.');
|
|
},
|
|
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to record compliance item.'),
|
|
});
|
|
|
|
const updateCompMutation = useMutation({
|
|
mutationFn: (vals: ComplianceFormValues) => {
|
|
if (!editingComp) throw new Error('No item selected');
|
|
return updateComplianceItem({
|
|
complianceItemId: editingComp.id,
|
|
title: vals.title,
|
|
requirements: vals.requirements,
|
|
status: vals.status ?? ApprovalStatus.Pending,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['complianceItems'] });
|
|
setIsCompModalOpen(false);
|
|
setEditingComp(null);
|
|
compForm.reset();
|
|
success('Compliance Item Updated', 'Status and requirement record updated.');
|
|
},
|
|
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update compliance item.'),
|
|
});
|
|
|
|
const createOutcomeMutation = useMutation({
|
|
mutationFn: (vals: OutcomeFormValues) => {
|
|
if (!selectedBenForDetail) throw new Error('No beneficiary selected');
|
|
return createOutcome({ beneficiaryId: selectedBenForDetail.id, name: vals.name, description: vals.description });
|
|
},
|
|
onSuccess: () => {
|
|
refetchOutcomes();
|
|
setIsOutcomeModalOpen(false);
|
|
outcomeForm.reset();
|
|
success('Outcome Logged', 'Beneficiary outcome and impact metric registered.');
|
|
},
|
|
onError: (err: any) => toastError('Failed to Record Outcome', err.message || 'Unable to save outcome.'),
|
|
});
|
|
|
|
// Filtered & Sorted Beneficiaries
|
|
const filteredBens = useMemo(() => {
|
|
return beneficiaries.filter((b) => {
|
|
const matchesSearch =
|
|
!searchTerm.trim() ||
|
|
(b.name && b.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
(b.location && b.location.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
const matchesCat = categoryFilter === 'all' || b.category === categoryFilter;
|
|
return matchesSearch && matchesCat;
|
|
});
|
|
}, [beneficiaries, searchTerm, categoryFilter]);
|
|
|
|
const { sortedData: sortedBens, sortColumn: benSortCol, sortDirection: benSortDir, handleSort: handleBenSort } =
|
|
useTableSort(filteredBens);
|
|
|
|
// Filtered & Sorted Compliance Items
|
|
const filteredComps = useMemo(() => {
|
|
return complianceItems.filter((c) => {
|
|
const matchesSearch =
|
|
!searchTerm.trim() ||
|
|
(c.title && c.title.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
(c.requirements && c.requirements.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
const matchesStatus =
|
|
compStatusFilter === 'all' ||
|
|
(compStatusFilter === 'approved' && c.status === ApprovalStatus.Approved) ||
|
|
(compStatusFilter === 'pending' && c.status !== ApprovalStatus.Approved);
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
}, [complianceItems, searchTerm, compStatusFilter]);
|
|
|
|
const { sortedData: sortedComps, sortColumn: compSortCol, sortDirection: compSortDir, handleSort: handleCompSort } =
|
|
useTableSort(filteredComps);
|
|
|
|
// Metrics
|
|
const totalTarget = beneficiaries.reduce((acc, b) => acc + (b.targetCount || 0), 0);
|
|
const totalReached = beneficiaries.reduce((acc, b) => acc + (b.reachedCount || 0), 0);
|
|
const reachPercentage = totalTarget > 0 ? Math.min(100, Math.round((totalReached / totalTarget) * 100)) : 0;
|
|
const verifiedCompCount = complianceItems.filter((c) => c.status === ApprovalStatus.Approved).length;
|
|
|
|
// Exports
|
|
const benExportColumns: ExportColumn<Beneficiary>[] = [
|
|
{ header: 'Beneficiary Name', key: 'name' },
|
|
{ header: 'Category', key: 'category' },
|
|
{ header: 'Location', key: 'location' },
|
|
{ header: 'Target Population', key: 'targetCount' },
|
|
{ header: 'Reached Population', key: 'reachedCount' },
|
|
];
|
|
|
|
const compExportColumns: ExportColumn<ComplianceItem>[] = [
|
|
{ header: 'Compliance Requirement', key: 'title' },
|
|
{ header: 'Requirements Detail', key: 'requirements' },
|
|
{ header: 'Status', key: 'status', format: (val) => (val === ApprovalStatus.Approved ? 'Approved' : 'Pending Review') },
|
|
];
|
|
|
|
const handleOpenEditBen = (b: Beneficiary) => {
|
|
setEditingBen(b);
|
|
benForm.reset({
|
|
name: b.name || '',
|
|
category: b.category || 'General',
|
|
location: b.location || '',
|
|
targetCount: b.targetCount || 0,
|
|
reachedCount: b.reachedCount || 0,
|
|
});
|
|
setIsBenModalOpen(true);
|
|
};
|
|
|
|
const handleOpenEditComp = (c: ComplianceItem) => {
|
|
setEditingComp(c);
|
|
compForm.reset({
|
|
title: c.title || '',
|
|
requirements: c.requirements || '',
|
|
status: c.status ?? ApprovalStatus.Pending,
|
|
});
|
|
setIsCompModalOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8 pb-16">
|
|
{/* Header Banner */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
|
<div className="space-y-2 relative z-10">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
|
<Users className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
|
Beneficiaries & Compliance Register
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-slate-400">
|
|
Manage target recipient demographics, track reached counts, and record verified statutory compliance items.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-xs font-semibold uppercase tracking-wider">Beneficiary Cohorts</span>
|
|
<Users className="w-4 h-4 text-blue-400" />
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">{beneficiaries.length}</p>
|
|
<p className="text-[11px] text-slate-400">Registered target stakeholder groups</p>
|
|
</div>
|
|
|
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-xs font-semibold uppercase tracking-wider">Total Target Reach</span>
|
|
<Target className="w-4 h-4 text-indigo-400" />
|
|
</div>
|
|
<p className="text-2xl font-bold text-white">{totalTarget.toLocaleString()}</p>
|
|
<p className="text-[11px] text-slate-400">Planned participant headcount</p>
|
|
</div>
|
|
|
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-xs font-semibold uppercase tracking-wider">Actual Reached</span>
|
|
<Award className="w-4 h-4 text-emerald-400" />
|
|
</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<p className="text-2xl font-bold text-emerald-400">{totalReached.toLocaleString()}</p>
|
|
<span className="text-xs font-bold text-slate-400">({reachPercentage}%)</span>
|
|
</div>
|
|
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
|
<div className="bg-emerald-500 h-full rounded-full transition-all" style={{ width: `${reachPercentage}%` }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
|
|
<div className="flex items-center justify-between text-slate-400">
|
|
<span className="text-xs font-semibold uppercase tracking-wider">Compliance Items</span>
|
|
<ShieldCheck className="w-4 h-4 text-cyan-400" />
|
|
</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<p className="text-2xl font-bold text-cyan-400">{complianceItems.length}</p>
|
|
<span className="text-xs font-medium text-slate-400">({verifiedCompCount} Satisfied)</span>
|
|
</div>
|
|
<p className="text-[11px] text-slate-400">Statutory & audit verification records</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="space-y-4">
|
|
<div className="flex border-b border-slate-800 bg-[#0d121f]/80 px-2 sm:px-4 rounded-2xl backdrop-blur-sm overflow-x-auto gap-2">
|
|
<button
|
|
onClick={() => setActiveTab('beneficiaries')}
|
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
|
|
activeTab === 'beneficiaries'
|
|
? 'border-blue-500 text-blue-400'
|
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<Users className="w-4 h-4" />
|
|
<span>Beneficiaries Register ({beneficiaries.length})</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setActiveTab('compliance')}
|
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
|
|
activeTab === 'compliance'
|
|
? 'border-blue-500 text-blue-400'
|
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<CheckSquare className="w-4 h-4" />
|
|
<span>Compliance Requirements ({complianceItems.length})</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab 1: Beneficiaries Tab Toolbar & Content */}
|
|
{activeTab === 'beneficiaries' && (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-[#0d121f]/60 p-3.5 rounded-2xl border border-slate-800/80">
|
|
<div className="flex items-center gap-2.5 flex-wrap flex-1">
|
|
{/* Search */}
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search name, location..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 shadow-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category Filter */}
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="all">All Categories</option>
|
|
<option value="Youth & Skills">Youth & Skills</option>
|
|
<option value="Women-Led">Women-Led</option>
|
|
<option value="SME / Enterprise">SME / Enterprise</option>
|
|
<option value="Agriculture & Rural">Agriculture & Rural</option>
|
|
<option value="Healthcare & Community">Healthcare & Community</option>
|
|
<option value="Education">Education</option>
|
|
<option value="General">General</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Project Selector */}
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<FolderKanban className="w-3.5 h-3.5 text-blue-400" />
|
|
<select
|
|
value={selectedProjectId}
|
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 max-w-[180px] truncate"
|
|
>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions: Refresh, Export, Register */}
|
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
refetchBens();
|
|
success('Refreshed', 'Beneficiaries register updated from API.');
|
|
}}
|
|
disabled={fetchingBens}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-900 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800 transition cursor-pointer disabled:opacity-50"
|
|
title="Force refresh beneficiaries"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${fetchingBens ? 'animate-spin' : ''}`} />
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
<ExportButton
|
|
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>
|
|
</div>
|
|
</div>
|
|
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
|
|
<SortableTh column="name" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-5 whitespace-nowrap">
|
|
Beneficiary / Cohort
|
|
</SortableTh>
|
|
<SortableTh column="category" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
|
|
Category
|
|
</SortableTh>
|
|
<SortableTh column="location" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
|
|
Location
|
|
</SortableTh>
|
|
<SortableTh column="targetCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
|
|
Target Headcount
|
|
</SortableTh>
|
|
<SortableTh column="reachedCount" currentColumn={benSortCol} direction={benSortDir} onSort={handleBenSort} className="py-3.5 px-4 whitespace-nowrap">
|
|
Reached Headcount
|
|
</SortableTh>
|
|
<th className="py-3.5 px-4 font-semibold whitespace-nowrap">Reach Progress</th>
|
|
<th className="py-3.5 px-5 text-right font-medium whitespace-nowrap">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60">
|
|
{loadingBens ? (
|
|
<tr>
|
|
<td colSpan={7} className="py-12 text-center text-slate-400">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
<span>Loading beneficiaries...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : sortedBens.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="py-12 text-center text-slate-400">
|
|
No beneficiaries recorded for this project.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sortedBens.map((b) => {
|
|
const prog = b.targetCount && b.targetCount > 0 ? Math.round(((b.reachedCount || 0) / b.targetCount) * 100) : 0;
|
|
return (
|
|
<tr key={b.id} className="hover:bg-slate-800/40 transition-colors">
|
|
<td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
|
<span>{b.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300">
|
|
<span className="bg-slate-800/80 px-2 py-0.5 rounded-md border border-slate-700/60 text-[11px]">
|
|
{b.category || 'General'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap text-slate-300">
|
|
<div className="flex items-center gap-1.5 text-slate-400">
|
|
<MapPin className="w-3 h-3 text-slate-500" />
|
|
<span>{b.location || 'Not Specified'}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap font-medium text-slate-200">{(b.targetCount || 0).toLocaleString()}</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap font-bold text-emerald-400">{(b.reachedCount || 0).toLocaleString()}</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap">
|
|
<div className="flex items-center gap-2 max-w-[140px]">
|
|
<div className="flex-1 bg-slate-800 h-1.5 rounded-full overflow-hidden">
|
|
<div className="bg-emerald-500 h-full rounded-full" style={{ width: `${Math.min(100, prog)}%` }} />
|
|
</div>
|
|
<span className="text-[11px] font-mono text-slate-400">{prog}%</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-5 text-right whitespace-nowrap space-x-2">
|
|
<button
|
|
onClick={() => {
|
|
setSelectedBenForDetail(b);
|
|
setIsDetailModalOpen(true);
|
|
}}
|
|
className="px-2.5 py-1 text-xs font-medium text-blue-400 hover:text-blue-300 bg-blue-950/40 hover:bg-blue-900/40 rounded-lg border border-blue-800/40 transition cursor-pointer"
|
|
>
|
|
Compliance & Outcomes
|
|
</button>
|
|
<button
|
|
onClick={() => handleOpenEditBen(b)}
|
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
|
title="Edit beneficiary"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab 2: Compliance Items Table */}
|
|
{activeTab === 'compliance' && (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-3 bg-[#0d121f]/60 p-3.5 rounded-2xl border border-slate-800/80">
|
|
<div className="flex items-center gap-2.5 flex-wrap flex-1">
|
|
{/* Search */}
|
|
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
|
<Search className="w-3.5 h-3.5 absolute left-3 top-2.5 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search requirement or title..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-8 pr-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 shadow-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<select
|
|
value={compStatusFilter}
|
|
onChange={(e) => setCompStatusFilter(e.target.value)}
|
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="all">All Statuses</option>
|
|
<option value="approved">Approved / Satisfied</option>
|
|
<option value="pending">Pending Review</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Project Selector */}
|
|
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
|
<FolderKanban className="w-3.5 h-3.5 text-blue-400" />
|
|
<select
|
|
value={selectedProjectId}
|
|
onChange={(e) => setSelectedProjectId(e.target.value)}
|
|
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 max-w-[180px] truncate"
|
|
>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions: Refresh, Export, Add Compliance Item */}
|
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
refetchComps();
|
|
success('Refreshed', 'Compliance items updated from API.');
|
|
}}
|
|
disabled={fetchingComps}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-slate-900 text-slate-300 hover:text-white border border-slate-800 hover:bg-slate-800 transition cursor-pointer disabled:opacity-50"
|
|
title="Force refresh compliance checklist"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${fetchingComps ? 'animate-spin' : ''}`} />
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
<ExportButton
|
|
data={sortedComps}
|
|
columns={compExportColumns}
|
|
filename={`compliance_requirements_${new Date().toISOString().split('T')[0]}`}
|
|
title="Compliance Requirements"
|
|
/>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setEditingComp(null);
|
|
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>
|
|
|
|
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
|
|
<SortableTh column="title" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-5 whitespace-nowrap">
|
|
Compliance Title
|
|
</SortableTh>
|
|
<SortableTh column="requirements" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
|
|
Requirements
|
|
</SortableTh>
|
|
<SortableTh column="status" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
|
|
Verification Status
|
|
</SortableTh>
|
|
<SortableTh column="createdAt" currentColumn={compSortCol} direction={compSortDir} onSort={handleCompSort} className="py-3.5 px-4 whitespace-nowrap">
|
|
Created
|
|
</SortableTh>
|
|
<th className="py-3.5 px-5 text-right font-medium whitespace-nowrap">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60">
|
|
{loadingComps ? (
|
|
<tr>
|
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
<span>Loading compliance items...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : sortedComps.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="py-12 text-center text-slate-400">
|
|
No compliance items recorded for this project.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sortedComps.map((c) => (
|
|
<tr key={c.id} className="hover:bg-slate-800/40 transition-colors">
|
|
<td className="py-3.5 px-5 whitespace-nowrap font-semibold text-white">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
|
<span>{c.title}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-slate-300 max-w-xs truncate" title={c.requirements || ''}>
|
|
{c.requirements || 'None'}
|
|
</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap">{getApprovalStatusBadge(c.status)}</td>
|
|
<td className="py-3.5 px-4 whitespace-nowrap text-slate-400">{formatDate(c.createdAt)}</td>
|
|
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
|
<button
|
|
onClick={() => handleOpenEditComp(c)}
|
|
className="p-1 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition cursor-pointer"
|
|
title="Edit compliance item"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modal: Create/Edit Beneficiary */}
|
|
<Modal
|
|
isOpen={isBenModalOpen}
|
|
onClose={() => setIsBenModalOpen(false)}
|
|
title={editingBen ? 'Edit Beneficiary Profile' : 'Register Project Beneficiary'}
|
|
size="md"
|
|
>
|
|
<form
|
|
onSubmit={benForm.handleSubmit((d) => (editingBen ? updateBenMutation.mutate(d) : createBenMutation.mutate(d)))}
|
|
className="space-y-4"
|
|
>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Beneficiary Name / Group *</label>
|
|
<input
|
|
{...benForm.register('name')}
|
|
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"
|
|
placeholder="e.g. Rural Female Farmers Cohort"
|
|
/>
|
|
{benForm.formState.errors.name && (
|
|
<span className="text-[11px] text-rose-400 mt-1 block">{benForm.formState.errors.name.message}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Category *</label>
|
|
<input
|
|
{...benForm.register('category')}
|
|
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"
|
|
placeholder="e.g. Agriculture, Youth"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Location *</label>
|
|
<input
|
|
{...benForm.register('location')}
|
|
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"
|
|
placeholder="e.g. Limpopo, District 4"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Target Count *</label>
|
|
<input
|
|
type="number"
|
|
{...benForm.register('targetCount')}
|
|
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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Reached Count</label>
|
|
<input
|
|
type="number"
|
|
{...benForm.register('reachedCount')}
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsBenModalOpen(false)}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={createBenMutation.isPending || updateBenMutation.isPending}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
|
|
>
|
|
{editingBen ? 'Update Beneficiary' : 'Save Beneficiary'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Modal: Create/Edit Compliance Item */}
|
|
<Modal
|
|
isOpen={isCompModalOpen}
|
|
onClose={() => setIsCompModalOpen(false)}
|
|
title={editingComp ? 'Edit Compliance Requirement' : 'Add Compliance Requirement'}
|
|
size="md"
|
|
>
|
|
<form
|
|
onSubmit={compForm.handleSubmit((d) => (editingComp ? updateCompMutation.mutate(d) : createCompMutation.mutate(d)))}
|
|
className="space-y-4"
|
|
>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Compliance Title *</label>
|
|
<input
|
|
{...compForm.register('title')}
|
|
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"
|
|
placeholder="e.g. Identity Verification & Consent"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Requirements Detail *</label>
|
|
<textarea
|
|
rows={3}
|
|
{...compForm.register('requirements')}
|
|
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"
|
|
placeholder="Specify requirements and criteria required to satisfy this compliance item..."
|
|
/>
|
|
</div>
|
|
|
|
{editingComp && (
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Verification Status</label>
|
|
<select
|
|
{...compForm.register('status')}
|
|
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}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCompModalOpen(false)}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={createCompMutation.isPending || updateCompMutation.isPending}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition shadow-md shadow-blue-600/30"
|
|
>
|
|
{editingComp ? 'Update Item' : 'Create Item'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Modal: Beneficiary Detail & Compliance / Outcomes Inspector */}
|
|
{selectedBenForDetail && (
|
|
<Modal
|
|
isOpen={isDetailModalOpen}
|
|
onClose={() => setIsDetailModalOpen(false)}
|
|
title={`Beneficiary: ${selectedBenForDetail.name}`}
|
|
size="lg"
|
|
>
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 bg-slate-900/60 p-4 rounded-xl border border-slate-800">
|
|
<div>
|
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Category</span>
|
|
<span className="text-xs font-bold text-white">{selectedBenForDetail.category || 'General'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Location</span>
|
|
<span className="text-xs font-bold text-white">{selectedBenForDetail.location || 'National'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Target Count</span>
|
|
<span className="text-xs font-bold text-white">{selectedBenForDetail.targetCount}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-[10px] uppercase font-semibold text-slate-400 block">Reached</span>
|
|
<span className="text-xs font-bold text-emerald-400">{selectedBenForDetail.reachedCount}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Compliance Satisfied Section */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
|
<FileCheck className="w-4 h-4 text-cyan-400" />
|
|
<span>Compliance Requirements Satisfying this Beneficiary</span>
|
|
</h4>
|
|
</div>
|
|
|
|
{complianceItems.length === 0 ? (
|
|
<div className="text-center py-4 bg-slate-900/40 rounded-xl border border-slate-800 text-slate-400 text-xs">
|
|
No compliance items recorded for this project yet.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
|
{complianceItems.map((ci) => (
|
|
<div
|
|
key={ci.id}
|
|
className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between gap-3 text-xs"
|
|
>
|
|
<div>
|
|
<p className="font-semibold text-white">{ci.title}</p>
|
|
<p className="text-[11px] text-slate-400 truncate max-w-sm">{ci.requirements}</p>
|
|
</div>
|
|
<div className="shrink-0">{getApprovalStatusBadge(ci.status)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Outcomes Section */}
|
|
<div className="space-y-3 pt-4 border-t border-slate-800">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-2">
|
|
<Sparkles className="w-4 h-4 text-indigo-400" />
|
|
<span>Recorded Outcomes & Impact ({outcomes.length})</span>
|
|
</h4>
|
|
<button
|
|
onClick={() => setIsOutcomeModalOpen(true)}
|
|
className="px-3 py-1 bg-indigo-600/30 hover:bg-indigo-600/50 text-indigo-300 border border-indigo-500/40 rounded-lg text-xs font-semibold transition cursor-pointer"
|
|
>
|
|
+ Add Outcome
|
|
</button>
|
|
</div>
|
|
|
|
{outcomes.length === 0 ? (
|
|
<div className="text-center py-4 bg-slate-900/40 rounded-xl border border-slate-800 text-slate-400 text-xs">
|
|
No outcomes registered for this beneficiary yet.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
|
{outcomes.map((o) => (
|
|
<div key={o.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 text-xs">
|
|
<p className="font-semibold text-white">{o.name}</p>
|
|
<p className="text-[11px] text-slate-400 mt-0.5">{o.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
|
|
{/* Modal: Add Outcome */}
|
|
<Modal isOpen={isOutcomeModalOpen} onClose={() => setIsOutcomeModalOpen(false)} title="Record Beneficiary Outcome" size="md">
|
|
<form onSubmit={outcomeForm.handleSubmit((d) => createOutcomeMutation.mutate(d))} className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Outcome Name *</label>
|
|
<input
|
|
{...outcomeForm.register('name')}
|
|
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"
|
|
placeholder="e.g. 85% Post-Training Employment Rate"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-300 mb-1">Impact Description *</label>
|
|
<textarea
|
|
rows={3}
|
|
{...outcomeForm.register('description')}
|
|
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"
|
|
placeholder="Detail quantitative or qualitative impact observed..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOutcomeModalOpen(false)}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={createOutcomeMutation.isPending}
|
|
className="px-4 py-2 rounded-xl text-xs font-semibold bg-indigo-600 hover:bg-indigo-500 text-white transition shadow-md shadow-indigo-600/30"
|
|
>
|
|
Save Outcome
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|