[TASK-007] feat: P1 complete - MLM and Goals UI modules

## T-02.1: MLM Structure Pages (4 pages)
- MLMPage.tsx - Dashboard
- StructuresPage.tsx - List structures
- RanksPage.tsx - Manage ranks
- MyNetworkPage.tsx - User network view

## T-02.2: MLM Detail Pages (3 pages)
- StructureDetailPage.tsx
- NodeDetailPage.tsx
- MyEarningsPage.tsx

## T-02.3: Goals Structure Pages (3 pages)
- GoalsPage.tsx - Dashboard
- DefinitionsPage.tsx - Create/edit goals
- MyGoalsPage.tsx - User's assigned goals

## T-02.4: Goals Detail Pages (3 pages)
- GoalDetailPage.tsx
- AssignmentDetailPage.tsx
- ReportsPage.tsx

## T-02.5 & T-02.6: Route Integration
- router/index.tsx: Added 13 new routes with lazy loading
- DashboardLayout.tsx: Added MLM and Goals to sidebar nav

Total: 13 new pages, 13 new routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-27 12:53:56 -06:00
parent 5c4e1511d6
commit 2bfd90cdff
17 changed files with 4575 additions and 0 deletions

View File

@ -19,6 +19,8 @@ import {
ClipboardList, ClipboardList,
Flag, Flag,
MessageSquare, MessageSquare,
Target,
Network,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
@ -27,6 +29,8 @@ import { NotificationBell } from '@/components/notifications';
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'AI Assistant', href: '/dashboard/ai', icon: Bot }, { name: 'AI Assistant', href: '/dashboard/ai', icon: Bot },
{ name: 'Goals', href: '/dashboard/goals', icon: Target },
{ name: 'MLM', href: '/dashboard/mlm', icon: Network },
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive }, { name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook }, { name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
{ name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag }, { name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag },

View File

@ -0,0 +1,450 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
useGoalAssignment,
useGoalProgressHistory,
useUpdateGoalProgress,
useUpdateAssignmentStatus,
useDeleteGoalAssignment,
} from '@/hooks/useGoals';
import type { AssignmentStatus, UpdateProgressDto, ProgressLog } from '@/services/goals';
export default function AssignmentDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: assignment, isLoading, error } = useGoalAssignment(id || '');
const { data: history } = useGoalProgressHistory(id || '');
const updateProgress = useUpdateGoalProgress();
const updateStatus = useUpdateAssignmentStatus();
const deleteAssignment = useDeleteGoalAssignment();
const [showProgressForm, setShowProgressForm] = useState(false);
const [progressValue, setProgressValue] = useState<number>(0);
const [progressNotes, setProgressNotes] = useState<string>('');
const handleUpdateProgress = async () => {
if (!id) return;
const data: UpdateProgressDto = {
value: progressValue,
source: 'manual',
notes: progressNotes || undefined,
};
await updateProgress.mutateAsync({ id, data });
setShowProgressForm(false);
setProgressNotes('');
};
const handleStatusChange = async (status: AssignmentStatus) => {
if (id) {
await updateStatus.mutateAsync({ id, status });
}
};
const handleDelete = async () => {
if (id && window.confirm('Are you sure you want to delete this assignment?')) {
await deleteAssignment.mutateAsync(id);
navigate('/dashboard/goals/assignments');
}
};
const startProgressUpdate = () => {
if (assignment) {
setProgressValue(assignment.currentValue);
setShowProgressForm(true);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error || !assignment) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading assignment</p>
<a href="/dashboard/goals/assignments" className="text-blue-600 hover:underline mt-2 inline-block">
Back to assignments
</a>
</div>
);
}
const getStatusColor = (status: AssignmentStatus) => {
const colors: Record<AssignmentStatus, string> = {
active: 'bg-blue-100 text-blue-800',
achieved: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getProgressBarColor = (percentage: number) => {
if (percentage >= 100) return 'bg-green-500';
if (percentage >= 75) return 'bg-blue-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
const getSourceColor = (source: string) => {
const colors: Record<string, string> = {
manual: 'bg-blue-100 text-blue-800',
automatic: 'bg-green-100 text-green-800',
import: 'bg-purple-100 text-purple-800',
api: 'bg-orange-100 text-orange-800',
};
return colors[source] || 'bg-gray-100 text-gray-800';
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString();
};
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleString();
};
const getDaysRemaining = (endDate: string | null) => {
if (!endDate) return null;
const end = new Date(endDate);
const now = new Date();
const diff = Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return diff;
};
const target = assignment.customTarget ?? assignment.definition?.targetValue ?? 0;
const unit = assignment.definition?.unit || '';
const goalName = assignment.definition?.name || 'Unknown Goal';
const daysRemaining = getDaysRemaining(assignment.definition?.endsAt || null);
// Calculate milestones progress
const milestones = [25, 50, 75, 100];
const achievedMilestones = milestones.filter((m) => assignment.progressPercentage >= m);
return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
<li>
<a href="/dashboard/goals" className="text-gray-400 hover:text-gray-500">Goals</a>
</li>
<li>
<span className="text-gray-400 mx-2">/</span>
<a href="/dashboard/goals/assignments" className="text-gray-400 hover:text-gray-500">Assignments</a>
</li>
<li>
<span className="text-gray-400 mx-2">/</span>
<span className="text-gray-900">Assignment Detail</span>
</li>
</ol>
</nav>
{/* Header */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-semibold text-gray-900">{goalName}</h1>
<div className="mt-2 flex items-center space-x-3">
<span className={`px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(assignment.status)}`}>
{assignment.status}
</span>
{daysRemaining !== null && daysRemaining > 0 && (
<span className={`text-sm ${daysRemaining <= 7 ? 'text-red-600' : 'text-gray-500'}`}>
{daysRemaining} days remaining
</span>
)}
{daysRemaining !== null && daysRemaining <= 0 && (
<span className="text-sm text-gray-500">Ended</span>
)}
</div>
<div className="mt-2 text-sm text-gray-600">
Assigned to: {assignment.user
? `${assignment.user.firstName || ''} ${assignment.user.lastName || ''}`.trim() || assignment.user.email
: 'Unknown User'}
</div>
</div>
<div className="flex space-x-2">
{assignment.status === 'active' && (
<button
onClick={startProgressUpdate}
className="inline-flex items-center px-3 py-2 border border-blue-600 text-sm font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50"
>
Update Progress
</button>
)}
<button
onClick={handleDelete}
disabled={deleteAssignment.isPending}
className="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
>
Delete
</button>
</div>
</div>
</div>
{/* Progress Update Form */}
{showProgressForm && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Update Progress</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">
New Value ({unit || 'units'})
</label>
<input
type="number"
value={progressValue}
onChange={(e) => setProgressValue(Number(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
Current: {assignment.currentValue.toLocaleString()} | Target: {target.toLocaleString()}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Notes (optional)</label>
<input
type="text"
value={progressNotes}
onChange={(e) => setProgressNotes(e.target.value)}
placeholder="Add a note about this update..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div className="mt-4 flex space-x-2">
<button
onClick={handleUpdateProgress}
disabled={updateProgress.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{updateProgress.isPending ? 'Saving...' : 'Save Progress'}
</button>
<button
onClick={() => setShowProgressForm(false)}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
)}
{/* Main Progress Card */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Progress</h3>
{/* Large Progress Display */}
<div className="text-center mb-6">
<div className="text-5xl font-bold text-gray-900">
{assignment.progressPercentage.toFixed(1)}%
</div>
<div className="text-lg text-gray-500 mt-2">
{assignment.currentValue.toLocaleString()} / {target.toLocaleString()} {unit}
</div>
</div>
{/* Progress Bar */}
<div className="mb-6">
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className={`h-4 rounded-full transition-all duration-500 ${getProgressBarColor(assignment.progressPercentage)}`}
style={{ width: `${Math.min(assignment.progressPercentage, 100)}%` }}
/>
</div>
</div>
{/* Milestones */}
<div className="flex justify-between">
{milestones.map((milestone) => (
<div key={milestone} className="text-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mx-auto ${
achievedMilestones.includes(milestone)
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-500'
}`}
>
{achievedMilestones.includes(milestone) ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<span className="text-xs font-medium">{milestone}%</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">{milestone}%</div>
</div>
))}
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Assignment Details */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Assignment Details</h3>
<dl className="space-y-4">
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">Assignee Type</dt>
<dd className="text-sm text-gray-900 capitalize">{assignment.assigneeType}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">Custom Target</dt>
<dd className="text-sm text-gray-900">
{assignment.customTarget ? `${assignment.customTarget.toLocaleString()} ${unit}` : 'Using default'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd>
<select
value={assignment.status}
onChange={(e) => handleStatusChange(e.target.value as AssignmentStatus)}
disabled={updateStatus.isPending}
className={`px-2 py-1 text-sm font-semibold rounded-full border-0 cursor-pointer ${getStatusColor(assignment.status)}`}
>
<option value="active">Active</option>
<option value="achieved">Achieved</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</dd>
</div>
{assignment.achievedAt && (
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">Achieved At</dt>
<dd className="text-sm text-gray-900">{formatDateTime(assignment.achievedAt)}</dd>
</div>
)}
{assignment.notes && (
<div>
<dt className="text-sm font-medium text-gray-500 mb-1">Notes</dt>
<dd className="text-sm text-gray-900 bg-gray-50 p-2 rounded">{assignment.notes}</dd>
</div>
)}
</dl>
</div>
{/* Goal Details */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Goal Details</h3>
<dl className="space-y-4">
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">Goal Name</dt>
<dd className="text-sm text-gray-900">
<a
href={`/dashboard/goals/definitions/${assignment.definitionId}`}
className="text-blue-600 hover:underline"
>
{goalName}
</a>
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">Target Value</dt>
<dd className="text-sm text-gray-900">{assignment.definition?.targetValue?.toLocaleString()} {unit}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">Start Date</dt>
<dd className="text-sm text-gray-900">{formatDate(assignment.definition?.startsAt || null)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm font-medium text-gray-500">End Date</dt>
<dd className="text-sm text-gray-900">{formatDate(assignment.definition?.endsAt || null)}</dd>
</div>
</dl>
</div>
</div>
{/* Progress History */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="p-6 border-b">
<h3 className="text-lg font-medium text-gray-900">Progress History</h3>
</div>
{history && history.length > 0 ? (
<div className="divide-y divide-gray-200">
{history.map((log: ProgressLog) => (
<div key={log.id} className="p-4 hover:bg-gray-50">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-2">
<span className="text-lg font-semibold text-gray-900">
{log.newValue.toLocaleString()} {unit}
</span>
{log.changeAmount !== null && log.changeAmount !== 0 && (
<span className={`text-sm ${log.changeAmount > 0 ? 'text-green-600' : 'text-red-600'}`}>
({log.changeAmount > 0 ? '+' : ''}{log.changeAmount.toLocaleString()})
</span>
)}
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${getSourceColor(log.source)}`}>
{log.source}
</span>
</div>
{log.previousValue !== null && (
<div className="text-xs text-gray-500 mt-1">
Previous: {log.previousValue.toLocaleString()} {unit}
</div>
)}
{log.notes && (
<div className="text-sm text-gray-600 mt-2">{log.notes}</div>
)}
{log.sourceReference && (
<div className="text-xs text-gray-400 mt-1">Ref: {log.sourceReference}</div>
)}
</div>
<div className="text-right">
<div className="text-sm text-gray-500">{formatDateTime(log.loggedAt)}</div>
{log.loggedBy && (
<div className="text-xs text-gray-400">by {log.loggedBy}</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-10">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No history yet</h3>
<p className="mt-1 text-sm text-gray-500">Progress updates will appear here.</p>
</div>
)}
</div>
{/* Metadata */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Metadata</h3>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-sm font-medium text-gray-500">Created</dt>
<dd className="mt-1 text-sm text-gray-900">{formatDateTime(assignment.createdAt)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dd className="mt-1 text-sm text-gray-900">{formatDateTime(assignment.updatedAt)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Progress Update</dt>
<dd className="mt-1 text-sm text-gray-900">{formatDateTime(assignment.lastUpdatedAt)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Assignment ID</dt>
<dd className="mt-1 text-xs text-gray-500 font-mono">{assignment.id}</dd>
</div>
</dl>
</div>
</div>
);
}

View File

@ -0,0 +1,283 @@
import { useState } from 'react';
import {
useGoalDefinitions,
useDeleteGoalDefinition,
useUpdateGoalStatus,
useDuplicateGoalDefinition,
} from '@/hooks/useGoals';
import type { DefinitionFilters, GoalDefinition, GoalStatus, PeriodType } from '@/services/goals';
export default function DefinitionsPage() {
const [filters, setFilters] = useState<DefinitionFilters>({ page: 1, limit: 20 });
const { data, isLoading, error } = useGoalDefinitions(filters);
const deleteDefinition = useDeleteGoalDefinition();
const updateStatus = useUpdateGoalStatus();
const duplicateDefinition = useDuplicateGoalDefinition();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this goal definition?')) {
await deleteDefinition.mutateAsync(id);
}
};
const handleStatusChange = async (id: string, status: GoalStatus) => {
await updateStatus.mutateAsync({ id, status });
};
const handleDuplicate = async (id: string) => {
await duplicateDefinition.mutateAsync(id);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading goal definitions</p>
</div>
);
}
const getStatusColor = (status: GoalStatus) => {
const colors: Record<GoalStatus, string> = {
draft: 'bg-gray-100 text-gray-800',
active: 'bg-green-100 text-green-800',
paused: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getPeriodLabel = (period: PeriodType) => {
const labels: Record<PeriodType, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
custom: 'Custom',
};
return labels[period] || period;
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString();
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Goal Definitions</h1>
<a
href="/dashboard/goals/definitions/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ New Definition
</a>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.status || ''}
onChange={(e) => setFilters((prev) => ({
...prev,
status: (e.target.value || undefined) as GoalStatus | undefined,
page: 1
}))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Period</label>
<select
value={filters.period || ''}
onChange={(e) => setFilters((prev) => ({
...prev,
period: (e.target.value || undefined) as PeriodType | undefined,
page: 1
}))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All Periods</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Category</label>
<input
type="text"
value={filters.category || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, category: e.target.value || undefined, page: 1 }))}
placeholder="Filter by category..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Search</label>
<input
type="text"
value={filters.search || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, search: e.target.value || undefined, page: 1 }))}
placeholder="Search by name..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
{/* Definitions Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Target</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Period</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Assigned</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data?.items?.map((definition: GoalDefinition) => (
<tr key={definition.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{definition.name}</div>
{definition.category && (
<div className="text-xs text-gray-500">{definition.category}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{definition.targetValue.toLocaleString()} {definition.unit || ''}
</div>
<div className="text-xs text-gray-500 capitalize">{definition.type} - {definition.metric}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{getPeriodLabel(definition.period)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div>{formatDate(definition.startsAt)}</div>
<div className="text-xs">to {formatDate(definition.endsAt)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{definition.assignmentCount ?? 0} users
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={definition.status}
onChange={(e) => handleStatusChange(definition.id, e.target.value as GoalStatus)}
disabled={updateStatus.isPending}
className={`px-2 py-1 text-xs font-semibold rounded-full border-0 cursor-pointer ${getStatusColor(definition.status)}`}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
<a
href={`/dashboard/goals/definitions/${definition.id}`}
className="text-blue-600 hover:text-blue-900"
>
View
</a>
<a
href={`/dashboard/goals/definitions/${definition.id}/edit`}
className="text-gray-600 hover:text-gray-900"
>
Edit
</a>
<button
onClick={() => handleDuplicate(definition.id)}
className="text-purple-600 hover:text-purple-900"
disabled={duplicateDefinition.isPending}
>
Duplicate
</button>
<button
onClick={() => handleDelete(definition.id)}
className="text-red-600 hover:text-red-900"
disabled={deleteDefinition.isPending}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{(!data?.items || data.items.length === 0) && (
<div className="text-center py-10">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No goal definitions</h3>
<p className="mt-1 text-sm text-gray-500">Get started by creating a new goal definition.</p>
<div className="mt-6">
<a
href="/dashboard/goals/definitions/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ New Definition
</a>
</div>
</div>
)}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-gray-700">
Showing page {data.page} of {data.totalPages} ({data.total} total)
</div>
<div className="flex space-x-2">
<button
onClick={() => setFilters((prev) => ({ ...prev, page: (prev.page || 1) - 1 }))}
disabled={data.page <= 1}
className="px-3 py-1 border rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Previous
</button>
<button
onClick={() => setFilters((prev) => ({ ...prev, page: (prev.page || 1) + 1 }))}
disabled={data.page >= data.totalPages}
className="px-3 py-1 border rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,440 @@
import { useParams, useNavigate } from 'react-router-dom';
import {
useGoalDefinition,
useGoalAssignmentsByGoal,
useUpdateGoalStatus,
useDeleteGoalDefinition,
useDuplicateGoalDefinition,
} from '@/hooks/useGoals';
import type { GoalStatus, AssignmentStatus, Assignment } from '@/services/goals';
export default function GoalDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: goal, isLoading, error } = useGoalDefinition(id || '');
const { data: assignments } = useGoalAssignmentsByGoal(id || '');
const updateStatus = useUpdateGoalStatus();
const deleteGoal = useDeleteGoalDefinition();
const duplicateGoal = useDuplicateGoalDefinition();
const handleStatusChange = async (status: GoalStatus) => {
if (id) {
await updateStatus.mutateAsync({ id, status });
}
};
const handleDelete = async () => {
if (id && window.confirm('Are you sure you want to delete this goal definition? All assignments will be affected.')) {
await deleteGoal.mutateAsync(id);
navigate('/dashboard/goals/definitions');
}
};
const handleDuplicate = async () => {
if (id) {
await duplicateGoal.mutateAsync(id);
navigate('/dashboard/goals/definitions');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error || !goal) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading goal definition</p>
<a href="/dashboard/goals/definitions" className="text-blue-600 hover:underline mt-2 inline-block">
Back to definitions
</a>
</div>
);
}
const getStatusColor = (status: GoalStatus) => {
const colors: Record<GoalStatus, string> = {
draft: 'bg-gray-100 text-gray-800',
active: 'bg-green-100 text-green-800',
paused: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getAssignmentStatusColor = (status: AssignmentStatus) => {
const colors: Record<AssignmentStatus, string> = {
active: 'bg-blue-100 text-blue-800',
achieved: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getProgressBarColor = (percentage: number) => {
if (percentage >= 100) return 'bg-green-500';
if (percentage >= 75) return 'bg-blue-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString();
};
const formatDateTime = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
const getPeriodLabel = (period: string) => {
const labels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
custom: 'Custom',
};
return labels[period] || period;
};
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
target: 'Target (reach a value)',
limit: 'Limit (stay under)',
maintain: 'Maintain (keep within range)',
};
return labels[type] || type;
};
// Calculate aggregate progress
const totalAssignments = assignments?.length || 0;
const achievedCount = assignments?.filter((a) => a.status === 'achieved').length || 0;
const activeCount = assignments?.filter((a) => a.status === 'active').length || 0;
const averageProgress = totalAssignments > 0
? assignments!.reduce((sum, a) => sum + a.progressPercentage, 0) / totalAssignments
: 0;
return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
<li>
<a href="/dashboard/goals" className="text-gray-400 hover:text-gray-500">Goals</a>
</li>
<li>
<span className="text-gray-400 mx-2">/</span>
<a href="/dashboard/goals/definitions" className="text-gray-400 hover:text-gray-500">Definitions</a>
</li>
<li>
<span className="text-gray-400 mx-2">/</span>
<span className="text-gray-900">{goal.name}</span>
</li>
</ol>
</nav>
{/* Header */}
<div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center space-x-3">
<h1 className="text-2xl font-semibold text-gray-900">{goal.name}</h1>
<span className={`px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(goal.status)}`}>
{goal.status}
</span>
</div>
{goal.description && (
<p className="mt-2 text-gray-600">{goal.description}</p>
)}
{goal.category && (
<span className="mt-2 inline-block px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
{goal.category}
</span>
)}
</div>
<div className="flex space-x-2">
<a
href={`/dashboard/goals/definitions/${id}/edit`}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Edit
</a>
<button
onClick={handleDuplicate}
disabled={duplicateGoal.isPending}
className="inline-flex items-center px-3 py-2 border border-purple-300 text-sm font-medium rounded-md text-purple-700 bg-white hover:bg-purple-50"
>
Duplicate
</button>
<button
onClick={handleDelete}
disabled={deleteGoal.isPending}
className="inline-flex items-center px-3 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
>
Delete
</button>
</div>
</div>
</div>
{/* Goal Details Grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Target & Metrics */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Target & Metrics</h3>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Target Value</dt>
<dd className="mt-1 text-lg font-semibold text-gray-900">
{goal.targetValue.toLocaleString()} {goal.unit || ''}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Type</dt>
<dd className="mt-1 text-sm text-gray-900 capitalize">{getTypeLabel(goal.type)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Metric</dt>
<dd className="mt-1 text-sm text-gray-900 capitalize">{goal.metric}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Data Source</dt>
<dd className="mt-1 text-sm text-gray-900 capitalize">{goal.source}</dd>
</div>
</dl>
</div>
{/* Duration */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Duration</h3>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Period</dt>
<dd className="mt-1 text-sm text-gray-900">{getPeriodLabel(goal.period)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="mt-1">
<select
value={goal.status}
onChange={(e) => handleStatusChange(e.target.value as GoalStatus)}
disabled={updateStatus.isPending}
className={`px-2 py-1 text-sm font-semibold rounded-full border-0 cursor-pointer ${getStatusColor(goal.status)}`}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Start Date</dt>
<dd className="mt-1 text-sm text-gray-900">{formatDate(goal.startsAt)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">End Date</dt>
<dd className="mt-1 text-sm text-gray-900">{formatDate(goal.endsAt)}</dd>
</div>
</dl>
</div>
</div>
{/* Milestones */}
{goal.milestones && goal.milestones.length > 0 && (
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Milestones</h3>
<div className="flex space-x-4 overflow-x-auto pb-2">
{goal.milestones.map((milestone, index) => (
<div key={index} className="flex-shrink-0 w-32 p-3 border rounded-lg text-center">
<div className="text-2xl font-bold text-blue-600">{milestone.percentage}%</div>
<div className="text-xs text-gray-500 mt-1">
{milestone.notify ? 'Notifications ON' : 'Notifications OFF'}
</div>
</div>
))}
</div>
</div>
)}
{/* Aggregate Progress */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Aggregate Progress</h3>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-4">
<div className="bg-gray-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-gray-900">{totalAssignments}</div>
<div className="text-sm text-gray-500">Total Assigned</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{activeCount}</div>
<div className="text-sm text-gray-500">Active</div>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">{achievedCount}</div>
<div className="text-sm text-gray-500">Achieved</div>
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-purple-600">{averageProgress.toFixed(1)}%</div>
<div className="text-sm text-gray-500">Avg Progress</div>
</div>
</div>
{/* Overall Progress Bar */}
<div className="mt-6">
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-600">Overall Completion Rate</span>
<span className="font-medium text-gray-900">
{totalAssignments > 0 ? ((achievedCount / totalAssignments) * 100).toFixed(1) : 0}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-300 ${getProgressBarColor(
totalAssignments > 0 ? (achievedCount / totalAssignments) * 100 : 0
)}`}
style={{ width: `${totalAssignments > 0 ? (achievedCount / totalAssignments) * 100 : 0}%` }}
/>
</div>
</div>
</div>
{/* Assignments List */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="p-6 border-b">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900">Assignments ({totalAssignments})</h3>
<a
href={`/dashboard/goals/assignments/new?goalId=${id}`}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Assign Goal
</a>
</div>
</div>
{assignments && assignments.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Assignee</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Progress</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{assignments.map((assignment: Assignment) => {
const target = assignment.customTarget ?? goal.targetValue;
return (
<tr key={assignment.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{assignment.user
? `${assignment.user.firstName || ''} ${assignment.user.lastName || ''}`.trim() || assignment.user.email
: 'Unknown User'}
</div>
<div className="text-xs text-gray-500 capitalize">{assignment.assigneeType}</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center">
<div className="flex-1 mr-4">
<div className="flex justify-between text-xs mb-1">
<span>{assignment.currentValue.toLocaleString()}</span>
<span>{target.toLocaleString()} {goal.unit || ''}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getProgressBarColor(assignment.progressPercentage)}`}
style={{ width: `${Math.min(assignment.progressPercentage, 100)}%` }}
/>
</div>
</div>
<span className="text-sm font-medium text-gray-900 w-12 text-right">
{assignment.progressPercentage.toFixed(0)}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getAssignmentStatusColor(assignment.status)}`}>
{assignment.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{assignment.lastUpdatedAt ? formatDateTime(assignment.lastUpdatedAt) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a
href={`/dashboard/goals/assignments/${assignment.id}`}
className="text-blue-600 hover:text-blue-900"
>
View
</a>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="text-center py-10">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No assignments yet</h3>
<p className="mt-1 text-sm text-gray-500">Get started by assigning this goal to users.</p>
<div className="mt-6">
<a
href={`/dashboard/goals/assignments/new?goalId=${id}`}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Assign Goal
</a>
</div>
</div>
)}
</div>
{/* History / Metadata */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">History</h3>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<dt className="text-sm font-medium text-gray-500">Created</dt>
<dd className="mt-1 text-sm text-gray-900">{formatDateTime(goal.createdAt)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Last Updated</dt>
<dd className="mt-1 text-sm text-gray-900">{formatDateTime(goal.updatedAt)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Created By</dt>
<dd className="mt-1 text-sm text-gray-900">{goal.createdBy || 'System'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Tags</dt>
<dd className="mt-1 flex flex-wrap gap-1">
{goal.tags && goal.tags.length > 0 ? (
goal.tags.map((tag, i) => (
<span key={i} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{tag}
</span>
))
) : (
<span className="text-sm text-gray-500">No tags</span>
)}
</dd>
</div>
</dl>
</div>
</div>
);
}

View File

@ -0,0 +1,235 @@
import {
useGoalDefinitions,
useMyGoalsSummary,
useGoalCompletionReport,
useGoalUserReport,
} from '@/hooks/useGoals';
import type { GoalDefinition, GoalStatus } from '@/services/goals';
export default function GoalsPage() {
const { data: definitions, isLoading } = useGoalDefinitions({ status: 'active', limit: 5 });
const { data: mySummary } = useMyGoalsSummary();
const { data: completionReport } = useGoalCompletionReport();
const { data: userReport } = useGoalUserReport();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
const getStatusColor = (status: GoalStatus) => {
const colors: Record<GoalStatus, string> = {
draft: 'bg-gray-100 text-gray-800',
active: 'bg-green-100 text-green-800',
paused: 'bg-yellow-100 text-yellow-800',
completed: 'bg-blue-100 text-blue-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Goals Dashboard</h1>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">My Active Goals</dt>
<dd className="text-lg font-semibold text-gray-900">
{mySummary?.activeAssignments ?? 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Achieved</dt>
<dd className="text-lg font-semibold text-gray-900">
{mySummary?.achievedAssignments ?? 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">At Risk</dt>
<dd className="text-lg font-semibold text-gray-900">
{mySummary?.atRiskCount ?? 0}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Avg Progress</dt>
<dd className="text-lg font-semibold text-gray-900">
{(mySummary?.averageProgress ?? 0).toFixed(1)}%
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Overall Completion Stats */}
{completionReport && (
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Organization Overview</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Total Goals</span>
<span className="text-sm font-medium text-gray-900">{completionReport.totalGoals}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Achieved</span>
<span className="text-sm font-medium text-green-600">{completionReport.achievedGoals}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Completion Rate</span>
<span className="text-sm font-medium text-gray-900">{completionReport.completionRate.toFixed(1)}%</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Avg Progress</span>
<span className="text-sm font-medium text-gray-900">{completionReport.averageProgress.toFixed(1)}%</span>
</div>
</div>
</div>
)}
{/* Two Column Layout */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Active Goal Definitions */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Active Goals</h3>
<div className="space-y-3">
{definitions?.items?.map((goal: GoalDefinition) => (
<div key={goal.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{goal.name}</p>
<p className="text-xs text-gray-500">
Target: {goal.targetValue.toLocaleString()} {goal.unit || ''}
</p>
</div>
<div className="flex items-center space-x-2">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(goal.status)}`}>
{goal.status}
</span>
<span className="text-xs text-gray-500">
{goal.assignmentCount ?? 0} assigned
</span>
</div>
</div>
))}
{(!definitions?.items || definitions.items.length === 0) && (
<p className="text-sm text-gray-500 text-center py-4">No active goals defined</p>
)}
</div>
</div>
{/* Top Performers */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Top Performers</h3>
<div className="space-y-3">
{userReport?.slice(0, 5).map((user, index) => (
<div key={user.userId} className="flex items-center justify-between">
<div className="flex items-center">
<span className="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-xs font-medium text-gray-600 mr-2">
{index + 1}
</span>
<span className="text-sm text-gray-600">{user.userName || 'Unknown User'}</span>
</div>
<div className="flex items-center space-x-3">
<span className="text-sm font-medium text-green-600">{user.achieved} achieved</span>
<span className="text-xs text-gray-500">{user.averageProgress.toFixed(0)}% avg</span>
</div>
</div>
))}
{(!userReport || userReport.length === 0) && (
<p className="text-sm text-gray-500 text-center py-4">No performance data yet</p>
)}
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<a
href="/dashboard/goals/my-goals"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">My Goals</span>
</a>
<a
href="/dashboard/goals/definitions"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Definitions</span>
</a>
<a
href="/dashboard/goals/assignments"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Assignments</span>
</a>
<a
href="/dashboard/goals/definitions/new"
className="flex items-center p-4 border border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100"
>
<span className="text-sm font-medium text-blue-900">+ New Goal</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,349 @@
import { useState } from 'react';
import {
useMyGoals,
useMyGoalsSummary,
useUpdateMyGoalProgress,
} from '@/hooks/useGoals';
import type { Assignment, AssignmentStatus, UpdateProgressDto } from '@/services/goals';
export default function MyGoalsPage() {
const { data: goals, isLoading, error } = useMyGoals();
const { data: summary } = useMyGoalsSummary();
const updateProgress = useUpdateMyGoalProgress();
const [editingGoal, setEditingGoal] = useState<string | null>(null);
const [progressValue, setProgressValue] = useState<number>(0);
const [progressNotes, setProgressNotes] = useState<string>('');
const handleUpdateProgress = async (id: string) => {
const data: UpdateProgressDto = {
value: progressValue,
source: 'manual',
notes: progressNotes || undefined,
};
await updateProgress.mutateAsync({ id, data });
setEditingGoal(null);
setProgressValue(0);
setProgressNotes('');
};
const startEditing = (goal: Assignment) => {
setEditingGoal(goal.id);
setProgressValue(goal.currentValue);
setProgressNotes('');
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading your goals</p>
</div>
);
}
const getStatusColor = (status: AssignmentStatus) => {
const colors: Record<AssignmentStatus, string> = {
active: 'bg-blue-100 text-blue-800',
achieved: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getProgressBarColor = (percentage: number) => {
if (percentage >= 100) return 'bg-green-500';
if (percentage >= 75) return 'bg-blue-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString();
};
const getDaysRemaining = (endDate: string) => {
const end = new Date(endDate);
const now = new Date();
const diff = Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
return diff;
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">My Goals</h1>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total</dt>
<dd className="text-lg font-semibold text-gray-900">{summary?.totalAssignments ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active</dt>
<dd className="text-lg font-semibold text-gray-900">{summary?.activeAssignments ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Achieved</dt>
<dd className="text-lg font-semibold text-gray-900">{summary?.achievedAssignments ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">At Risk</dt>
<dd className="text-lg font-semibold text-gray-900">{summary?.atRiskCount ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Avg Progress</dt>
<dd className="text-lg font-semibold text-gray-900">{(summary?.averageProgress ?? 0).toFixed(1)}%</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Goals List */}
<div className="space-y-4">
{goals?.map((goal: Assignment) => {
const target = goal.customTarget ?? goal.definition?.targetValue ?? 0;
const daysRemaining = goal.definition?.endsAt ? getDaysRemaining(goal.definition.endsAt) : null;
const isEditing = editingGoal === goal.id;
return (
<div key={goal.id} className="bg-white shadow rounded-lg overflow-hidden">
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-medium text-gray-900">{goal.definition?.name || 'Unknown Goal'}</h3>
<p className="text-sm text-gray-500 mt-1">
Target: {target.toLocaleString()} {goal.definition?.unit || ''}
</p>
</div>
<div className="flex items-center space-x-3">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(goal.status)}`}>
{goal.status}
</span>
{daysRemaining !== null && daysRemaining > 0 && (
<span className={`text-xs ${daysRemaining <= 7 ? 'text-red-600' : 'text-gray-500'}`}>
{daysRemaining} days left
</span>
)}
{daysRemaining !== null && daysRemaining <= 0 && (
<span className="text-xs text-gray-500">Ended</span>
)}
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">
Current: {goal.currentValue.toLocaleString()} {goal.definition?.unit || ''}
</span>
<span className="font-medium text-gray-900">{goal.progressPercentage.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-300 ${getProgressBarColor(goal.progressPercentage)}`}
style={{ width: `${Math.min(goal.progressPercentage, 100)}%` }}
/>
</div>
</div>
{/* Duration Info */}
<div className="flex justify-between text-xs text-gray-500 mb-4">
<span>Start: {formatDate(goal.definition?.startsAt || null)}</span>
<span>End: {formatDate(goal.definition?.endsAt || null)}</span>
</div>
{/* Update Progress Form */}
{isEditing ? (
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-medium text-gray-900 mb-3">Update Progress</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">New Value</label>
<input
type="number"
value={progressValue}
onChange={(e) => setProgressValue(Number(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Notes (optional)</label>
<input
type="text"
value={progressNotes}
onChange={(e) => setProgressNotes(e.target.value)}
placeholder="Add a note..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div className="mt-3 flex space-x-2">
<button
onClick={() => handleUpdateProgress(goal.id)}
disabled={updateProgress.isPending}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{updateProgress.isPending ? 'Saving...' : 'Save Progress'}
</button>
<button
onClick={() => setEditingGoal(null)}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex justify-between items-center border-t pt-4 mt-4">
{goal.lastUpdatedAt && (
<span className="text-xs text-gray-500">
Last updated: {formatDate(goal.lastUpdatedAt)}
</span>
)}
<div className="flex space-x-2">
{goal.status === 'active' && (
<button
onClick={() => startEditing(goal)}
className="inline-flex items-center px-3 py-1.5 border border-blue-600 text-sm font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50"
>
Update Progress
</button>
)}
<a
href={`/dashboard/goals/assignments/${goal.id}`}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
View Details
</a>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
{(!goals || goals.length === 0) && (
<div className="text-center py-10 bg-white rounded-lg shadow">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No goals assigned</h3>
<p className="mt-1 text-sm text-gray-500">You don't have any goals assigned yet.</p>
<div className="mt-6">
<a
href="/dashboard/goals"
className="text-blue-600 hover:text-blue-900"
>
View all goals
</a>
</div>
</div>
)}
{/* Quick Links */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<a
href="/dashboard/goals"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Dashboard</span>
</a>
<a
href="/dashboard/goals/definitions"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">All Definitions</span>
</a>
<a
href="/dashboard/goals/assignments"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">All Assignments</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,441 @@
import { useState } from 'react';
import {
useGoalCompletionReport,
useGoalUserReport,
useGoalDefinitions,
} from '@/hooks/useGoals';
import type { UserReport, GoalDefinition } from '@/services/goals';
export default function ReportsPage() {
const [dateRange, setDateRange] = useState<{ start?: string; end?: string }>({});
const { data: completionReport, isLoading: loadingCompletion } = useGoalCompletionReport(
dateRange.start,
dateRange.end
);
const { data: userReport, isLoading: loadingUsers } = useGoalUserReport();
const { data: definitions } = useGoalDefinitions({ status: 'active', limit: 10 });
const isLoading = loadingCompletion || loadingUsers;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
const getProgressBarColor = (percentage: number) => {
if (percentage >= 100) return 'bg-green-500';
if (percentage >= 75) return 'bg-blue-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
const handleExport = (type: 'completion' | 'users') => {
let csvContent = '';
let filename = '';
if (type === 'completion' && completionReport) {
csvContent = 'Metric,Value\n';
csvContent += `Total Goals,${completionReport.totalGoals}\n`;
csvContent += `Achieved Goals,${completionReport.achievedGoals}\n`;
csvContent += `Failed Goals,${completionReport.failedGoals}\n`;
csvContent += `Active Goals,${completionReport.activeGoals}\n`;
csvContent += `Completion Rate,${completionReport.completionRate.toFixed(2)}%\n`;
csvContent += `Average Progress,${completionReport.averageProgress.toFixed(2)}%\n`;
filename = `goals-completion-report-${new Date().toISOString().split('T')[0]}.csv`;
} else if (type === 'users' && userReport) {
csvContent = 'User,Total Assignments,Achieved,Failed,Active,Average Progress\n';
userReport.forEach((user) => {
csvContent += `"${user.userName || 'Unknown'}",${user.totalAssignments},${user.achieved},${user.failed},${user.active},${user.averageProgress.toFixed(2)}%\n`;
});
filename = `goals-user-report-${new Date().toISOString().split('T')[0]}.csv`;
}
if (csvContent) {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
}
};
// Calculate additional metrics
const totalUsers = userReport?.length || 0;
const usersWithAchievements = userReport?.filter((u) => u.achieved > 0).length || 0;
const topPerformer = userReport && userReport.length > 0
? userReport.reduce((best, user) =>
user.averageProgress > best.averageProgress ? user : best
)
: null;
return (
<div className="space-y-6">
{/* Breadcrumb */}
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
<li>
<a href="/dashboard/goals" className="text-gray-400 hover:text-gray-500">Goals</a>
</li>
<li>
<span className="text-gray-400 mx-2">/</span>
<span className="text-gray-900">Reports</span>
</li>
</ol>
</nav>
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Goals Reports</h1>
<div className="flex space-x-2">
<button
onClick={() => handleExport('completion')}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export Summary
</button>
<button
onClick={() => handleExport('users')}
className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export User Report
</button>
</div>
</div>
{/* Date Filter */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Start Date</label>
<input
type="date"
value={dateRange.start || ''}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value || undefined }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">End Date</label>
<input
type="date"
value={dateRange.end || ''}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value || undefined }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div className="flex items-end">
<button
onClick={() => setDateRange({})}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Clear Filters
</button>
</div>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Goals</dt>
<dd className="text-2xl font-semibold text-gray-900">{completionReport?.totalGoals ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Achieved</dt>
<dd className="text-2xl font-semibold text-green-600">{completionReport?.achievedGoals ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Completion Rate</dt>
<dd className="text-2xl font-semibold text-gray-900">{(completionReport?.completionRate ?? 0).toFixed(1)}%</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Avg Progress</dt>
<dd className="text-2xl font-semibold text-gray-900">{(completionReport?.averageProgress ?? 0).toFixed(1)}%</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Overall Progress Visualization */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-6">Goal Status Distribution</h3>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Status Breakdown */}
<div>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Achieved</span>
<span className="font-medium text-green-600">{completionReport?.achievedGoals ?? 0}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="h-3 rounded-full bg-green-500"
style={{
width: `${completionReport?.totalGoals ? (completionReport.achievedGoals / completionReport.totalGoals) * 100 : 0}%`
}}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Active</span>
<span className="font-medium text-blue-600">{completionReport?.activeGoals ?? 0}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="h-3 rounded-full bg-blue-500"
style={{
width: `${completionReport?.totalGoals ? (completionReport.activeGoals / completionReport.totalGoals) * 100 : 0}%`
}}
/>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Failed</span>
<span className="font-medium text-red-600">{completionReport?.failedGoals ?? 0}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="h-3 rounded-full bg-red-500"
style={{
width: `${completionReport?.totalGoals ? (completionReport.failedGoals / completionReport.totalGoals) * 100 : 0}%`
}}
/>
</div>
</div>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 p-4 rounded-lg text-center">
<div className="text-3xl font-bold text-gray-900">{totalUsers}</div>
<div className="text-sm text-gray-500">Total Users</div>
</div>
<div className="bg-green-50 p-4 rounded-lg text-center">
<div className="text-3xl font-bold text-green-600">{usersWithAchievements}</div>
<div className="text-sm text-gray-500">With Achievements</div>
</div>
{topPerformer && (
<div className="col-span-2 bg-purple-50 p-4 rounded-lg">
<div className="text-sm text-gray-500 mb-1">Top Performer</div>
<div className="text-lg font-semibold text-purple-700">
{topPerformer.userName || 'Unknown User'}
</div>
<div className="text-sm text-gray-600">
{topPerformer.averageProgress.toFixed(1)}% average progress
</div>
</div>
)}
</div>
</div>
</div>
{/* User Performance Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="p-6 border-b">
<h3 className="text-lg font-medium text-gray-900">User Performance</h3>
</div>
{userReport && userReport.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rank</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Progress</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Achieved</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Active</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Failed</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{userReport
.sort((a, b) => b.averageProgress - a.averageProgress)
.map((user: UserReport, index) => (
<tr key={user.userId} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
index === 0 ? 'bg-yellow-100 text-yellow-800' :
index === 1 ? 'bg-gray-100 text-gray-800' :
index === 2 ? 'bg-orange-100 text-orange-800' :
'bg-gray-50 text-gray-600'
}`}>
{index + 1}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{user.userName || 'Unknown User'}</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center">
<div className="flex-1 mr-3">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getProgressBarColor(user.averageProgress)}`}
style={{ width: `${Math.min(user.averageProgress, 100)}%` }}
/>
</div>
</div>
<span className="text-sm font-medium text-gray-900 w-12 text-right">
{user.averageProgress.toFixed(0)}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-green-600">{user.achieved}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-blue-600">{user.active}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-red-600">{user.failed}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-500">{user.totalAssignments}</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-center py-10">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No user data yet</h3>
<p className="mt-1 text-sm text-gray-500">Assign goals to users to see performance data.</p>
</div>
)}
</div>
{/* Active Goals Summary */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Active Goals Overview</h3>
<div className="space-y-4">
{definitions?.items?.map((goal: GoalDefinition) => (
<div key={goal.id} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{goal.name}</div>
<div className="text-xs text-gray-500 mt-1">
Target: {goal.targetValue.toLocaleString()} {goal.unit || ''} | Period: {goal.period}
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="text-sm font-medium text-gray-900">{goal.assignmentCount ?? 0}</div>
<div className="text-xs text-gray-500">assigned</div>
</div>
<a
href={`/dashboard/goals/definitions/${goal.id}`}
className="text-blue-600 hover:text-blue-900 text-sm"
>
View
</a>
</div>
</div>
))}
{(!definitions?.items || definitions.items.length === 0) && (
<p className="text-sm text-gray-500 text-center py-4">No active goals</p>
)}
</div>
</div>
{/* Quick Links */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<a
href="/dashboard/goals"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Dashboard</span>
</a>
<a
href="/dashboard/goals/definitions"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Definitions</span>
</a>
<a
href="/dashboard/goals/assignments"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Assignments</span>
</a>
<a
href="/dashboard/goals/my-goals"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">My Goals</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
export { default as GoalsPage } from './GoalsPage';
export { default as DefinitionsPage } from './DefinitionsPage';
export { default as MyGoalsPage } from './MyGoalsPage';
export { default as GoalDetailPage } from './GoalDetailPage';
export { default as AssignmentDetailPage } from './AssignmentDetailPage';
export { default as ReportsPage } from './ReportsPage';

View File

@ -0,0 +1,237 @@
import { useMyDashboard, useStructures } from '@/hooks/useMlm';
export default function MLMPage() {
const { data: dashboard, isLoading } = useMyDashboard();
const { data: structures } = useStructures({ isActive: true });
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">MLM Dashboard</h1>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Downline</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.totalDownline ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Direct Referrals</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.directReferrals ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Group Volume</dt>
<dd className="text-lg font-semibold text-gray-900">
{(dashboard?.groupVolume ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Earnings</dt>
<dd className="text-lg font-semibold text-gray-900">
${(dashboard?.totalEarnings ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Current Rank */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Current Rank</h3>
{dashboard?.currentRank ? (
<div className="flex items-center space-x-4">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-yellow-400 to-yellow-600 flex items-center justify-center">
<span className="text-2xl font-bold text-white">{dashboard.currentRank.level}</span>
</div>
<div>
<p className="text-xl font-semibold text-gray-900">{dashboard.currentRank.name}</p>
<p className="text-sm text-gray-500">Level {dashboard.currentRank.level}</p>
</div>
</div>
) : (
<p className="text-sm text-gray-500">No rank assigned yet</p>
)}
</div>
{/* Next Rank Progress */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Next Rank Progress</h3>
{dashboard?.nextRank ? (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-900">{dashboard.nextRank.name}</span>
<span className="text-xs text-gray-500">Level {dashboard.nextRank.level}</span>
</div>
{Object.entries(dashboard.nextRank.progress || {}).map(([key, value]) => (
<div key={key}>
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span className="capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</span>
<span>{Math.min(100, Math.round(Number(value)))}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${Math.min(100, Number(value))}%` }}
></div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">You've reached the highest rank!</p>
)}
</div>
</div>
{/* Volume Stats */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Volume Summary</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Personal Volume</span>
<span className="text-sm font-medium text-gray-900">
{(dashboard?.personalVolume ?? 0).toLocaleString()}
</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Group Volume</span>
<span className="text-sm font-medium text-gray-900">
{(dashboard?.groupVolume ?? 0).toLocaleString()}
</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Active Downline</span>
<span className="text-sm font-medium text-gray-900">{dashboard?.activeDownline ?? 0}</span>
</div>
</div>
</div>
{/* Active Structures */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Active Structures</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{structures?.map((structure) => (
<div key={structure.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div>
<h4 className="font-medium text-gray-900">{structure.name}</h4>
<p className="text-sm text-gray-500 capitalize">{structure.type}</p>
</div>
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
Active
</span>
</div>
{structure.description && (
<p className="mt-2 text-sm text-gray-600 line-clamp-2">{structure.description}</p>
)}
</div>
))}
{(!structures || structures.length === 0) && (
<p className="text-sm text-gray-500 col-span-full">No active structures</p>
)}
</div>
</div>
{/* Quick Links */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<a
href="/dashboard/mlm/my-network"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">My Network</span>
</a>
<a
href="/dashboard/mlm/structures"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Structures</span>
</a>
<a
href="/dashboard/mlm/ranks"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Ranks</span>
</a>
<a
href="/dashboard/commissions/my-earnings"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">My Earnings</span>
</a>
<a
href="/dashboard/mlm/my-network"
className="flex items-center p-4 border border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100"
>
<span className="text-sm font-medium text-blue-900">+ Invite</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,445 @@
import { useState } from 'react';
import { useMyEarnings, useMLMCommissions, useCommissionsByLevel } from '@/hooks/useMlm';
import type { CommissionStatus, CommissionType } from '@/services/mlm';
export default function MyEarningsPage() {
const [periodFilter, setPeriodFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<CommissionStatus | ''>('');
const [typeFilter, setTypeFilter] = useState<CommissionType | ''>('');
const { data: earnings, isLoading: earningsLoading } = useMyEarnings();
const { data: commissionsByLevel } = useCommissionsByLevel();
const { data: commissions, isLoading: commissionsLoading } = useMLMCommissions({
status: statusFilter || undefined,
type: typeFilter || undefined,
limit: 50,
});
const isLoading = earningsLoading || commissionsLoading;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
const getStatusColor = (status: CommissionStatus) => {
const colors: Record<CommissionStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
approved: 'bg-blue-100 text-blue-800',
paid: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getTypeLabel = (type: CommissionType) => {
const labels: Record<CommissionType, string> = {
level: 'Level Commission',
matching: 'Matching Bonus',
infinity: 'Infinity Bonus',
leadership: 'Leadership Bonus',
pool: 'Pool Bonus',
};
return labels[type] || type;
};
const earningsData = earnings as {
totalCommissions?: number;
totalBonuses?: number;
totalEarnings?: number;
pendingAmount?: number;
paidAmount?: number;
} | undefined;
return (
<div className="space-y-6">
{/* Breadcrumbs */}
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
<li>
<a href="/dashboard/mlm" className="text-gray-400 hover:text-gray-500">
MLM
</a>
</li>
<li>
<svg className="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</li>
<li className="text-gray-500">My Earnings</li>
</ol>
</nav>
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">My Earnings</h1>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Earnings</dt>
<dd className="text-lg font-semibold text-gray-900">
${(earningsData?.totalEarnings ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Commissions</dt>
<dd className="text-lg font-semibold text-gray-900">
${(earningsData?.totalCommissions ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Bonuses</dt>
<dd className="text-lg font-semibold text-gray-900">
${(earningsData?.totalBonuses ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
<dd className="text-lg font-semibold text-yellow-600">
${(earningsData?.pendingAmount ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Paid Out</dt>
<dd className="text-lg font-semibold text-green-600">
${(earningsData?.paidAmount ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Earnings by Level */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Earnings by Level</h3>
{commissionsByLevel && commissionsByLevel.length > 0 ? (
<div className="space-y-3">
{commissionsByLevel.map((levelData) => (
<div key={levelData.level} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium mr-3">
{levelData.level}
</div>
<div>
<p className="text-sm font-medium text-gray-900">Level {levelData.level}</p>
<p className="text-xs text-gray-500">{levelData.count} transactions</p>
</div>
</div>
<span className="text-sm font-semibold text-green-600">
${levelData.totalAmount.toLocaleString()}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No earnings by level yet</p>
)}
</div>
{/* Earnings Summary Chart Placeholder */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Earnings Summary</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-500">Commissions</span>
<span className="font-medium text-gray-900">
${(earningsData?.totalCommissions ?? 0).toLocaleString()}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{
width: `${
earningsData?.totalEarnings
? Math.round(((earningsData?.totalCommissions ?? 0) / earningsData.totalEarnings) * 100)
: 0
}%`,
}}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-500">Bonuses</span>
<span className="font-medium text-gray-900">
${(earningsData?.totalBonuses ?? 0).toLocaleString()}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full"
style={{
width: `${
earningsData?.totalEarnings
? Math.round(((earningsData?.totalBonuses ?? 0) / earningsData.totalEarnings) * 100)
: 0
}%`,
}}
></div>
</div>
</div>
<div className="pt-4 border-t border-gray-200">
<div className="flex justify-between">
<span className="text-gray-500">Paid</span>
<span className="font-medium text-green-600">
${(earningsData?.paidAmount ?? 0).toLocaleString()}
</span>
</div>
<div className="flex justify-between mt-2">
<span className="text-gray-500">Pending</span>
<span className="font-medium text-yellow-600">
${(earningsData?.pendingAmount ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Period</label>
<select
value={periodFilter}
onChange={(e) => setPeriodFilter(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="all">All Time</option>
<option value="this_month">This Month</option>
<option value="last_month">Last Month</option>
<option value="this_quarter">This Quarter</option>
<option value="this_year">This Year</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as CommissionStatus | '')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="paid">Paid</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as CommissionType | '')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All Types</option>
<option value="level">Level Commission</option>
<option value="matching">Matching Bonus</option>
<option value="infinity">Infinity Bonus</option>
<option value="leadership">Leadership Bonus</option>
<option value="pool">Pool Bonus</option>
</select>
</div>
</div>
</div>
{/* Commissions Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Commission History</h2>
</div>
{commissions && commissions.items && commissions.items.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rate</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Commission</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{commissions.items.map((commission) => (
<tr key={commission.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(commission.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900">
{getTypeLabel(commission.type)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
L{commission.level}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="text-sm">
<p className="font-medium text-gray-900">
{commission.sourceNode?.user?.firstName || 'Unknown'}
</p>
<p className="text-xs text-gray-500">
{commission.sourceNode?.user?.email}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${commission.sourceAmount.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{commission.rateApplied}%
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-semibold text-green-600">
${commission.commissionAmount.toLocaleString()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${getStatusColor(commission.status)}`}>
{commission.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-10">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="mt-2 text-gray-500">No commission records found</p>
<p className="text-sm text-gray-400">Start referring members to earn commissions</p>
</div>
)}
</div>
{/* Quick Actions */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<a
href="/dashboard/mlm"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">MLM Dashboard</span>
</a>
<a
href="/dashboard/mlm/my-network"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">My Network</span>
</a>
<a
href="/dashboard/mlm/ranks"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">View Ranks</span>
</a>
<a
href="/dashboard/payments/history"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Payment History</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,320 @@
import { useState } from 'react';
import { useMyDashboard, useMyNetwork, useMyRank, useGenerateInviteLink } from '@/hooks/useMlm';
import type { TreeNode, NodeStatus } from '@/services/mlm';
export default function MyNetworkPage() {
const [maxDepth, setMaxDepth] = useState<number>(3);
const { data: dashboard, isLoading: dashboardLoading } = useMyDashboard();
const { data: network, isLoading: networkLoading } = useMyNetwork(maxDepth);
const { data: rankInfo } = useMyRank();
const generateInvite = useGenerateInviteLink();
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const handleGenerateInvite = async () => {
const result = await generateInvite.mutateAsync();
setInviteUrl(result.inviteUrl);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const isLoading = dashboardLoading || networkLoading;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
const getStatusColor = (status: NodeStatus) => {
const colors: Record<NodeStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
suspended: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const renderTreeNode = (node: TreeNode, depth: number = 0) => {
const indent = depth * 24;
return (
<div key={node.id}>
<div
className="flex items-center py-2 px-4 hover:bg-gray-50 border-b border-gray-100"
style={{ paddingLeft: `${indent + 16}px` }}
>
{depth > 0 && (
<span className="mr-2 text-gray-300">|--</span>
)}
<div className="flex-1 flex items-center justify-between">
<div className="flex items-center">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium mr-3"
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
>
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{node.user?.firstName && node.user?.lastName
? `${node.user.firstName} ${node.user.lastName}`
: node.user?.email || 'Unknown'}
</p>
<p className="text-xs text-gray-500">
{node.rank?.name || 'No Rank'} | Level {node.depth}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<p className="text-xs text-gray-500">PV: {node.personalVolume.toLocaleString()}</p>
<p className="text-xs text-gray-500">GV: {node.groupVolume.toLocaleString()}</p>
</div>
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${getStatusColor(node.status)}`}>
{node.status}
</span>
</div>
</div>
</div>
{node.children?.map((child) => renderTreeNode(child, depth + 1))}
</div>
);
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">My Network</h1>
<button
onClick={handleGenerateInvite}
disabled={generateInvite.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{generateInvite.isPending ? 'Generating...' : 'Generate Invite Link'}
</button>
</div>
{/* Invite Link */}
{inviteUrl && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-blue-900">Your Invite Link</p>
<p className="text-sm text-blue-700 break-all">{inviteUrl}</p>
</div>
<button
onClick={() => copyToClipboard(inviteUrl)}
className="ml-4 px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Copy
</button>
</div>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Downline</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.totalDownline ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Direct Referrals</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.directReferrals ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active Members</dt>
<dd className="text-lg font-semibold text-gray-900">{dashboard?.activeDownline ?? 0}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Group Volume</dt>
<dd className="text-lg font-semibold text-gray-900">
{(dashboard?.groupVolume ?? 0).toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Current Rank Info */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">My Rank</h3>
{dashboard?.currentRank ? (
<div className="flex items-center space-x-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-2xl font-bold"
style={{ backgroundColor: rankInfo?.color || '#6B7280' }}
>
{dashboard.currentRank.level}
</div>
<div>
<p className="text-xl font-semibold text-gray-900">{dashboard.currentRank.name}</p>
<p className="text-sm text-gray-500">Level {dashboard.currentRank.level}</p>
</div>
</div>
) : (
<p className="text-sm text-gray-500">No rank assigned yet</p>
)}
</div>
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Volume Summary</h3>
<div className="space-y-3">
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Personal Volume</span>
<span className="text-sm font-medium text-gray-900">
{(dashboard?.personalVolume ?? 0).toLocaleString()}
</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Group Volume</span>
<span className="text-sm font-medium text-gray-900">
{(dashboard?.groupVolume ?? 0).toLocaleString()}
</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Total Earnings</span>
<span className="text-sm font-medium text-green-600">
${(dashboard?.totalEarnings ?? 0).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
{/* Network Tree */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-lg font-medium text-gray-900">Network Tree</h2>
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-500">Depth:</label>
<select
value={maxDepth}
onChange={(e) => setMaxDepth(Number(e.target.value))}
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value={1}>1 Level</option>
<option value={2}>2 Levels</option>
<option value={3}>3 Levels</option>
<option value={5}>5 Levels</option>
<option value={10}>10 Levels</option>
</select>
</div>
</div>
{network ? (
<div className="max-h-[600px] overflow-y-auto">
{renderTreeNode(network)}
</div>
) : (
<div className="text-center py-10">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<p className="mt-2 text-gray-500">You're not part of any MLM structure yet</p>
<p className="text-sm text-gray-400">Contact your administrator to get started</p>
</div>
)}
</div>
{/* Quick Actions */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<a
href="/dashboard/mlm"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Dashboard</span>
</a>
<a
href="/dashboard/commissions/my-earnings"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">My Earnings</span>
</a>
<a
href="/dashboard/mlm/ranks"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">View Ranks</span>
</a>
<a
href="/dashboard/mlm/structures"
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-gray-900">Structures</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,472 @@
import { useParams, useNavigate } from 'react-router-dom';
import {
useNode,
useNodeUpline,
useNodeDownline,
useUpdateNodeStatus,
useMLMCommissions,
} from '@/hooks/useMlm';
import type { NodeStatus } from '@/services/mlm';
export default function NodeDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: node, isLoading, error } = useNode(id || '');
const { data: upline } = useNodeUpline(id || '');
const { data: downline } = useNodeDownline(id || '', 1);
const { data: commissions } = useMLMCommissions({ nodeId: id, limit: 10 });
const updateStatus = useUpdateNodeStatus();
const handleStatusChange = async (newStatus: NodeStatus) => {
if (window.confirm(`Are you sure you want to change the status to "${newStatus}"?`)) {
await updateStatus.mutateAsync({ id: id!, status: newStatus });
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error || !node) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading node information</p>
<button
onClick={() => navigate(-1)}
className="text-blue-600 hover:text-blue-900"
>
Go Back
</button>
</div>
);
}
const getStatusColor = (status: NodeStatus) => {
const colors: Record<NodeStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
suspended: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const memberName = node.user?.firstName && node.user?.lastName
? `${node.user.firstName} ${node.user.lastName}`
: node.user?.email || 'Unknown Member';
return (
<div className="space-y-6">
{/* Breadcrumbs */}
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
<li>
<a href="/dashboard/mlm" className="text-gray-400 hover:text-gray-500">
MLM
</a>
</li>
<li>
<svg className="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</li>
<li>
<a href="/dashboard/mlm/my-network" className="text-gray-400 hover:text-gray-500">
Network
</a>
</li>
<li>
<svg className="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</li>
<li className="text-gray-500">Member Details</li>
</ol>
</nav>
{/* Header */}
<div className="flex justify-between items-start">
<div className="flex items-center space-x-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-2xl font-bold"
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
>
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">{memberName}</h1>
<p className="text-sm text-gray-500">{node.user?.email}</p>
<div className="flex items-center space-x-2 mt-1">
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${getStatusColor(node.status)}`}>
{node.status}
</span>
<span className="text-sm text-gray-500">
{node.rank?.name || 'No Rank'} | Level {node.depth}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<select
value={node.status}
onChange={(e) => handleStatusChange(e.target.value as NodeStatus)}
disabled={updateStatus.isPending}
className="block rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
</select>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Personal Volume</dt>
<dd className="text-lg font-semibold text-gray-900">{node.personalVolume.toLocaleString()}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Group Volume</dt>
<dd className="text-lg font-semibold text-gray-900">{node.groupVolume.toLocaleString()}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Direct Referrals</dt>
<dd className="text-lg font-semibold text-gray-900">{node.directReferrals}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Earnings</dt>
<dd className="text-lg font-semibold text-gray-900">${node.totalEarnings.toLocaleString()}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Member Details Grid */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Member Information */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Member Information</h3>
<div className="space-y-3">
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Member ID</span>
<span className="text-sm font-medium text-gray-900 font-mono">{node.id.slice(0, 8)}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Position</span>
<span className="text-sm font-medium text-gray-900">{node.position ?? 'N/A'}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Depth Level</span>
<span className="text-sm font-medium text-gray-900">Level {node.depth}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Total Downline</span>
<span className="text-sm font-medium text-gray-900">{node.totalDownline}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Joined Date</span>
<span className="text-sm font-medium text-gray-900">
{new Date(node.joinedAt).toLocaleDateString()}
</span>
</div>
{node.inviteCode && (
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Invite Code</span>
<span className="text-sm font-medium text-gray-900 font-mono">{node.inviteCode}</span>
</div>
)}
</div>
</div>
{/* Rank Information */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Rank Information</h3>
{node.rank ? (
<div className="flex items-center space-x-4 mb-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-2xl font-bold"
style={{ backgroundColor: node.rank.color || '#6B7280' }}
>
{node.rank.level}
</div>
<div>
<p className="text-xl font-semibold text-gray-900">{node.rank.name}</p>
<p className="text-sm text-gray-500">Current Rank - Level {node.rank.level}</p>
</div>
</div>
) : (
<div className="flex items-center space-x-4 mb-4">
<div className="w-16 h-16 rounded-full flex items-center justify-center text-white text-2xl font-bold bg-gray-400">
-
</div>
<div>
<p className="text-xl font-semibold text-gray-900">No Rank</p>
<p className="text-sm text-gray-500">This member has not achieved a rank yet</p>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Current Rank</span>
<span className="text-sm font-medium text-gray-900">{node.rank?.name || 'None'}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Highest Rank Achieved</span>
<span className="text-sm font-medium text-gray-900">
{node.highestRankId ? 'Level ' + node.rank?.level : 'None'}
</span>
</div>
</div>
</div>
</div>
{/* Upline Section */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Upline (Sponsors)</h2>
<p className="text-sm text-gray-500">The chain of sponsors above this member</p>
</div>
{upline && upline.length > 0 ? (
<div className="divide-y divide-gray-100">
{upline.map((sponsor, index) => (
<div key={sponsor.id} className="flex items-center justify-between px-6 py-4 hover:bg-gray-50">
<div className="flex items-center">
<span className="text-sm text-gray-400 w-8">L{index + 1}</span>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium mr-3"
style={{ backgroundColor: sponsor.rank?.color || '#6B7280' }}
>
{sponsor.user?.firstName?.charAt(0) || sponsor.user?.email?.charAt(0) || '?'}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{sponsor.user?.firstName && sponsor.user?.lastName
? `${sponsor.user.firstName} ${sponsor.user.lastName}`
: sponsor.user?.email || 'Unknown'}
</p>
<p className="text-xs text-gray-500">{sponsor.rank?.name || 'No Rank'}</p>
</div>
</div>
<a
href={`/dashboard/mlm/nodes/${sponsor.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View
</a>
</div>
))}
</div>
) : (
<div className="text-center py-10">
<p className="text-sm text-gray-500">This member is at the top of the structure</p>
</div>
)}
</div>
{/* Downline Section */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 className="text-lg font-medium text-gray-900">Direct Downline</h2>
<p className="text-sm text-gray-500">Members directly sponsored by this member</p>
</div>
{downline && downline.length > 0 && (
<span className="text-sm text-gray-500">{downline.length} direct referrals</span>
)}
</div>
{downline && downline.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Member</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rank</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Volume</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{downline.map((member) => (
<tr key={member.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium mr-3"
style={{ backgroundColor: member.rank?.color || '#6B7280' }}
>
{member.user?.firstName?.charAt(0) || member.user?.email?.charAt(0) || '?'}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{member.user?.firstName && member.user?.lastName
? `${member.user.firstName} ${member.user.lastName}`
: member.user?.email || 'Unknown'}
</p>
<p className="text-xs text-gray-500">{member.user?.email}</p>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900">{member.rank?.name || 'No Rank'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-xs text-gray-500">
<div>PV: {member.personalVolume.toLocaleString()}</div>
<div>GV: {member.groupVolume.toLocaleString()}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${getStatusColor(member.status)}`}>
{member.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<a
href={`/dashboard/mlm/nodes/${member.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View
</a>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-center py-10">
<p className="text-sm text-gray-500">No direct downline members</p>
</div>
)}
</div>
{/* Recent Commissions */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<div>
<h2 className="text-lg font-medium text-gray-900">Recent Commissions</h2>
<p className="text-sm text-gray-500">Latest commission earnings for this member</p>
</div>
<a
href={`/dashboard/commissions?nodeId=${id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View All
</a>
</div>
{commissions && commissions.items && commissions.items.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Source</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{commissions.items.map((commission) => (
<tr key={commission.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900 capitalize">{commission.type}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-500">Level {commission.level}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-500">
{commission.sourceNode?.user?.firstName || commission.sourceNode?.user?.email || 'Unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-green-600">
${commission.commissionAmount.toLocaleString()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-0.5 text-xs font-semibold rounded-full ${
commission.status === 'paid'
? 'bg-green-100 text-green-800'
: commission.status === 'approved'
? 'bg-blue-100 text-blue-800'
: commission.status === 'cancelled'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{commission.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-center py-10">
<p className="text-sm text-gray-500">No commissions recorded yet</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,208 @@
import { useState } from 'react';
import { useRanks, useDeleteRank, useStructures, useEvaluateRanks } from '@/hooks/useMlm';
import type { RankFilters, Rank } from '@/services/mlm';
export default function RanksPage() {
const [filters, setFilters] = useState<RankFilters>({});
const { data: ranks, isLoading, error } = useRanks(filters);
const { data: structures } = useStructures({ isActive: true });
const deleteRank = useDeleteRank();
const evaluateRanks = useEvaluateRanks();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this rank?')) {
await deleteRank.mutateAsync(id);
}
};
const handleEvaluate = async (structureId: string) => {
if (window.confirm('This will evaluate and update all member ranks in this structure. Continue?')) {
await evaluateRanks.mutateAsync(structureId);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading ranks</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">MLM Ranks</h1>
<a
href="/dashboard/mlm/ranks/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Add Rank
</a>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Structure</label>
<select
value={filters.structureId || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, structureId: e.target.value || undefined }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All Structures</option>
{structures?.map((structure) => (
<option key={structure.id} value={structure.id}>
{structure.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.isActive === undefined ? '' : filters.isActive ? 'true' : 'false'}
onChange={(e) => setFilters((prev) => ({
...prev,
isActive: e.target.value === '' ? undefined : e.target.value === 'true',
}))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
<div className="flex items-end">
{filters.structureId && (
<button
onClick={() => handleEvaluate(filters.structureId!)}
disabled={evaluateRanks.isPending}
className="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
{evaluateRanks.isPending ? 'Evaluating...' : 'Evaluate All Ranks'}
</button>
)}
</div>
</div>
</div>
{/* Ranks Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rank</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requirements</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bonus Rate</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{ranks?.map((rank: Rank) => (
<tr key={rank.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white"
style={{ backgroundColor: rank.color || '#6B7280' }}
>
{rank.level}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{rank.badgeUrl && (
<img src={rank.badgeUrl} alt={rank.name} className="w-8 h-8 mr-3" />
)}
<div>
<div className="text-sm font-medium text-gray-900">{rank.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-500 space-y-1">
{rank.requirements?.personalVolume && (
<div>PV: {rank.requirements.personalVolume.toLocaleString()}</div>
)}
{rank.requirements?.groupVolume && (
<div>GV: {rank.requirements.groupVolume.toLocaleString()}</div>
)}
{rank.requirements?.directReferrals && (
<div>Referrals: {rank.requirements.directReferrals}</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{rank.bonusRate ? (
<span className="text-sm font-medium text-green-600">{rank.bonusRate}%</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
rank.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{rank.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a
href={`/dashboard/mlm/ranks/${rank.id}`}
className="text-blue-600 hover:text-blue-900 mr-4"
>
Edit
</a>
<button
onClick={() => handleDelete(rank.id)}
disabled={deleteRank.isPending}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{(!ranks || ranks.length === 0) && (
<div className="text-center py-10">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
<p className="mt-2 text-gray-500">No ranks found</p>
<a href="/dashboard/mlm/ranks/new" className="text-blue-600 hover:text-blue-900">
Create your first rank
</a>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,443 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useStructure, useNodes, useRanks, useDeleteStructure } from '@/hooks/useMlm';
import type { NodeStatus } from '@/services/mlm';
export default function StructureDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: structure, isLoading, error } = useStructure(id || '');
const { data: nodes } = useNodes({ structureId: id, limit: 100 });
const { data: ranks } = useRanks({ structureId: id });
const deleteStructure = useDeleteStructure();
const handleDelete = async () => {
if (window.confirm('Are you sure you want to delete this structure? This action cannot be undone.')) {
await deleteStructure.mutateAsync(id!);
navigate('/dashboard/mlm/structures');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error || !structure) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading structure</p>
<a href="/dashboard/mlm/structures" className="text-blue-600 hover:text-blue-900">
Back to Structures
</a>
</div>
);
}
const getTypeLabel = (type: string) => {
const labels: Record<string, string> = {
unilevel: 'Unilevel',
binary: 'Binary',
matrix: 'Matrix',
hybrid: 'Hybrid',
};
return labels[type] || type;
};
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
unilevel: 'bg-blue-100 text-blue-800',
binary: 'bg-purple-100 text-purple-800',
matrix: 'bg-orange-100 text-orange-800',
hybrid: 'bg-teal-100 text-teal-800',
};
return colors[type] || 'bg-gray-100 text-gray-800';
};
const getStatusColor = (status: NodeStatus) => {
const colors: Record<NodeStatus, string> = {
pending: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
suspended: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const nodeStats = {
total: nodes?.total || 0,
active: nodes?.items?.filter((n) => n.status === 'active').length || 0,
pending: nodes?.items?.filter((n) => n.status === 'pending').length || 0,
};
return (
<div className="space-y-6">
{/* Breadcrumbs */}
<nav className="flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
<li>
<a href="/dashboard/mlm" className="text-gray-400 hover:text-gray-500">
MLM
</a>
</li>
<li>
<svg className="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</li>
<li>
<a href="/dashboard/mlm/structures" className="text-gray-400 hover:text-gray-500">
Structures
</a>
</li>
<li>
<svg className="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</li>
<li className="text-gray-500">{structure.name}</li>
</ol>
</nav>
{/* Header */}
<div className="flex justify-between items-start">
<div>
<div className="flex items-center space-x-3">
<h1 className="text-2xl font-semibold text-gray-900">{structure.name}</h1>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getTypeColor(structure.type)}`}>
{getTypeLabel(structure.type)}
</span>
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
structure.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{structure.isActive ? 'Active' : 'Inactive'}
</span>
</div>
{structure.description && (
<p className="mt-2 text-sm text-gray-600">{structure.description}</p>
)}
</div>
<div className="flex space-x-3">
<a
href={`/dashboard/mlm/structures/${id}/edit`}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Edit
</a>
<button
onClick={handleDelete}
disabled={deleteStructure.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50"
>
{deleteStructure.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Nodes</dt>
<dd className="text-lg font-semibold text-gray-900">{nodeStats.total}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active Nodes</dt>
<dd className="text-lg font-semibold text-gray-900">{nodeStats.active}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending Nodes</dt>
<dd className="text-lg font-semibold text-gray-900">{nodeStats.pending}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Available Ranks</dt>
<dd className="text-lg font-semibold text-gray-900">{ranks?.length || 0}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Configuration Section */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Structure Configuration */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Configuration</h3>
<div className="space-y-3">
{structure.config?.maxDepth && (
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Maximum Depth</span>
<span className="text-sm font-medium text-gray-900">{structure.config.maxDepth} levels</span>
</div>
)}
{structure.config?.maxWidth && (
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Maximum Width</span>
<span className="text-sm font-medium text-gray-900">{structure.config.maxWidth}</span>
</div>
)}
{structure.config?.spillover && (
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Spillover Strategy</span>
<span className="text-sm font-medium text-gray-900 capitalize">
{structure.config.spillover.replace(/_/g, ' ')}
</span>
</div>
)}
{structure.type === 'matrix' && (
<>
{structure.config?.width && (
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Matrix Width</span>
<span className="text-sm font-medium text-gray-900">{structure.config.width}</span>
</div>
)}
{structure.config?.depth && (
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Matrix Depth</span>
<span className="text-sm font-medium text-gray-900">{structure.config.depth}</span>
</div>
)}
</>
)}
<div className="flex justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Created</span>
<span className="text-sm font-medium text-gray-900">
{new Date(structure.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Level Commission Rates */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Commission Rates</h3>
{structure.levelRates && structure.levelRates.length > 0 ? (
<div className="space-y-2">
{structure.levelRates.map((rate) => (
<div key={rate.level} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500">Level {rate.level}</span>
<span className="text-sm font-medium text-green-600">{rate.rate}%</span>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No commission rates configured</p>
)}
{structure.matchingRates && structure.matchingRates.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-900 mb-2">Matching Bonus Rates</h4>
<div className="space-y-2">
{structure.matchingRates.map((rate) => (
<div key={rate.level} className="flex justify-between items-center p-3 bg-blue-50 rounded-lg">
<span className="text-sm text-gray-500">Generation {rate.level}</span>
<span className="text-sm font-medium text-blue-600">{rate.rate}%</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Ranks Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-lg font-medium text-gray-900">Available Ranks</h2>
<a
href={`/dashboard/mlm/ranks/new?structureId=${id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
+ Add Rank
</a>
</div>
{ranks && ranks.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rank Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requirements</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bonus Rate</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{ranks.map((rank) => (
<tr key={rank.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white"
style={{ backgroundColor: rank.color || '#6B7280' }}
>
{rank.level}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900">{rank.name}</span>
</td>
<td className="px-6 py-4">
<div className="text-xs text-gray-500 space-y-1">
{rank.requirements?.personalVolume && <div>PV: {rank.requirements.personalVolume.toLocaleString()}</div>}
{rank.requirements?.groupVolume && <div>GV: {rank.requirements.groupVolume.toLocaleString()}</div>}
{rank.requirements?.directReferrals && <div>Referrals: {rank.requirements.directReferrals}</div>}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{rank.bonusRate ? (
<span className="text-sm font-medium text-green-600">{rank.bonusRate}%</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-center py-10">
<p className="text-sm text-gray-500">No ranks configured for this structure</p>
<a href={`/dashboard/mlm/ranks/new?structureId=${id}`} className="text-blue-600 hover:text-blue-900">
Create the first rank
</a>
</div>
)}
</div>
{/* Recent Nodes Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h2 className="text-lg font-medium text-gray-900">Recent Members</h2>
<a
href={`/dashboard/mlm/nodes?structureId=${id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View All
</a>
</div>
{nodes && nodes.items && nodes.items.length > 0 ? (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Member</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rank</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Volume</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{nodes.items.slice(0, 10).map((node) => (
<tr key={node.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium mr-3"
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
>
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{node.user?.firstName && node.user?.lastName
? `${node.user.firstName} ${node.user.lastName}`
: node.user?.email || 'Unknown'}
</div>
<div className="text-xs text-gray-500">{node.user?.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900">{node.rank?.name || 'No Rank'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-500">Level {node.depth}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-xs text-gray-500">
<div>PV: {node.personalVolume.toLocaleString()}</div>
<div>GV: {node.groupVolume.toLocaleString()}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${getStatusColor(node.status)}`}>
{node.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<a
href={`/dashboard/mlm/nodes/${node.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View
</a>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-center py-10">
<p className="text-sm text-gray-500">No members in this structure yet</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,201 @@
import { useState } from 'react';
import { useStructures, useDeleteStructure } from '@/hooks/useMlm';
import type { StructureFilters, Structure, StructureType } from '@/services/mlm';
export default function StructuresPage() {
const [filters, setFilters] = useState<StructureFilters>({});
const { data: structures, isLoading, error } = useStructures(filters);
const deleteStructure = useDeleteStructure();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this structure?')) {
await deleteStructure.mutateAsync(id);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="text-center py-10">
<p className="text-red-600">Error loading structures</p>
</div>
);
}
const getTypeLabel = (type: StructureType) => {
const labels: Record<StructureType, string> = {
unilevel: 'Unilevel',
binary: 'Binary',
matrix: 'Matrix',
hybrid: 'Hybrid',
};
return labels[type] || type;
};
const getTypeColor = (type: StructureType) => {
const colors: Record<StructureType, string> = {
unilevel: 'bg-blue-100 text-blue-800',
binary: 'bg-purple-100 text-purple-800',
matrix: 'bg-orange-100 text-orange-800',
hybrid: 'bg-teal-100 text-teal-800',
};
return colors[type] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">MLM Structures</h1>
<a
href="/dashboard/mlm/structures/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
+ Add Structure
</a>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={filters.type || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, type: (e.target.value || undefined) as StructureType }))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All Types</option>
<option value="unilevel">Unilevel</option>
<option value="binary">Binary</option>
<option value="matrix">Matrix</option>
<option value="hybrid">Hybrid</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={filters.isActive === undefined ? '' : filters.isActive ? 'true' : 'false'}
onChange={(e) => setFilters((prev) => ({
...prev,
isActive: e.target.value === '' ? undefined : e.target.value === 'true',
}))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">All</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Search</label>
<input
type="text"
value={filters.search || ''}
onChange={(e) => setFilters((prev) => ({ ...prev, search: e.target.value || undefined }))}
placeholder="Search by name..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
{/* Structures Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{structures?.map((structure: Structure) => (
<div
key={structure.id}
className="bg-white shadow rounded-lg p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-medium text-gray-900">{structure.name}</h3>
<span className={`inline-flex px-2 py-0.5 text-xs font-semibold rounded-full ${getTypeColor(structure.type)}`}>
{getTypeLabel(structure.type)}
</span>
</div>
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
structure.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{structure.isActive ? 'Active' : 'Inactive'}
</span>
</div>
{structure.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{structure.description}</p>
)}
<div className="space-y-2 mb-4">
{structure.config?.maxDepth && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Max Depth</span>
<span className="font-medium">{structure.config.maxDepth} levels</span>
</div>
)}
{structure.config?.maxWidth && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Max Width</span>
<span className="font-medium">{structure.config.maxWidth}</span>
</div>
)}
{structure.levelRates && structure.levelRates.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Commission Levels</span>
<span className="font-medium">{structure.levelRates.length}</span>
</div>
)}
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<a
href={`/dashboard/mlm/structures/${structure.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View Details
</a>
<button
onClick={() => handleDelete(structure.id)}
disabled={deleteStructure.isPending}
className="text-sm text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</div>
))}
</div>
{structures?.length === 0 && (
<div className="text-center py-10 bg-white rounded-lg shadow">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p className="mt-2 text-gray-500">No structures found</p>
<a href="/dashboard/mlm/structures/new" className="text-blue-600 hover:text-blue-900">
Create your first structure
</a>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,7 @@
export { default as MLMPage } from './MLMPage';
export { default as StructuresPage } from './StructuresPage';
export { default as StructureDetailPage } from './StructureDetailPage';
export { default as RanksPage } from './RanksPage';
export { default as MyNetworkPage } from './MyNetworkPage';
export { default as NodeDetailPage } from './NodeDetailPage';
export { default as MyEarningsPage } from './MyEarningsPage';

View File

@ -46,6 +46,23 @@ const EntriesPage = lazy(() => import('@/pages/dashboard/commissions').then(m =>
const PeriodsPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.PeriodsPage }))); const PeriodsPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.PeriodsPage })));
const MyEarningsPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.MyEarningsPage }))); const MyEarningsPage = lazy(() => import('@/pages/dashboard/commissions').then(m => ({ default: m.MyEarningsPage })));
// Lazy loaded pages - Goals
const GoalsPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.GoalsPage })));
const GoalDefinitionsPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.DefinitionsPage })));
const MyGoalsPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.MyGoalsPage })));
const GoalDetailPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.GoalDetailPage })));
const AssignmentDetailPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.AssignmentDetailPage })));
const GoalReportsPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.ReportsPage })));
// Lazy loaded pages - MLM
const MLMPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.MLMPage })));
const StructuresPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.StructuresPage })));
const StructureDetailPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.StructureDetailPage })));
const RanksPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.RanksPage })));
const MyNetworkPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.MyNetworkPage })));
const NodeDetailPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.NodeDetailPage })));
const MLMMyEarningsPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.MyEarningsPage })));
// Lazy loaded pages - Admin // Lazy loaded pages - Admin
const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings }))); const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings })));
const AnalyticsDashboardPage = lazy(() => import('@/pages/admin/AnalyticsDashboardPage').then(m => ({ default: m.AnalyticsDashboardPage }))); const AnalyticsDashboardPage = lazy(() => import('@/pages/admin/AnalyticsDashboardPage').then(m => ({ default: m.AnalyticsDashboardPage })));
@ -163,6 +180,23 @@ export function AppRouter() {
<Route path="commissions/entries" element={<SuspensePage><EntriesPage /></SuspensePage>} /> <Route path="commissions/entries" element={<SuspensePage><EntriesPage /></SuspensePage>} />
<Route path="commissions/periods" element={<SuspensePage><PeriodsPage /></SuspensePage>} /> <Route path="commissions/periods" element={<SuspensePage><PeriodsPage /></SuspensePage>} />
<Route path="commissions/my-earnings" element={<SuspensePage><MyEarningsPage /></SuspensePage>} /> <Route path="commissions/my-earnings" element={<SuspensePage><MyEarningsPage /></SuspensePage>} />
{/* Goals routes */}
<Route path="goals" element={<SuspensePage><GoalsPage /></SuspensePage>} />
<Route path="goals/definitions" element={<SuspensePage><GoalDefinitionsPage /></SuspensePage>} />
<Route path="goals/definitions/:id" element={<SuspensePage><GoalDetailPage /></SuspensePage>} />
<Route path="goals/my-goals" element={<SuspensePage><MyGoalsPage /></SuspensePage>} />
<Route path="goals/assignments/:id" element={<SuspensePage><AssignmentDetailPage /></SuspensePage>} />
<Route path="goals/reports" element={<SuspensePage><GoalReportsPage /></SuspensePage>} />
{/* MLM routes */}
<Route path="mlm" element={<SuspensePage><MLMPage /></SuspensePage>} />
<Route path="mlm/structures" element={<SuspensePage><StructuresPage /></SuspensePage>} />
<Route path="mlm/structures/:id" element={<SuspensePage><StructureDetailPage /></SuspensePage>} />
<Route path="mlm/ranks" element={<SuspensePage><RanksPage /></SuspensePage>} />
<Route path="mlm/my-network" element={<SuspensePage><MyNetworkPage /></SuspensePage>} />
<Route path="mlm/nodes/:id" element={<SuspensePage><NodeDetailPage /></SuspensePage>} />
<Route path="mlm/my-earnings" element={<SuspensePage><MLMMyEarningsPage /></SuspensePage>} />
</Route> </Route>
{/* Superadmin routes */} {/* Superadmin routes */}