diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx index 96e8eaa..584b454 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/DashboardLayout.tsx @@ -19,6 +19,8 @@ import { ClipboardList, Flag, MessageSquare, + Target, + Network, } from 'lucide-react'; import { useState } from 'react'; import clsx from 'clsx'; @@ -27,6 +29,8 @@ import { NotificationBell } from '@/components/notifications'; const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, { 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: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook }, { name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag }, diff --git a/src/pages/dashboard/goals/AssignmentDetailPage.tsx b/src/pages/dashboard/goals/AssignmentDetailPage.tsx new file mode 100644 index 0000000..e2964e2 --- /dev/null +++ b/src/pages/dashboard/goals/AssignmentDetailPage.tsx @@ -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(0); + const [progressNotes, setProgressNotes] = useState(''); + + 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 ( +
+
+
+ ); + } + + if (error || !assignment) { + return ( +
+

Error loading assignment

+ + Back to assignments + +
+ ); + } + + const getStatusColor = (status: AssignmentStatus) => { + const colors: Record = { + 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 = { + 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 ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+
+

{goalName}

+
+ + {assignment.status} + + {daysRemaining !== null && daysRemaining > 0 && ( + + {daysRemaining} days remaining + + )} + {daysRemaining !== null && daysRemaining <= 0 && ( + Ended + )} +
+
+ Assigned to: {assignment.user + ? `${assignment.user.firstName || ''} ${assignment.user.lastName || ''}`.trim() || assignment.user.email + : 'Unknown User'} +
+
+
+ {assignment.status === 'active' && ( + + )} + +
+
+
+ + {/* Progress Update Form */} + {showProgressForm && ( +
+

Update Progress

+
+
+ + 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" + /> +

+ Current: {assignment.currentValue.toLocaleString()} | Target: {target.toLocaleString()} +

+
+
+ + 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" + /> +
+
+
+ + +
+
+ )} + + {/* Main Progress Card */} +
+

Progress

+ + {/* Large Progress Display */} +
+
+ {assignment.progressPercentage.toFixed(1)}% +
+
+ {assignment.currentValue.toLocaleString()} / {target.toLocaleString()} {unit} +
+
+ + {/* Progress Bar */} +
+
+
+
+
+ + {/* Milestones */} +
+ {milestones.map((milestone) => ( +
+
+ {achievedMilestones.includes(milestone) ? ( + + + + ) : ( + {milestone}% + )} +
+
{milestone}%
+
+ ))} +
+
+ + {/* Details Grid */} +
+ {/* Assignment Details */} +
+

Assignment Details

+
+
+
Assignee Type
+
{assignment.assigneeType}
+
+
+
Custom Target
+
+ {assignment.customTarget ? `${assignment.customTarget.toLocaleString()} ${unit}` : 'Using default'} +
+
+
+
Status
+
+ +
+
+ {assignment.achievedAt && ( +
+
Achieved At
+
{formatDateTime(assignment.achievedAt)}
+
+ )} + {assignment.notes && ( +
+
Notes
+
{assignment.notes}
+
+ )} +
+
+ + {/* Goal Details */} +
+

Goal Details

+
+
+
Goal Name
+
+ + {goalName} + +
+
+
+
Target Value
+
{assignment.definition?.targetValue?.toLocaleString()} {unit}
+
+
+
Start Date
+
{formatDate(assignment.definition?.startsAt || null)}
+
+
+
End Date
+
{formatDate(assignment.definition?.endsAt || null)}
+
+
+
+
+ + {/* Progress History */} +
+
+

Progress History

+
+ + {history && history.length > 0 ? ( +
+ {history.map((log: ProgressLog) => ( +
+
+
+
+ + {log.newValue.toLocaleString()} {unit} + + {log.changeAmount !== null && log.changeAmount !== 0 && ( + 0 ? 'text-green-600' : 'text-red-600'}`}> + ({log.changeAmount > 0 ? '+' : ''}{log.changeAmount.toLocaleString()}) + + )} + + {log.source} + +
+ {log.previousValue !== null && ( +
+ Previous: {log.previousValue.toLocaleString()} {unit} +
+ )} + {log.notes && ( +
{log.notes}
+ )} + {log.sourceReference && ( +
Ref: {log.sourceReference}
+ )} +
+
+
{formatDateTime(log.loggedAt)}
+ {log.loggedBy && ( +
by {log.loggedBy}
+ )} +
+
+
+ ))} +
+ ) : ( +
+ + + +

No history yet

+

Progress updates will appear here.

+
+ )} +
+ + {/* Metadata */} +
+

Metadata

+
+
+
Created
+
{formatDateTime(assignment.createdAt)}
+
+
+
Last Updated
+
{formatDateTime(assignment.updatedAt)}
+
+
+
Last Progress Update
+
{formatDateTime(assignment.lastUpdatedAt)}
+
+
+
Assignment ID
+
{assignment.id}
+
+
+
+
+ ); +} diff --git a/src/pages/dashboard/goals/DefinitionsPage.tsx b/src/pages/dashboard/goals/DefinitionsPage.tsx new file mode 100644 index 0000000..797ba98 --- /dev/null +++ b/src/pages/dashboard/goals/DefinitionsPage.tsx @@ -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({ 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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+

Error loading goal definitions

+
+ ); + } + + const getStatusColor = (status: GoalStatus) => { + const colors: Record = { + 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 = { + 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 ( +
+
+

Goal Definitions

+ + + New Definition + +
+ + {/* Filters */} +
+
+
+ + +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + {/* Definitions Table */} +
+ + + + + + + + + + + + + + {data?.items?.map((definition: GoalDefinition) => ( + + + + + + + + + + ))} + +
NameTargetPeriodDurationAssignedStatusActions
+
{definition.name}
+ {definition.category && ( +
{definition.category}
+ )} +
+
+ {definition.targetValue.toLocaleString()} {definition.unit || ''} +
+
{definition.type} - {definition.metric}
+
+ {getPeriodLabel(definition.period)} + +
{formatDate(definition.startsAt)}
+
to {formatDate(definition.endsAt)}
+
+ {definition.assignmentCount ?? 0} users + + + +
+ + View + + + Edit + + + +
+
+ + {(!data?.items || data.items.length === 0) && ( +
+ + + +

No goal definitions

+

Get started by creating a new goal definition.

+ +
+ )} +
+ + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+
+ Showing page {data.page} of {data.totalPages} ({data.total} total) +
+
+ + +
+
+ )} +
+ ); +} diff --git a/src/pages/dashboard/goals/GoalDetailPage.tsx b/src/pages/dashboard/goals/GoalDetailPage.tsx new file mode 100644 index 0000000..ae0878c --- /dev/null +++ b/src/pages/dashboard/goals/GoalDetailPage.tsx @@ -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 ( +
+
+
+ ); + } + + if (error || !goal) { + return ( +
+

Error loading goal definition

+ + Back to definitions + +
+ ); + } + + const getStatusColor = (status: GoalStatus) => { + const colors: Record = { + 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 = { + 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 = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + quarterly: 'Quarterly', + yearly: 'Yearly', + custom: 'Custom', + }; + return labels[period] || period; + }; + + const getTypeLabel = (type: string) => { + const labels: Record = { + 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 ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+
+
+

{goal.name}

+ + {goal.status} + +
+ {goal.description && ( +

{goal.description}

+ )} + {goal.category && ( + + {goal.category} + + )} +
+
+ + Edit + + + +
+
+
+ + {/* Goal Details Grid */} +
+ {/* Target & Metrics */} +
+

Target & Metrics

+
+
+
Target Value
+
+ {goal.targetValue.toLocaleString()} {goal.unit || ''} +
+
+
+
Type
+
{getTypeLabel(goal.type)}
+
+
+
Metric
+
{goal.metric}
+
+
+
Data Source
+
{goal.source}
+
+
+
+ + {/* Duration */} +
+

Duration

+
+
+
Period
+
{getPeriodLabel(goal.period)}
+
+
+
Status
+
+ +
+
+
+
Start Date
+
{formatDate(goal.startsAt)}
+
+
+
End Date
+
{formatDate(goal.endsAt)}
+
+
+
+
+ + {/* Milestones */} + {goal.milestones && goal.milestones.length > 0 && ( +
+

Milestones

+
+ {goal.milestones.map((milestone, index) => ( +
+
{milestone.percentage}%
+
+ {milestone.notify ? 'Notifications ON' : 'Notifications OFF'} +
+
+ ))} +
+
+ )} + + {/* Aggregate Progress */} +
+

Aggregate Progress

+
+
+
{totalAssignments}
+
Total Assigned
+
+
+
{activeCount}
+
Active
+
+
+
{achievedCount}
+
Achieved
+
+
+
{averageProgress.toFixed(1)}%
+
Avg Progress
+
+
+ + {/* Overall Progress Bar */} +
+
+ Overall Completion Rate + + {totalAssignments > 0 ? ((achievedCount / totalAssignments) * 100).toFixed(1) : 0}% + +
+
+
0 ? (achievedCount / totalAssignments) * 100 : 0 + )}`} + style={{ width: `${totalAssignments > 0 ? (achievedCount / totalAssignments) * 100 : 0}%` }} + /> +
+
+
+ + {/* Assignments List */} +
+
+
+

Assignments ({totalAssignments})

+ + + Assign Goal + +
+
+ + {assignments && assignments.length > 0 ? ( + + + + + + + + + + + + {assignments.map((assignment: Assignment) => { + const target = assignment.customTarget ?? goal.targetValue; + return ( + + + + + + + + ); + })} + +
AssigneeProgressStatusLast UpdatedActions
+
+ {assignment.user + ? `${assignment.user.firstName || ''} ${assignment.user.lastName || ''}`.trim() || assignment.user.email + : 'Unknown User'} +
+
{assignment.assigneeType}
+
+
+
+
+ {assignment.currentValue.toLocaleString()} + {target.toLocaleString()} {goal.unit || ''} +
+
+
+
+
+ + {assignment.progressPercentage.toFixed(0)}% + +
+
+ + {assignment.status} + + + {assignment.lastUpdatedAt ? formatDateTime(assignment.lastUpdatedAt) : 'Never'} + + + View + +
+ ) : ( +
+ + + +

No assignments yet

+

Get started by assigning this goal to users.

+ +
+ )} +
+ + {/* History / Metadata */} +
+

History

+
+
+
Created
+
{formatDateTime(goal.createdAt)}
+
+
+
Last Updated
+
{formatDateTime(goal.updatedAt)}
+
+
+
Created By
+
{goal.createdBy || 'System'}
+
+
+
Tags
+
+ {goal.tags && goal.tags.length > 0 ? ( + goal.tags.map((tag, i) => ( + + {tag} + + )) + ) : ( + No tags + )} +
+
+
+
+
+ ); +} diff --git a/src/pages/dashboard/goals/GoalsPage.tsx b/src/pages/dashboard/goals/GoalsPage.tsx new file mode 100644 index 0000000..294ab1b --- /dev/null +++ b/src/pages/dashboard/goals/GoalsPage.tsx @@ -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 ( +
+
+
+ ); + } + + const getStatusColor = (status: GoalStatus) => { + const colors: Record = { + 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 ( +
+
+

Goals Dashboard

+
+ + {/* Summary Cards */} +
+
+
+
+
+ + + +
+
+
+
My Active Goals
+
+ {mySummary?.activeAssignments ?? 0} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Achieved
+
+ {mySummary?.achievedAssignments ?? 0} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
At Risk
+
+ {mySummary?.atRiskCount ?? 0} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Avg Progress
+
+ {(mySummary?.averageProgress ?? 0).toFixed(1)}% +
+
+
+
+
+
+
+ + {/* Overall Completion Stats */} + {completionReport && ( +
+

Organization Overview

+
+
+ Total Goals + {completionReport.totalGoals} +
+
+ Achieved + {completionReport.achievedGoals} +
+
+ Completion Rate + {completionReport.completionRate.toFixed(1)}% +
+
+ Avg Progress + {completionReport.averageProgress.toFixed(1)}% +
+
+
+ )} + + {/* Two Column Layout */} +
+ {/* Active Goal Definitions */} +
+

Active Goals

+
+ {definitions?.items?.map((goal: GoalDefinition) => ( +
+
+

{goal.name}

+

+ Target: {goal.targetValue.toLocaleString()} {goal.unit || ''} +

+
+
+ + {goal.status} + + + {goal.assignmentCount ?? 0} assigned + +
+
+ ))} + {(!definitions?.items || definitions.items.length === 0) && ( +

No active goals defined

+ )} +
+
+ + {/* Top Performers */} +
+

Top Performers

+
+ {userReport?.slice(0, 5).map((user, index) => ( +
+
+ + {index + 1} + + {user.userName || 'Unknown User'} +
+
+ {user.achieved} achieved + {user.averageProgress.toFixed(0)}% avg +
+
+ ))} + {(!userReport || userReport.length === 0) && ( +

No performance data yet

+ )} +
+
+
+ + {/* Quick Actions */} + +
+ ); +} diff --git a/src/pages/dashboard/goals/MyGoalsPage.tsx b/src/pages/dashboard/goals/MyGoalsPage.tsx new file mode 100644 index 0000000..187fec1 --- /dev/null +++ b/src/pages/dashboard/goals/MyGoalsPage.tsx @@ -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(null); + const [progressValue, setProgressValue] = useState(0); + const [progressNotes, setProgressNotes] = useState(''); + + 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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+

Error loading your goals

+
+ ); + } + + const getStatusColor = (status: AssignmentStatus) => { + const colors: Record = { + 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 ( +
+
+

My Goals

+
+ + {/* Summary Cards */} +
+
+
+
+
+ + + +
+
+
+
Total
+
{summary?.totalAssignments ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Active
+
{summary?.activeAssignments ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Achieved
+
{summary?.achievedAssignments ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
At Risk
+
{summary?.atRiskCount ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Avg Progress
+
{(summary?.averageProgress ?? 0).toFixed(1)}%
+
+
+
+
+
+
+ + {/* Goals List */} +
+ {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 ( +
+
+
+
+

{goal.definition?.name || 'Unknown Goal'}

+

+ Target: {target.toLocaleString()} {goal.definition?.unit || ''} +

+
+
+ + {goal.status} + + {daysRemaining !== null && daysRemaining > 0 && ( + + {daysRemaining} days left + + )} + {daysRemaining !== null && daysRemaining <= 0 && ( + Ended + )} +
+
+ + {/* Progress Bar */} +
+
+ + Current: {goal.currentValue.toLocaleString()} {goal.definition?.unit || ''} + + {goal.progressPercentage.toFixed(1)}% +
+
+
+
+
+ + {/* Duration Info */} +
+ Start: {formatDate(goal.definition?.startsAt || null)} + End: {formatDate(goal.definition?.endsAt || null)} +
+ + {/* Update Progress Form */} + {isEditing ? ( +
+

Update Progress

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ + +
+
+ ) : ( +
+ {goal.lastUpdatedAt && ( + + Last updated: {formatDate(goal.lastUpdatedAt)} + + )} +
+ {goal.status === 'active' && ( + + )} + + View Details + +
+
+ )} +
+
+ ); + })} +
+ + {(!goals || goals.length === 0) && ( +
+ + + +

No goals assigned

+

You don't have any goals assigned yet.

+ +
+ )} + + {/* Quick Links */} + +
+ ); +} diff --git a/src/pages/dashboard/goals/ReportsPage.tsx b/src/pages/dashboard/goals/ReportsPage.tsx new file mode 100644 index 0000000..1e91a64 --- /dev/null +++ b/src/pages/dashboard/goals/ReportsPage.tsx @@ -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 ( +
+
+
+ ); + } + + 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 ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+

Goals Reports

+
+ + +
+
+ + {/* Date Filter */} +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+
+
+ + {/* Summary Cards */} +
+
+
+
+
+ + + +
+
+
+
Total Goals
+
{completionReport?.totalGoals ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Achieved
+
{completionReport?.achievedGoals ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Completion Rate
+
{(completionReport?.completionRate ?? 0).toFixed(1)}%
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Avg Progress
+
{(completionReport?.averageProgress ?? 0).toFixed(1)}%
+
+
+
+
+
+
+ + {/* Overall Progress Visualization */} +
+

Goal Status Distribution

+
+ {/* Status Breakdown */} +
+
+
+
+ Achieved + {completionReport?.achievedGoals ?? 0} +
+
+
+
+
+
+
+ Active + {completionReport?.activeGoals ?? 0} +
+
+
+
+
+
+
+ Failed + {completionReport?.failedGoals ?? 0} +
+
+
+
+
+
+
+ + {/* Summary Stats */} +
+
+
{totalUsers}
+
Total Users
+
+
+
{usersWithAchievements}
+
With Achievements
+
+ {topPerformer && ( +
+
Top Performer
+
+ {topPerformer.userName || 'Unknown User'} +
+
+ {topPerformer.averageProgress.toFixed(1)}% average progress +
+
+ )} +
+
+
+ + {/* User Performance Table */} +
+
+

User Performance

+
+ + {userReport && userReport.length > 0 ? ( + + + + + + + + + + + + + + {userReport + .sort((a, b) => b.averageProgress - a.averageProgress) + .map((user: UserReport, index) => ( + + + + + + + + + + ))} + +
RankUserProgressAchievedActiveFailedTotal
+
+ {index + 1} +
+
+
{user.userName || 'Unknown User'}
+
+
+
+
+
+
+
+ + {user.averageProgress.toFixed(0)}% + +
+
+ {user.achieved} + + {user.active} + + {user.failed} + + {user.totalAssignments} +
+ ) : ( +
+ + + +

No user data yet

+

Assign goals to users to see performance data.

+
+ )} +
+ + {/* Active Goals Summary */} +
+

Active Goals Overview

+
+ {definitions?.items?.map((goal: GoalDefinition) => ( +
+
+
{goal.name}
+
+ Target: {goal.targetValue.toLocaleString()} {goal.unit || ''} | Period: {goal.period} +
+
+
+
+
{goal.assignmentCount ?? 0}
+
assigned
+
+ + View + +
+
+ ))} + {(!definitions?.items || definitions.items.length === 0) && ( +

No active goals

+ )} +
+
+ + {/* Quick Links */} + +
+ ); +} diff --git a/src/pages/dashboard/goals/index.ts b/src/pages/dashboard/goals/index.ts new file mode 100644 index 0000000..9d86dc7 --- /dev/null +++ b/src/pages/dashboard/goals/index.ts @@ -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'; diff --git a/src/pages/dashboard/mlm/MLMPage.tsx b/src/pages/dashboard/mlm/MLMPage.tsx new file mode 100644 index 0000000..811c900 --- /dev/null +++ b/src/pages/dashboard/mlm/MLMPage.tsx @@ -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 ( +
+
+
+ ); + } + + return ( +
+
+

MLM Dashboard

+
+ + {/* Summary Cards */} +
+
+
+
+
+ + + +
+
+
+
Total Downline
+
{dashboard?.totalDownline ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Direct Referrals
+
{dashboard?.directReferrals ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Group Volume
+
+ {(dashboard?.groupVolume ?? 0).toLocaleString()} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Total Earnings
+
+ ${(dashboard?.totalEarnings ?? 0).toLocaleString()} +
+
+
+
+
+
+
+ + {/* Stats Grid */} +
+ {/* Current Rank */} +
+

Current Rank

+ {dashboard?.currentRank ? ( +
+
+ {dashboard.currentRank.level} +
+
+

{dashboard.currentRank.name}

+

Level {dashboard.currentRank.level}

+
+
+ ) : ( +

No rank assigned yet

+ )} +
+ + {/* Next Rank Progress */} +
+

Next Rank Progress

+ {dashboard?.nextRank ? ( +
+
+ {dashboard.nextRank.name} + Level {dashboard.nextRank.level} +
+ {Object.entries(dashboard.nextRank.progress || {}).map(([key, value]) => ( +
+
+ {key.replace(/([A-Z])/g, ' $1').trim()} + {Math.min(100, Math.round(Number(value)))}% +
+
+
+
+
+ ))} +
+ ) : ( +

You've reached the highest rank!

+ )} +
+
+ + {/* Volume Stats */} +
+

Volume Summary

+
+
+ Personal Volume + + {(dashboard?.personalVolume ?? 0).toLocaleString()} + +
+
+ Group Volume + + {(dashboard?.groupVolume ?? 0).toLocaleString()} + +
+
+ Active Downline + {dashboard?.activeDownline ?? 0} +
+
+
+ + {/* Active Structures */} +
+

Active Structures

+
+ {structures?.map((structure) => ( +
+
+
+

{structure.name}

+

{structure.type}

+
+ + Active + +
+ {structure.description && ( +

{structure.description}

+ )} +
+ ))} + {(!structures || structures.length === 0) && ( +

No active structures

+ )} +
+
+ + {/* Quick Links */} + +
+ ); +} diff --git a/src/pages/dashboard/mlm/MyEarningsPage.tsx b/src/pages/dashboard/mlm/MyEarningsPage.tsx new file mode 100644 index 0000000..5eeafcf --- /dev/null +++ b/src/pages/dashboard/mlm/MyEarningsPage.tsx @@ -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('all'); + const [statusFilter, setStatusFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + + 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 ( +
+
+
+ ); + } + + const getStatusColor = (status: CommissionStatus) => { + const colors: Record = { + 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 = { + 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 ( +
+ {/* Breadcrumbs */} + + + {/* Header */} +
+

My Earnings

+
+ + {/* Summary Cards */} +
+
+
+
+
+ + + +
+
+
+
Total Earnings
+
+ ${(earningsData?.totalEarnings ?? 0).toLocaleString()} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Commissions
+
+ ${(earningsData?.totalCommissions ?? 0).toLocaleString()} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Bonuses
+
+ ${(earningsData?.totalBonuses ?? 0).toLocaleString()} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Pending
+
+ ${(earningsData?.pendingAmount ?? 0).toLocaleString()} +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Paid Out
+
+ ${(earningsData?.paidAmount ?? 0).toLocaleString()} +
+
+
+
+
+
+
+ + {/* Earnings by Level */} +
+
+

Earnings by Level

+ {commissionsByLevel && commissionsByLevel.length > 0 ? ( +
+ {commissionsByLevel.map((levelData) => ( +
+
+
+ {levelData.level} +
+
+

Level {levelData.level}

+

{levelData.count} transactions

+
+
+ + ${levelData.totalAmount.toLocaleString()} + +
+ ))} +
+ ) : ( +

No earnings by level yet

+ )} +
+ + {/* Earnings Summary Chart Placeholder */} +
+

Earnings Summary

+
+
+
+ Commissions + + ${(earningsData?.totalCommissions ?? 0).toLocaleString()} + +
+
+
+
+
+
+
+ Bonuses + + ${(earningsData?.totalBonuses ?? 0).toLocaleString()} + +
+
+
+
+
+
+
+ Paid + + ${(earningsData?.paidAmount ?? 0).toLocaleString()} + +
+
+ Pending + + ${(earningsData?.pendingAmount ?? 0).toLocaleString()} + +
+
+
+
+
+ + {/* Filters */} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Commissions Table */} +
+
+

Commission History

+
+ {commissions && commissions.items && commissions.items.length > 0 ? ( +
+ + + + + + + + + + + + + + + {commissions.items.map((commission) => ( + + + + + + + + + + + ))} + +
DateTypeLevelSourceSource AmountRateCommissionStatus
+ {new Date(commission.createdAt).toLocaleDateString()} + + + {getTypeLabel(commission.type)} + + + + L{commission.level} + + +
+
+

+ {commission.sourceNode?.user?.firstName || 'Unknown'} +

+

+ {commission.sourceNode?.user?.email} +

+
+
+
+ ${commission.sourceAmount.toLocaleString()} + + {commission.rateApplied}% + + + ${commission.commissionAmount.toLocaleString()} + + + + {commission.status} + +
+
+ ) : ( +
+ + + +

No commission records found

+

Start referring members to earn commissions

+
+ )} +
+ + {/* Quick Actions */} + +
+ ); +} diff --git a/src/pages/dashboard/mlm/MyNetworkPage.tsx b/src/pages/dashboard/mlm/MyNetworkPage.tsx new file mode 100644 index 0000000..5fc31dd --- /dev/null +++ b/src/pages/dashboard/mlm/MyNetworkPage.tsx @@ -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(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(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 ( +
+
+
+ ); + } + + const getStatusColor = (status: NodeStatus) => { + const colors: Record = { + 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 ( +
+
+ {depth > 0 && ( + |-- + )} +
+
+
+ {node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'} +
+
+

+ {node.user?.firstName && node.user?.lastName + ? `${node.user.firstName} ${node.user.lastName}` + : node.user?.email || 'Unknown'} +

+

+ {node.rank?.name || 'No Rank'} | Level {node.depth} +

+
+
+
+
+

PV: {node.personalVolume.toLocaleString()}

+

GV: {node.groupVolume.toLocaleString()}

+
+ + {node.status} + +
+
+
+ {node.children?.map((child) => renderTreeNode(child, depth + 1))} +
+ ); + }; + + return ( +
+
+

My Network

+ +
+ + {/* Invite Link */} + {inviteUrl && ( +
+
+
+

Your Invite Link

+

{inviteUrl}

+
+ +
+
+ )} + + {/* Summary Cards */} +
+
+
+
+
+ + + +
+
+
+
Total Downline
+
{dashboard?.totalDownline ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Direct Referrals
+
{dashboard?.directReferrals ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Active Members
+
{dashboard?.activeDownline ?? 0}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Group Volume
+
+ {(dashboard?.groupVolume ?? 0).toLocaleString()} +
+
+
+
+
+
+
+ + {/* Current Rank Info */} +
+
+

My Rank

+ {dashboard?.currentRank ? ( +
+
+ {dashboard.currentRank.level} +
+
+

{dashboard.currentRank.name}

+

Level {dashboard.currentRank.level}

+
+
+ ) : ( +

No rank assigned yet

+ )} +
+ +
+

Volume Summary

+
+
+ Personal Volume + + {(dashboard?.personalVolume ?? 0).toLocaleString()} + +
+
+ Group Volume + + {(dashboard?.groupVolume ?? 0).toLocaleString()} + +
+
+ Total Earnings + + ${(dashboard?.totalEarnings ?? 0).toLocaleString()} + +
+
+
+
+ + {/* Network Tree */} +
+
+

Network Tree

+
+ + +
+
+ + {network ? ( +
+ {renderTreeNode(network)} +
+ ) : ( +
+ + + +

You're not part of any MLM structure yet

+

Contact your administrator to get started

+
+ )} +
+ + {/* Quick Actions */} + +
+ ); +} diff --git a/src/pages/dashboard/mlm/NodeDetailPage.tsx b/src/pages/dashboard/mlm/NodeDetailPage.tsx new file mode 100644 index 0000000..595011c --- /dev/null +++ b/src/pages/dashboard/mlm/NodeDetailPage.tsx @@ -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 ( +
+
+
+ ); + } + + if (error || !node) { + return ( +
+

Error loading node information

+ +
+ ); + } + + const getStatusColor = (status: NodeStatus) => { + const colors: Record = { + 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 ( +
+ {/* Breadcrumbs */} + + + {/* Header */} +
+
+
+ {node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'} +
+
+

{memberName}

+

{node.user?.email}

+
+ + {node.status} + + + {node.rank?.name || 'No Rank'} | Level {node.depth} + +
+
+
+
+ +
+
+ + {/* Stats Cards */} +
+
+
+
+
+ + + +
+
+
+
Personal Volume
+
{node.personalVolume.toLocaleString()}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Group Volume
+
{node.groupVolume.toLocaleString()}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Direct Referrals
+
{node.directReferrals}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Total Earnings
+
${node.totalEarnings.toLocaleString()}
+
+
+
+
+
+
+ + {/* Member Details Grid */} +
+ {/* Member Information */} +
+

Member Information

+
+
+ Member ID + {node.id.slice(0, 8)} +
+
+ Position + {node.position ?? 'N/A'} +
+
+ Depth Level + Level {node.depth} +
+
+ Total Downline + {node.totalDownline} +
+
+ Joined Date + + {new Date(node.joinedAt).toLocaleDateString()} + +
+ {node.inviteCode && ( +
+ Invite Code + {node.inviteCode} +
+ )} +
+
+ + {/* Rank Information */} +
+

Rank Information

+ {node.rank ? ( +
+
+ {node.rank.level} +
+
+

{node.rank.name}

+

Current Rank - Level {node.rank.level}

+
+
+ ) : ( +
+
+ - +
+
+

No Rank

+

This member has not achieved a rank yet

+
+
+ )} +
+
+ Current Rank + {node.rank?.name || 'None'} +
+
+ Highest Rank Achieved + + {node.highestRankId ? 'Level ' + node.rank?.level : 'None'} + +
+
+
+
+ + {/* Upline Section */} +
+
+

Upline (Sponsors)

+

The chain of sponsors above this member

+
+ {upline && upline.length > 0 ? ( +
+ {upline.map((sponsor, index) => ( +
+
+ L{index + 1} +
+ {sponsor.user?.firstName?.charAt(0) || sponsor.user?.email?.charAt(0) || '?'} +
+
+

+ {sponsor.user?.firstName && sponsor.user?.lastName + ? `${sponsor.user.firstName} ${sponsor.user.lastName}` + : sponsor.user?.email || 'Unknown'} +

+

{sponsor.rank?.name || 'No Rank'}

+
+
+ + View + +
+ ))} +
+ ) : ( +
+

This member is at the top of the structure

+
+ )} +
+ + {/* Downline Section */} +
+
+
+

Direct Downline

+

Members directly sponsored by this member

+
+ {downline && downline.length > 0 && ( + {downline.length} direct referrals + )} +
+ {downline && downline.length > 0 ? ( + + + + + + + + + + + + {downline.map((member) => ( + + + + + + + + ))} + +
MemberRankVolumeStatusActions
+
+
+ {member.user?.firstName?.charAt(0) || member.user?.email?.charAt(0) || '?'} +
+
+

+ {member.user?.firstName && member.user?.lastName + ? `${member.user.firstName} ${member.user.lastName}` + : member.user?.email || 'Unknown'} +

+

{member.user?.email}

+
+
+
+ {member.rank?.name || 'No Rank'} + +
+
PV: {member.personalVolume.toLocaleString()}
+
GV: {member.groupVolume.toLocaleString()}
+
+
+ + {member.status} + + + + View + +
+ ) : ( +
+

No direct downline members

+
+ )} +
+ + {/* Recent Commissions */} +
+
+
+

Recent Commissions

+

Latest commission earnings for this member

+
+ + View All + +
+ {commissions && commissions.items && commissions.items.length > 0 ? ( + + + + + + + + + + + + {commissions.items.map((commission) => ( + + + + + + + + ))} + +
TypeLevelSourceAmountStatus
+ {commission.type} + + Level {commission.level} + + + {commission.sourceNode?.user?.firstName || commission.sourceNode?.user?.email || 'Unknown'} + + + + ${commission.commissionAmount.toLocaleString()} + + + + {commission.status} + +
+ ) : ( +
+

No commissions recorded yet

+
+ )} +
+
+ ); +} diff --git a/src/pages/dashboard/mlm/RanksPage.tsx b/src/pages/dashboard/mlm/RanksPage.tsx new file mode 100644 index 0000000..533fc40 --- /dev/null +++ b/src/pages/dashboard/mlm/RanksPage.tsx @@ -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({}); + 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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+

Error loading ranks

+
+ ); + } + + return ( +
+
+

MLM Ranks

+ + + Add Rank + +
+ + {/* Filters */} +
+
+
+ + +
+
+ + +
+
+ {filters.structureId && ( + + )} +
+
+
+ + {/* Ranks Table */} +
+ + + + + + + + + + + + + {ranks?.map((rank: Rank) => ( + + + + + + + + + ))} + +
LevelRankRequirementsBonus RateStatusActions
+
+ {rank.level} +
+
+
+ {rank.badgeUrl && ( + {rank.name} + )} +
+
{rank.name}
+
+
+
+
+ {rank.requirements?.personalVolume && ( +
PV: {rank.requirements.personalVolume.toLocaleString()}
+ )} + {rank.requirements?.groupVolume && ( +
GV: {rank.requirements.groupVolume.toLocaleString()}
+ )} + {rank.requirements?.directReferrals && ( +
Referrals: {rank.requirements.directReferrals}
+ )} +
+
+ {rank.bonusRate ? ( + {rank.bonusRate}% + ) : ( + - + )} + + + {rank.isActive ? 'Active' : 'Inactive'} + + + + Edit + + +
+ + {(!ranks || ranks.length === 0) && ( +
+ + + +

No ranks found

+ + Create your first rank + +
+ )} +
+
+ ); +} diff --git a/src/pages/dashboard/mlm/StructureDetailPage.tsx b/src/pages/dashboard/mlm/StructureDetailPage.tsx new file mode 100644 index 0000000..3a46339 --- /dev/null +++ b/src/pages/dashboard/mlm/StructureDetailPage.tsx @@ -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 ( +
+
+
+ ); + } + + if (error || !structure) { + return ( +
+

Error loading structure

+ + Back to Structures + +
+ ); + } + + const getTypeLabel = (type: string) => { + const labels: Record = { + unilevel: 'Unilevel', + binary: 'Binary', + matrix: 'Matrix', + hybrid: 'Hybrid', + }; + return labels[type] || type; + }; + + const getTypeColor = (type: string) => { + const colors: Record = { + 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 = { + 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 ( +
+ {/* Breadcrumbs */} + + + {/* Header */} +
+
+
+

{structure.name}

+ + {getTypeLabel(structure.type)} + + + {structure.isActive ? 'Active' : 'Inactive'} + +
+ {structure.description && ( +

{structure.description}

+ )} +
+
+ + Edit + + +
+
+ + {/* Stats Cards */} +
+
+
+
+
+ + + +
+
+
+
Total Nodes
+
{nodeStats.total}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Active Nodes
+
{nodeStats.active}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Pending Nodes
+
{nodeStats.pending}
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Available Ranks
+
{ranks?.length || 0}
+
+
+
+
+
+
+ + {/* Configuration Section */} +
+ {/* Structure Configuration */} +
+

Configuration

+
+ {structure.config?.maxDepth && ( +
+ Maximum Depth + {structure.config.maxDepth} levels +
+ )} + {structure.config?.maxWidth && ( +
+ Maximum Width + {structure.config.maxWidth} +
+ )} + {structure.config?.spillover && ( +
+ Spillover Strategy + + {structure.config.spillover.replace(/_/g, ' ')} + +
+ )} + {structure.type === 'matrix' && ( + <> + {structure.config?.width && ( +
+ Matrix Width + {structure.config.width} +
+ )} + {structure.config?.depth && ( +
+ Matrix Depth + {structure.config.depth} +
+ )} + + )} +
+ Created + + {new Date(structure.createdAt).toLocaleDateString()} + +
+
+
+ + {/* Level Commission Rates */} +
+

Commission Rates

+ {structure.levelRates && structure.levelRates.length > 0 ? ( +
+ {structure.levelRates.map((rate) => ( +
+ Level {rate.level} + {rate.rate}% +
+ ))} +
+ ) : ( +

No commission rates configured

+ )} + + {structure.matchingRates && structure.matchingRates.length > 0 && ( +
+

Matching Bonus Rates

+
+ {structure.matchingRates.map((rate) => ( +
+ Generation {rate.level} + {rate.rate}% +
+ ))} +
+
+ )} +
+
+ + {/* Ranks Table */} +
+
+

Available Ranks

+ + + Add Rank + +
+ {ranks && ranks.length > 0 ? ( + + + + + + + + + + + {ranks.map((rank) => ( + + + + + + + ))} + +
LevelRank NameRequirementsBonus Rate
+
+ {rank.level} +
+
+ {rank.name} + +
+ {rank.requirements?.personalVolume &&
PV: {rank.requirements.personalVolume.toLocaleString()}
} + {rank.requirements?.groupVolume &&
GV: {rank.requirements.groupVolume.toLocaleString()}
} + {rank.requirements?.directReferrals &&
Referrals: {rank.requirements.directReferrals}
} +
+
+ {rank.bonusRate ? ( + {rank.bonusRate}% + ) : ( + - + )} +
+ ) : ( +
+

No ranks configured for this structure

+ + Create the first rank + +
+ )} +
+ + {/* Recent Nodes Table */} +
+
+

Recent Members

+ + View All + +
+ {nodes && nodes.items && nodes.items.length > 0 ? ( + + + + + + + + + + + + + {nodes.items.slice(0, 10).map((node) => ( + + + + + + + + + ))} + +
MemberRankLevelVolumeStatusActions
+
+
+ {node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'} +
+
+
+ {node.user?.firstName && node.user?.lastName + ? `${node.user.firstName} ${node.user.lastName}` + : node.user?.email || 'Unknown'} +
+
{node.user?.email}
+
+
+
+ {node.rank?.name || 'No Rank'} + + Level {node.depth} + +
+
PV: {node.personalVolume.toLocaleString()}
+
GV: {node.groupVolume.toLocaleString()}
+
+
+ + {node.status} + + + + View + +
+ ) : ( +
+

No members in this structure yet

+
+ )} +
+
+ ); +} diff --git a/src/pages/dashboard/mlm/StructuresPage.tsx b/src/pages/dashboard/mlm/StructuresPage.tsx new file mode 100644 index 0000000..a7ed010 --- /dev/null +++ b/src/pages/dashboard/mlm/StructuresPage.tsx @@ -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({}); + 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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+

Error loading structures

+
+ ); + } + + const getTypeLabel = (type: StructureType) => { + const labels: Record = { + unilevel: 'Unilevel', + binary: 'Binary', + matrix: 'Matrix', + hybrid: 'Hybrid', + }; + return labels[type] || type; + }; + + const getTypeColor = (type: StructureType) => { + const colors: Record = { + 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 ( +
+
+

MLM Structures

+ + + Add Structure + +
+ + {/* Filters */} +
+
+
+ + +
+
+ + +
+
+ + 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" + /> +
+
+
+ + {/* Structures Grid */} +
+ {structures?.map((structure: Structure) => ( +
+
+
+

{structure.name}

+ + {getTypeLabel(structure.type)} + +
+ + {structure.isActive ? 'Active' : 'Inactive'} + +
+ + {structure.description && ( +

{structure.description}

+ )} + +
+ {structure.config?.maxDepth && ( +
+ Max Depth + {structure.config.maxDepth} levels +
+ )} + {structure.config?.maxWidth && ( +
+ Max Width + {structure.config.maxWidth} +
+ )} + {structure.levelRates && structure.levelRates.length > 0 && ( +
+ Commission Levels + {structure.levelRates.length} +
+ )} +
+ +
+ + View Details + + +
+
+ ))} +
+ + {structures?.length === 0 && ( +
+ + + +

No structures found

+ + Create your first structure + +
+ )} +
+ ); +} diff --git a/src/pages/dashboard/mlm/index.ts b/src/pages/dashboard/mlm/index.ts new file mode 100644 index 0000000..84b676f --- /dev/null +++ b/src/pages/dashboard/mlm/index.ts @@ -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'; diff --git a/src/router/index.tsx b/src/router/index.tsx index bb78e64..d368834 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -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 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 const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings }))); const AnalyticsDashboardPage = lazy(() => import('@/pages/admin/AnalyticsDashboardPage').then(m => ({ default: m.AnalyticsDashboardPage }))); @@ -163,6 +180,23 @@ export function AppRouter() { } /> } /> } /> + + {/* Goals routes */} + } /> + } /> + } /> + } /> + } /> + } /> + + {/* MLM routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Superadmin routes */}