[SPRINT-2] feat: Complete Portfolio, MLM and Goals UI modules

## ST-2.1 Portfolio UI (5 SP)
- Add CategoryDetailPage for category details and products
- Add ProductDetailPage with variants and prices management
- Add ProductFormPage for create/edit products
- Add 7 components: CategoryTree, CategoryForm, ProductCard, ProductForm,
  VariantList, PriceTable, ProductFilters
- Add Portfolio link to sidebar

## ST-2.2 MLM UI (8 SP)
- Add NodesPage with filters and status management
- Add 8 components: NetworkTree, StructureCard, NodeCard, RankBadge,
  NodeStatusBadge, MLMStatsCard, DownlineList, CommissionsSummary
- Complete MLM navigation structure

## ST-2.3 Goals UI (8 SP)
- Add AssignmentsPage for goal assignments list
- Add DefinitionFormPage for create/edit goal definitions
- Add 9 components: GoalProgressBar, GoalCard, GoalAssignmentCard,
  GoalFilters, GoalForm, ProgressLogList, ProgressLogForm, GoalsKPIGrid
- Complete Goals navigation with all routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 20:10:38 -06:00
parent 193b26f6f1
commit 891689a4f4
36 changed files with 5726 additions and 2 deletions

View File

@ -0,0 +1,193 @@
import type { Assignment, AssignmentStatus } from '@/services/goals';
import { GoalProgressBar } from './GoalProgressBar';
interface GoalAssignmentCardProps {
assignment: Assignment;
onUpdateProgress?: () => void;
onStatusChange?: (status: AssignmentStatus) => void;
onDelete?: () => void;
isUpdating?: boolean;
isDeleting?: boolean;
compact?: boolean;
}
export function GoalAssignmentCard({
assignment,
onUpdateProgress,
onStatusChange,
onDelete,
isUpdating = false,
isDeleting = false,
compact = false,
}: GoalAssignmentCardProps) {
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 formatDate = (dateStr: string | null) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString();
};
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);
const userName = assignment.user
? `${assignment.user.firstName || ''} ${assignment.user.lastName || ''}`.trim() || assignment.user.email
: 'Unknown User';
if (compact) {
return (
<div className="flex items-center justify-between p-4 bg-white border rounded-lg hover:shadow-sm transition-shadow">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-900 truncate">{goalName}</span>
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full ${getStatusColor(assignment.status)}`}>
{assignment.status}
</span>
</div>
<p className="text-xs text-gray-500 mt-1">{userName}</p>
</div>
<div className="flex items-center space-x-4 ml-4">
<div className="w-24">
<GoalProgressBar
currentValue={assignment.currentValue}
targetValue={target}
showValue={false}
showPercentage={true}
size="sm"
/>
</div>
<a
href={`/dashboard/goals/assignments/${assignment.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View
</a>
</div>
</div>
);
}
return (
<div 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">{goalName}</h3>
<p className="text-sm text-gray-500 mt-1">
Assigned to: {userName}
</p>
{assignment.customTarget && (
<p className="text-xs text-purple-600 mt-1">
Custom target: {assignment.customTarget.toLocaleString()} {unit}
</p>
)}
</div>
<div className="flex items-center space-x-3">
{onStatusChange ? (
<select
value={assignment.status}
onChange={(e) => onStatusChange(e.target.value as AssignmentStatus)}
disabled={isUpdating}
className={`px-2 py-1 text-xs 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>
) : (
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(assignment.status)}`}>
{assignment.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">
<GoalProgressBar
currentValue={assignment.currentValue}
targetValue={target}
unit={unit}
showValue={true}
showPercentage={true}
size="md"
/>
</div>
{/* Duration Info */}
<div className="flex justify-between text-xs text-gray-500 mb-4">
<span>Start: {formatDate(assignment.definition?.startsAt || null)}</span>
<span>End: {formatDate(assignment.definition?.endsAt || null)}</span>
</div>
{assignment.notes && (
<div className="mb-4 p-2 bg-gray-50 rounded text-sm text-gray-600">
{assignment.notes}
</div>
)}
{/* Actions */}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
{assignment.lastUpdatedAt && (
<span className="text-xs text-gray-500">
Last updated: {formatDate(assignment.lastUpdatedAt)}
</span>
)}
<div className="flex space-x-2">
{assignment.status === 'active' && onUpdateProgress && (
<button
onClick={onUpdateProgress}
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/${assignment.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>
{onDelete && (
<button
onClick={onDelete}
disabled={isDeleting}
className="inline-flex items-center px-3 py-1.5 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 disabled:opacity-50"
>
Delete
</button>
)}
</div>
</div>
</div>
</div>
);
}
export default GoalAssignmentCard;

View File

@ -0,0 +1,155 @@
import type { GoalDefinition, GoalStatus, PeriodType } from '@/services/goals';
import { GoalProgressBar } from './GoalProgressBar';
interface GoalCardProps {
goal: GoalDefinition;
onStatusChange?: (status: GoalStatus) => void;
onDelete?: () => void;
onDuplicate?: () => void;
isUpdating?: boolean;
isDeleting?: boolean;
}
export function GoalCard({
goal,
onStatusChange,
onDelete,
onDuplicate,
isUpdating = false,
isDeleting = false,
}: GoalCardProps) {
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="bg-white shadow rounded-lg p-6 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-gray-900 truncate">{goal.name}</h3>
<div className="flex items-center space-x-2 mt-1">
<span className="text-sm text-gray-500 capitalize">{goal.type}</span>
<span className="text-gray-300">|</span>
<span className="text-sm text-gray-500">{getPeriodLabel(goal.period)}</span>
</div>
</div>
{onStatusChange ? (
<select
value={goal.status}
onChange={(e) => onStatusChange(e.target.value as GoalStatus)}
disabled={isUpdating}
className={`px-2 py-1 text-xs 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>
) : (
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(goal.status)}`}>
{goal.status}
</span>
)}
</div>
{goal.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{goal.description}</p>
)}
<div className="space-y-3 mb-4">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Target</span>
<span className="font-medium text-gray-900">
{goal.targetValue.toLocaleString()} {goal.unit || ''}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Duration</span>
<span className="text-gray-900">
{formatDate(goal.startsAt)} - {formatDate(goal.endsAt)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Assigned</span>
<span className="text-gray-900">{goal.assignmentCount ?? 0} users</span>
</div>
</div>
{goal.category && (
<div className="mb-4">
<span className="inline-block px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
{goal.category}
</span>
</div>
)}
{goal.milestones && goal.milestones.length > 0 && (
<div className="mb-4">
<div className="text-xs text-gray-500 mb-1">Milestones</div>
<GoalProgressBar
currentValue={0}
targetValue={100}
milestones={goal.milestones}
showValue={false}
showPercentage={false}
size="sm"
/>
</div>
)}
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<a
href={`/dashboard/goals/definitions/${goal.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View Details
</a>
<div className="flex space-x-2">
{onDuplicate && (
<button
onClick={onDuplicate}
className="text-sm text-purple-600 hover:text-purple-900"
>
Duplicate
</button>
)}
{onDelete && (
<button
onClick={onDelete}
disabled={isDeleting}
className="text-sm text-red-600 hover:text-red-900 disabled:opacity-50"
>
Delete
</button>
)}
</div>
</div>
</div>
);
}
export default GoalCard;

View File

@ -0,0 +1,155 @@
import type { GoalStatus, PeriodType, DefinitionFilters, AssignmentFilters, AssignmentStatus } from '@/services/goals';
interface GoalFiltersProps {
type: 'definitions' | 'assignments';
filters: DefinitionFilters | AssignmentFilters;
onFilterChange: (filters: DefinitionFilters | AssignmentFilters) => void;
}
export function GoalFilters({ type, filters, onFilterChange }: GoalFiltersProps) {
if (type === 'definitions') {
const definitionFilters = filters as DefinitionFilters;
return (
<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={definitionFilters.status || ''}
onChange={(e) => onFilterChange({
...definitionFilters,
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={definitionFilters.period || ''}
onChange={(e) => onFilterChange({
...definitionFilters,
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={definitionFilters.category || ''}
onChange={(e) => onFilterChange({ ...definitionFilters, 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={definitionFilters.search || ''}
onChange={(e) => onFilterChange({ ...definitionFilters, 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>
);
}
// Assignment filters
const assignmentFilters = filters as AssignmentFilters;
return (
<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={assignmentFilters.status || ''}
onChange={(e) => onFilterChange({
...assignmentFilters,
status: (e.target.value || undefined) as AssignmentStatus | 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="active">Active</option>
<option value="achieved">Achieved</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Assignee Type</label>
<select
value={assignmentFilters.assigneeType || ''}
onChange={(e) => onFilterChange({
...assignmentFilters,
assigneeType: (e.target.value || undefined) as 'user' | 'team' | 'tenant' | 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 Types</option>
<option value="user">User</option>
<option value="team">Team</option>
<option value="tenant">Tenant</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Min Progress (%)</label>
<input
type="number"
min="0"
max="100"
value={assignmentFilters.minProgress ?? ''}
onChange={(e) => onFilterChange({
...assignmentFilters,
minProgress: e.target.value ? Number(e.target.value) : undefined,
page: 1
})}
placeholder="0"
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">Max Progress (%)</label>
<input
type="number"
min="0"
max="100"
value={assignmentFilters.maxProgress ?? ''}
onChange={(e) => onFilterChange({
...assignmentFilters,
maxProgress: e.target.value ? Number(e.target.value) : undefined,
page: 1
})}
placeholder="100"
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>
);
}
export default GoalFilters;

View File

@ -0,0 +1,390 @@
import { useState, useEffect } from 'react';
import type {
GoalDefinition,
CreateDefinitionDto,
GoalType,
MetricType,
PeriodType,
DataSource,
GoalStatus,
Milestone,
} from '@/services/goals';
interface GoalFormProps {
initialData?: GoalDefinition;
onSubmit: (data: CreateDefinitionDto) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
export function GoalForm({ initialData, onSubmit, onCancel, isSubmitting = false }: GoalFormProps) {
const [formData, setFormData] = useState<CreateDefinitionDto>({
name: '',
description: '',
category: '',
type: 'target',
metric: 'number',
targetValue: 0,
unit: '',
period: 'monthly',
startsAt: new Date().toISOString().split('T')[0],
endsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
source: 'manual',
milestones: [],
status: 'draft',
tags: [],
});
const [tagInput, setTagInput] = useState('');
const [milestoneInput, setMilestoneInput] = useState({ percentage: 50, notify: true });
useEffect(() => {
if (initialData) {
setFormData({
name: initialData.name,
description: initialData.description || '',
category: initialData.category || '',
type: initialData.type,
metric: initialData.metric,
targetValue: initialData.targetValue,
unit: initialData.unit || '',
period: initialData.period,
startsAt: initialData.startsAt.split('T')[0],
endsAt: initialData.endsAt.split('T')[0],
source: initialData.source,
milestones: initialData.milestones || [],
status: initialData.status,
tags: initialData.tags || [],
});
}
}, [initialData]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (field: keyof CreateDefinitionDto, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const addTag = () => {
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
handleChange('tags', [...(formData.tags || []), tagInput.trim()]);
setTagInput('');
}
};
const removeTag = (tag: string) => {
handleChange('tags', (formData.tags || []).filter((t) => t !== tag));
};
const addMilestone = () => {
if (milestoneInput.percentage > 0 && milestoneInput.percentage <= 100) {
const exists = (formData.milestones || []).some(
(m) => m.percentage === milestoneInput.percentage
);
if (!exists) {
handleChange('milestones', [
...(formData.milestones || []),
{ ...milestoneInput },
].sort((a, b) => a.percentage - b.percentage));
setMilestoneInput({ percentage: 50, notify: true });
}
}
};
const removeMilestone = (percentage: number) => {
handleChange(
'milestones',
(formData.milestones || []).filter((m) => m.percentage !== percentage)
);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Basic Information</h3>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700">Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="e.g., Monthly Sales Target"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => handleChange('description', e.target.value)}
rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Describe the goal..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Category</label>
<input
type="text"
value={formData.category || ''}
onChange={(e) => handleChange('category', 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"
placeholder="e.g., Sales, Performance"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Status</label>
<select
value={formData.status || 'draft'}
onChange={(e) => handleChange('status', e.target.value as GoalStatus)}
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="draft">Draft</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
</select>
</div>
</div>
</div>
{/* Target Configuration */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Target Configuration</h3>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700">Type</label>
<select
value={formData.type || 'target'}
onChange={(e) => handleChange('type', e.target.value as GoalType)}
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="target">Target (reach a value)</option>
<option value="limit">Limit (stay under)</option>
<option value="maintain">Maintain (keep within range)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Metric</label>
<select
value={formData.metric || 'number'}
onChange={(e) => handleChange('metric', e.target.value as MetricType)}
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="number">Number</option>
<option value="currency">Currency</option>
<option value="percentage">Percentage</option>
<option value="count">Count</option>
<option value="boolean">Boolean</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Data Source</label>
<select
value={formData.source || 'manual'}
onChange={(e) => handleChange('source', e.target.value as DataSource)}
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="manual">Manual</option>
<option value="sales">Sales</option>
<option value="billing">Billing</option>
<option value="commissions">Commissions</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Target Value *</label>
<input
type="number"
value={formData.targetValue}
onChange={(e) => handleChange('targetValue', Number(e.target.value))}
required
min="0"
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">Unit</label>
<input
type="text"
value={formData.unit || ''}
onChange={(e) => handleChange('unit', 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"
placeholder="e.g., USD, units, %"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Period</label>
<select
value={formData.period || 'monthly'}
onChange={(e) => handleChange('period', e.target.value as PeriodType)}
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="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
</div>
{/* Duration */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Duration</h3>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">Start Date *</label>
<input
type="date"
value={formData.startsAt}
onChange={(e) => handleChange('startsAt', e.target.value)}
required
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={formData.endsAt}
onChange={(e) => handleChange('endsAt', e.target.value)}
required
min={formData.startsAt}
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>
{/* Milestones */}
<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 items-end gap-4 mb-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700">Percentage</label>
<input
type="number"
value={milestoneInput.percentage}
onChange={(e) => setMilestoneInput((prev) => ({ ...prev, percentage: Number(e.target.value) }))}
min="1"
max="100"
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-center">
<input
type="checkbox"
checked={milestoneInput.notify}
onChange={(e) => setMilestoneInput((prev) => ({ ...prev, notify: e.target.checked }))}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-700">Notify</label>
</div>
<button
type="button"
onClick={addMilestone}
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"
>
Add
</button>
</div>
{formData.milestones && formData.milestones.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.milestones.map((milestone: Milestone) => (
<span
key={milestone.percentage}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800"
>
{milestone.percentage}%
{milestone.notify && (
<svg className="ml-1 w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z" />
</svg>
)}
<button
type="button"
onClick={() => removeMilestone(milestone.percentage)}
className="ml-2 text-blue-600 hover:text-blue-900"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* Tags */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Tags</h3>
<div className="flex items-end gap-4 mb-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700">Tag Name</label>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Press Enter to add"
/>
</div>
<button
type="button"
onClick={addTag}
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"
>
Add Tag
</button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag: string) => (
<span
key={tag}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100 text-gray-800"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-2 text-gray-600 hover:text-gray-900"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
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>
<button
type="submit"
disabled={isSubmitting}
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"
>
{isSubmitting ? 'Saving...' : initialData ? 'Update Goal' : 'Create Goal'}
</button>
</div>
</form>
);
}
export default GoalForm;

View File

@ -0,0 +1,123 @@
import { useMemo } from 'react';
interface Milestone {
percentage: number;
notify?: boolean;
}
interface GoalProgressBarProps {
currentValue: number;
targetValue: number;
unit?: string;
milestones?: Milestone[];
showValue?: boolean;
showPercentage?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function GoalProgressBar({
currentValue,
targetValue,
unit = '',
milestones,
showValue = true,
showPercentage = true,
size = 'md',
className = '',
}: GoalProgressBarProps) {
const percentage = useMemo(() => {
if (targetValue === 0) return 0;
return Math.min((currentValue / targetValue) * 100, 100);
}, [currentValue, targetValue]);
const getProgressColor = (pct: number) => {
if (pct >= 100) return 'bg-green-500';
if (pct >= 80) return 'bg-green-400';
if (pct >= 50) return 'bg-yellow-500';
if (pct >= 25) return 'bg-orange-500';
return 'bg-red-500';
};
const sizeClasses = {
sm: 'h-2',
md: 'h-3',
lg: 'h-4',
};
const milestoneSizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4',
};
return (
<div className={`w-full ${className}`}>
{/* Value and Percentage Display */}
{(showValue || showPercentage) && (
<div className="flex justify-between items-center mb-1">
{showValue && (
<span className="text-sm text-gray-600">
{currentValue.toLocaleString()} / {targetValue.toLocaleString()} {unit}
</span>
)}
{showPercentage && (
<span className="text-sm font-medium text-gray-900">
{percentage.toFixed(1)}%
</span>
)}
</div>
)}
{/* Progress Bar Container */}
<div className="relative">
{/* Background Bar */}
<div className={`w-full bg-gray-200 rounded-full ${sizeClasses[size]}`}>
{/* Progress Fill */}
<div
className={`${sizeClasses[size]} rounded-full transition-all duration-500 ${getProgressColor(percentage)}`}
style={{ width: `${percentage}%` }}
/>
</div>
{/* Milestones */}
{milestones && milestones.length > 0 && (
<div className="absolute inset-0 flex items-center pointer-events-none">
{milestones.map((milestone, index) => (
<div
key={index}
className="absolute flex flex-col items-center"
style={{ left: `${milestone.percentage}%`, transform: 'translateX(-50%)' }}
>
<div
className={`${milestoneSizeClasses[size]} rounded-full border-2 ${
percentage >= milestone.percentage
? 'bg-green-500 border-green-600'
: 'bg-white border-gray-300'
}`}
/>
</div>
))}
</div>
)}
</div>
{/* Milestone Labels */}
{milestones && milestones.length > 0 && size === 'lg' && (
<div className="relative mt-1 text-xs text-gray-500">
{milestones.map((milestone, index) => (
<span
key={index}
className="absolute"
style={{ left: `${milestone.percentage}%`, transform: 'translateX(-50%)' }}
>
{milestone.percentage}%
</span>
))}
</div>
)}
</div>
);
}
export default GoalProgressBar;

View File

@ -0,0 +1,123 @@
interface KPIData {
totalGoals?: number;
activeGoals?: number;
achievedGoals?: number;
failedGoals?: number;
averageProgress?: number;
completionRate?: number;
atRiskCount?: number;
}
interface GoalsKPIGridProps {
data: KPIData;
showAll?: boolean;
}
export function GoalsKPIGrid({ data, showAll = false }: GoalsKPIGridProps) {
const kpis = [
{
key: 'totalGoals',
label: 'Total Goals',
value: data.totalGoals ?? 0,
icon: (
<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>
),
color: 'text-gray-900',
},
{
key: 'activeGoals',
label: 'Active',
value: data.activeGoals ?? 0,
icon: (
<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>
),
color: 'text-blue-600',
},
{
key: 'achievedGoals',
label: 'Achieved',
value: data.achievedGoals ?? 0,
icon: (
<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>
),
color: 'text-green-600',
},
{
key: 'failedGoals',
label: 'Failed',
value: data.failedGoals ?? 0,
icon: (
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
color: 'text-red-600',
show: showAll,
},
{
key: 'atRiskCount',
label: 'At Risk',
value: data.atRiskCount ?? 0,
icon: (
<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>
),
color: 'text-yellow-600',
},
{
key: 'averageProgress',
label: 'Avg Progress',
value: `${(data.averageProgress ?? 0).toFixed(1)}%`,
icon: (
<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>
),
color: 'text-purple-600',
},
{
key: 'completionRate',
label: 'Completion Rate',
value: `${(data.completionRate ?? 0).toFixed(1)}%`,
icon: (
<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="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>
),
color: 'text-indigo-600',
show: showAll,
},
];
const visibleKpis = kpis.filter((kpi) => kpi.show !== false);
const gridCols = visibleKpis.length <= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-5';
return (
<div className={`grid grid-cols-1 gap-5 sm:grid-cols-2 ${gridCols}`}>
{visibleKpis.map((kpi) => (
<div key={kpi.key} className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">{kpi.icon}</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">{kpi.label}</dt>
<dd className={`text-lg font-semibold ${kpi.color}`}>{kpi.value}</dd>
</dl>
</div>
</div>
</div>
</div>
))}
</div>
);
}
export default GoalsKPIGrid;

View File

@ -0,0 +1,139 @@
import { useState } from 'react';
import type { UpdateProgressDto, ProgressSource } from '@/services/goals';
interface ProgressLogFormProps {
currentValue: number;
targetValue: number;
unit?: string;
onSubmit: (data: UpdateProgressDto) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
export function ProgressLogForm({
currentValue,
targetValue,
unit = '',
onSubmit,
onCancel,
isSubmitting = false,
}: ProgressLogFormProps) {
const [formData, setFormData] = useState<UpdateProgressDto>({
value: currentValue,
source: 'manual',
notes: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (field: keyof UpdateProgressDto, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const progressPercentage = targetValue > 0 ? ((formData.value || 0) / targetValue) * 100 : 0;
const changeAmount = (formData.value || 0) - currentValue;
return (
<form onSubmit={handleSubmit} className="space-y-4">
<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={formData.value}
onChange={(e) => handleChange('value', Number(e.target.value))}
required
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 className="mt-1 text-xs text-gray-500">
Current: {currentValue.toLocaleString()} | Target: {targetValue.toLocaleString()}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Source</label>
<select
value={formData.source || 'manual'}
onChange={(e) => handleChange('source', e.target.value as ProgressSource)}
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="manual">Manual</option>
<option value="automatic">Automatic</option>
<option value="import">Import</option>
<option value="api">API</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Notes (optional)</label>
<textarea
value={formData.notes || ''}
onChange={(e) => handleChange('notes', e.target.value)}
rows={2}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Add a note about this update..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Reference (optional)</label>
<input
type="text"
value={formData.sourceReference || ''}
onChange={(e) => handleChange('sourceReference', 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"
placeholder="e.g., Invoice #12345"
/>
</div>
{/* Preview */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Preview</h4>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900">
{(formData.value || 0).toLocaleString()}
</div>
<div className="text-xs text-gray-500">New Value</div>
</div>
<div>
<div className={`text-2xl font-bold ${changeAmount >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{changeAmount >= 0 ? '+' : ''}{changeAmount.toLocaleString()}
</div>
<div className="text-xs text-gray-500">Change</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600">
{progressPercentage.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">Progress</div>
</div>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
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>
<button
type="submit"
disabled={isSubmitting}
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"
>
{isSubmitting ? 'Saving...' : 'Save Progress'}
</button>
</div>
</form>
);
}
export default ProgressLogForm;

View File

@ -0,0 +1,79 @@
import type { ProgressLog } from '@/services/goals';
interface ProgressLogListProps {
logs: ProgressLog[];
unit?: string;
}
export function ProgressLogList({ logs, unit = '' }: ProgressLogListProps) {
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 formatDateTime = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
if (!logs || logs.length === 0) {
return (
<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>
);
}
return (
<div className="divide-y divide-gray-200">
{logs.map((log) => (
<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 ml-4">
<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>
);
}
export default ProgressLogList;

View File

@ -0,0 +1,8 @@
export { GoalProgressBar } from './GoalProgressBar';
export { GoalCard } from './GoalCard';
export { GoalAssignmentCard } from './GoalAssignmentCard';
export { GoalFilters } from './GoalFilters';
export { GoalForm } from './GoalForm';
export { ProgressLogList } from './ProgressLogList';
export { ProgressLogForm } from './ProgressLogForm';
export { GoalsKPIGrid } from './GoalsKPIGrid';

View File

@ -0,0 +1,106 @@
interface CommissionsSummaryProps {
totalEarnings?: number;
totalCommissions?: number;
totalBonuses?: number;
pendingAmount?: number;
paidAmount?: number;
compact?: boolean;
}
export function CommissionsSummary({
totalEarnings = 0,
totalCommissions = 0,
totalBonuses = 0,
pendingAmount = 0,
paidAmount = 0,
compact = false,
}: CommissionsSummaryProps) {
if (compact) {
return (
<div className="bg-white shadow rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Earnings Summary</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Total Earnings</span>
<span className="text-sm font-semibold text-green-600">${totalEarnings.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Pending</span>
<span className="text-sm font-medium text-yellow-600">${pendingAmount.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Paid</span>
<span className="text-sm font-medium text-gray-900">${paidAmount.toLocaleString()}</span>
</div>
</div>
</div>
);
}
return (
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Earnings Summary</h3>
{/* Total Earnings */}
<div className="mb-6">
<div className="flex justify-between items-end mb-2">
<span className="text-sm text-gray-500">Total Earnings</span>
<span className="text-2xl font-bold text-green-600">${totalEarnings.toLocaleString()}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{ width: `${totalEarnings > 0 ? (paidAmount / totalEarnings) * 100 : 0}%` }}
></div>
</div>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>Paid: ${paidAmount.toLocaleString()}</span>
<span>Pending: ${pendingAmount.toLocaleString()}</span>
</div>
</div>
{/* Breakdown */}
<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">${totalCommissions.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: `${totalEarnings > 0 ? (totalCommissions / 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">${totalBonuses.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: `${totalEarnings > 0 ? (totalBonuses / totalEarnings) * 100 : 0}%`,
}}
></div>
</div>
</div>
</div>
{/* Action Link */}
<div className="mt-6 pt-4 border-t border-gray-200">
<a
href="/dashboard/mlm/my-earnings"
className="text-sm text-blue-600 hover:text-blue-900"
>
View Detailed Earnings
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
import type { Node, NodeStatus } from '@/services/mlm';
interface DownlineListProps {
nodes: Node[];
title?: string;
showViewAll?: boolean;
viewAllHref?: string;
}
const statusColors: 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',
};
export function DownlineList({
nodes,
title = 'Downline',
showViewAll = false,
viewAllHref,
}: DownlineListProps) {
if (nodes.length === 0) {
return (
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">{title}</h3>
<p className="text-sm text-gray-500 text-center py-4">No downline members</p>
</div>
);
}
return (
<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">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
{showViewAll && viewAllHref && (
<a href={viewAllHref} className="text-sm text-blue-600 hover:text-blue-900">
View All
</a>
)}
</div>
<div className="divide-y divide-gray-100">
{nodes.map((node) => {
const memberName = node.user?.firstName && node.user?.lastName
? `${node.user.firstName} ${node.user.lastName}`
: node.user?.email || 'Unknown';
return (
<div
key={node.id}
className="flex items-center justify-between px-6 py-4 hover:bg-gray-50"
>
<div className="flex items-center">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium mr-4"
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">{memberName}</p>
<div className="flex items-center space-x-2 mt-0.5">
<span className="text-xs text-gray-500">{node.rank?.name || 'No Rank'}</span>
<span className="text-xs text-gray-300">|</span>
<span className="text-xs text-gray-500">Level {node.depth}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right hidden sm:block">
<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 ${statusColors[node.status]}`}
>
{node.status}
</span>
<a
href={`/dashboard/mlm/nodes/${node.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View
</a>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import { ReactNode } from 'react';
interface MLMStatsCardProps {
title: string;
value: string | number;
icon?: ReactNode;
iconColor?: string;
valueColor?: string;
subtitle?: string;
}
export function MLMStatsCard({
title,
value,
icon,
iconColor = 'text-gray-400',
valueColor = 'text-gray-900',
subtitle,
}: MLMStatsCardProps) {
return (
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
{icon && (
<div className={`flex-shrink-0 ${iconColor}`}>
{icon}
</div>
)}
<div className={icon ? 'ml-5 w-0 flex-1' : 'w-full'}>
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
<dd className={`text-lg font-semibold ${valueColor}`}>
{typeof value === 'number' ? value.toLocaleString() : value}
</dd>
{subtitle && (
<dd className="text-xs text-gray-400 mt-1">{subtitle}</dd>
)}
</dl>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
import type { TreeNode, NodeStatus } from '@/services/mlm';
interface NetworkTreeProps {
tree: TreeNode | null;
maxHeight?: string;
onNodeClick?: (nodeId: string) => void;
}
interface TreeNodeItemProps {
node: TreeNode;
depth: number;
onNodeClick?: (nodeId: string) => void;
}
const statusColors: 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',
};
function TreeNodeItem({ node, depth, onNodeClick }: TreeNodeItemProps) {
const indent = depth * 24;
const handleClick = () => {
if (onNodeClick) {
onNodeClick(node.id);
} else {
window.location.href = `/dashboard/mlm/nodes/${node.id}`;
}
};
return (
<div>
<div
className="flex items-center py-2 px-4 hover:bg-gray-50 border-b border-gray-100 cursor-pointer"
style={{ paddingLeft: `${indent + 16}px` }}
onClick={handleClick}
>
{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 hidden sm:block">
<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 ${statusColors[node.status]}`}>
{node.status}
</span>
</div>
</div>
</div>
{node.children?.map((child) => (
<TreeNodeItem key={child.id} node={child} depth={depth + 1} onNodeClick={onNodeClick} />
))}
</div>
);
}
export function NetworkTree({ tree, maxHeight = '600px', onNodeClick }: NetworkTreeProps) {
if (!tree) {
return (
<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">No network data available</p>
</div>
);
}
return (
<div className="overflow-y-auto" style={{ maxHeight }}>
<TreeNodeItem node={tree} depth={0} onNodeClick={onNodeClick} />
</div>
);
}

View File

@ -0,0 +1,97 @@
import type { Node } from '@/services/mlm';
import { NodeStatusBadge } from './NodeStatusBadge';
interface NodeCardProps {
node: Node;
showVolume?: boolean;
showEarnings?: boolean;
compact?: boolean;
}
export function NodeCard({ node, showVolume = true, showEarnings = true, compact = false }: NodeCardProps) {
const memberName = node.user?.firstName && node.user?.lastName
? `${node.user.firstName} ${node.user.lastName}`
: node.user?.email || 'Unknown';
if (compact) {
return (
<div className="flex items-center justify-between py-2">
<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">{memberName}</p>
<p className="text-xs text-gray-500">{node.rank?.name || 'No Rank'}</p>
</div>
</div>
<NodeStatusBadge status={node.status} size="sm" />
</div>
);
}
return (
<div className="bg-white shadow rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium"
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
>
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-900">{memberName}</p>
<p className="text-xs text-gray-500">{node.user?.email}</p>
</div>
</div>
<NodeStatusBadge status={node.status} />
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Rank</span>
<span className="font-medium text-gray-900">{node.rank?.name || 'No Rank'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Level</span>
<span className="font-medium text-gray-900">Level {node.depth}</span>
</div>
{showVolume && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Personal Volume</span>
<span className="font-medium text-gray-900">{node.personalVolume.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Group Volume</span>
<span className="font-medium text-gray-900">{node.groupVolume.toLocaleString()}</span>
</div>
</>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Direct Referrals</span>
<span className="font-medium text-gray-900">{node.directReferrals}</span>
</div>
{showEarnings && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Total Earnings</span>
<span className="font-medium text-green-600">${node.totalEarnings.toLocaleString()}</span>
</div>
)}
</div>
<div className="mt-4 pt-3 border-t border-gray-200">
<a
href={`/dashboard/mlm/nodes/${node.id}`}
className="text-sm text-blue-600 hover:text-blue-900"
>
View Details
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import type { NodeStatus } from '@/services/mlm';
interface NodeStatusBadgeProps {
status: NodeStatus;
size?: 'sm' | 'md';
}
const statusConfig: Record<NodeStatus, { bg: string; text: string; label: string }> = {
pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Pending' },
active: { bg: 'bg-green-100', text: 'text-green-800', label: 'Active' },
inactive: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Inactive' },
suspended: { bg: 'bg-red-100', text: 'text-red-800', label: 'Suspended' },
};
export function NodeStatusBadge({ status, size = 'md' }: NodeStatusBadgeProps) {
const config = statusConfig[status] || statusConfig.inactive;
const sizeClasses = size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-xs';
return (
<span className={`${sizeClasses} font-semibold rounded-full ${config.bg} ${config.text}`}>
{config.label}
</span>
);
}

View File

@ -0,0 +1,34 @@
interface RankBadgeProps {
level: number;
name: string;
color?: string | null;
size?: 'sm' | 'md' | 'lg';
showLevel?: boolean;
}
export function RankBadge({ level, name, color, size = 'md', showLevel = true }: RankBadgeProps) {
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-12 h-12 text-lg',
};
const bgColor = color || '#6B7280';
return (
<div className="flex items-center">
<div
className={`${sizeClasses[size]} rounded-full flex items-center justify-center text-white font-bold`}
style={{ backgroundColor: bgColor }}
>
{showLevel ? level : name.charAt(0)}
</div>
{size !== 'sm' && (
<div className="ml-2">
<span className="text-sm font-medium text-gray-900">{name}</span>
{showLevel && <span className="text-xs text-gray-500 ml-1">Level {level}</span>}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,94 @@
import type { Structure, StructureType } from '@/services/mlm';
interface StructureCardProps {
structure: Structure;
onDelete?: (id: string) => void;
isDeleting?: boolean;
}
const typeLabels: Record<StructureType, string> = {
unilevel: 'Unilevel',
binary: 'Binary',
matrix: 'Matrix',
hybrid: 'Hybrid',
};
const typeColors: 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',
};
export function StructureCard({ structure, onDelete, isDeleting }: StructureCardProps) {
const handleDelete = () => {
if (onDelete && window.confirm('Are you sure you want to delete this structure?')) {
onDelete(structure.id);
}
};
return (
<div 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 ${typeColors[structure.type]}`}>
{typeLabels[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>
{onDelete && (
<button
onClick={handleDelete}
disabled={isDeleting}
className="text-sm text-red-600 hover:text-red-900 disabled:opacity-50"
>
Delete
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
export { RankBadge } from './RankBadge';
export { NodeStatusBadge } from './NodeStatusBadge';
export { MLMStatsCard } from './MLMStatsCard';
export { StructureCard } from './StructureCard';
export { NodeCard } from './NodeCard';
export { NetworkTree } from './NetworkTree';
export { DownlineList } from './DownlineList';
export { CommissionsSummary } from './CommissionsSummary';

View File

@ -0,0 +1,199 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import type { CreateCategoryDto, UpdateCategoryDto, Category } from '@/services/portfolio';
interface CategoryFormProps {
category?: Category | null;
onSubmit: (data: CreateCategoryDto | UpdateCategoryDto) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
parentCategories?: Category[];
}
export function CategoryForm({
category,
onSubmit,
onCancel,
isLoading = false,
parentCategories = [],
}: CategoryFormProps) {
const isEditing = !!category;
const [formData, setFormData] = useState<CreateCategoryDto>({
name: '',
slug: '',
description: '',
color: '#3b82f6',
is_active: true,
});
useEffect(() => {
if (category) {
setFormData({
name: category.name,
slug: category.slug,
description: category.description || '',
color: category.color || '#3b82f6',
is_active: category.is_active,
parent_id: category.parent_id || undefined,
icon: category.icon || undefined,
meta_title: category.meta_title || undefined,
meta_description: category.meta_description || undefined,
});
}
}, [category]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
const isValid = formData.name && formData.slug;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditing ? 'Edit Category' : 'New Category'}
</h2>
<button
onClick={onCancel}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => {
const name = e.target.value;
setFormData({
...formData,
name,
slug: !isEditing ? generateSlug(name) : formData.slug,
});
}}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Category name"
required
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Slug *
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="category-slug"
required
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Category description..."
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Parent Category
</label>
<select
value={formData.parent_id || ''}
onChange={(e) => setFormData({ ...formData, parent_id: e.target.value || undefined })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">No parent (root category)</option>
{parentCategories
.filter((cat) => cat.id !== category?.id)
.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Color
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="h-10 w-10 cursor-pointer rounded border border-gray-300"
/>
<input
type="text"
value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="is_active" className="text-sm text-gray-700 dark:text-gray-300">
Active category (visible in catalog)
</label>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Saving...' : isEditing ? 'Update Category' : 'Create Category'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,181 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { FolderTree, ChevronRight, ChevronDown, Edit, Trash2 } from 'lucide-react';
import type { CategoryTreeNode } from '@/services/portfolio';
interface CategoryTreeProps {
categories: CategoryTreeNode[];
onDelete?: (id: string) => void;
onEdit?: (id: string) => void;
selectedId?: string;
expandedIds?: Set<string>;
onToggleExpand?: (id: string) => void;
showActions?: boolean;
}
interface CategoryNodeProps {
category: CategoryTreeNode;
level: number;
expandedIds: Set<string>;
onToggleExpand: (id: string) => void;
onDelete?: (id: string) => void;
onEdit?: (id: string) => void;
selectedId?: string;
showActions?: boolean;
}
function CategoryNode({
category,
level,
expandedIds,
onToggleExpand,
onDelete,
onEdit,
selectedId,
showActions = true,
}: CategoryNodeProps) {
const isExpanded = expandedIds.has(category.id);
const hasChildren = category.children && category.children.length > 0;
const isSelected = selectedId === category.id;
return (
<div>
<div
className={`flex items-center justify-between rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-700 ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
style={{ paddingLeft: `${level * 24 + 8}px` }}
>
<div className="flex items-center gap-2">
{hasChildren ? (
<button
onClick={() => onToggleExpand(category.id)}
className="rounded p-1 hover:bg-gray-200 dark:hover:bg-gray-600"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-400" />
)}
</button>
) : (
<div className="w-6" />
)}
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: category.color || '#3b82f6' }}
/>
<Link
to={`/dashboard/portfolio/categories/${category.id}`}
className={`text-sm font-medium hover:text-blue-600 dark:hover:text-blue-400 ${
isSelected
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-white'
}`}
>
{category.name}
</Link>
{!category.is_active && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400">
Inactive
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">
{category.product_count || 0} products
</span>
{showActions && (
<>
{onEdit && (
<button
onClick={() => onEdit(category.id)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-300"
>
<Edit className="h-4 w-4" />
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(category.id)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-red-600 dark:hover:bg-gray-600 dark:hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</>
)}
</div>
</div>
{hasChildren && isExpanded && (
<div>
{category.children.map((child) => (
<CategoryNode
key={child.id}
category={child}
level={level + 1}
expandedIds={expandedIds}
onToggleExpand={onToggleExpand}
onDelete={onDelete}
onEdit={onEdit}
selectedId={selectedId}
showActions={showActions}
/>
))}
</div>
)}
</div>
);
}
export function CategoryTree({
categories,
onDelete,
onEdit,
selectedId,
expandedIds: externalExpandedIds,
onToggleExpand: externalToggleExpand,
showActions = true,
}: CategoryTreeProps) {
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const expandedIds = externalExpandedIds || internalExpandedIds;
const toggleExpand = externalToggleExpand || ((id: string) => {
setInternalExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
});
if (!categories.length) {
return (
<div className="py-8 text-center">
<FolderTree className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-gray-500 dark:text-gray-400">No categories found.</p>
</div>
);
}
return (
<div className="space-y-1">
{categories.map((category) => (
<CategoryNode
key={category.id}
category={category}
level={0}
expandedIds={expandedIds}
onToggleExpand={toggleExpand}
onDelete={onDelete}
onEdit={onEdit}
selectedId={selectedId}
showActions={showActions}
/>
))}
</div>
);
}

View File

@ -0,0 +1,268 @@
import { useState } from 'react';
import { Plus, Trash2, DollarSign } from 'lucide-react';
import type { Price, CreatePriceDto, PriceType } from '@/services/portfolio';
interface PriceTableProps {
prices: Price[];
onAdd: (data: CreatePriceDto) => Promise<void>;
onDelete: (priceId: string) => void;
isLoading?: boolean;
}
const priceTypeLabels: Record<PriceType, string> = {
one_time: 'One Time',
recurring: 'Recurring',
usage_based: 'Usage Based',
tiered: 'Tiered',
};
export function PriceTable({ prices, onAdd, onDelete, isLoading = false }: PriceTableProps) {
const [showAddForm, setShowAddForm] = useState(false);
const [newPrice, setNewPrice] = useState<CreatePriceDto>({
currency: 'USD',
amount: 0,
price_type: 'one_time',
min_quantity: 1,
is_active: true,
});
const handleAdd = async () => {
await onAdd(newPrice);
setShowAddForm(false);
setNewPrice({
currency: 'USD',
amount: 0,
price_type: 'one_time',
min_quantity: 1,
is_active: true,
});
};
return (
<div className="space-y-4">
{/* Prices Table */}
{prices.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Type
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Amount
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Min Qty
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Max Qty
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Priority
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Status
</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-gray-500">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{prices.map((price) => (
<tr key={price.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-900 dark:text-white">
{priceTypeLabels[price.price_type] || price.price_type}
{price.billing_period && (
<span className="ml-1 text-xs text-gray-500">
/ {price.billing_interval} {price.billing_period}
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm font-medium text-gray-900 dark:text-white">
${price.amount.toFixed(2)} {price.currency}
{price.compare_at_amount && price.compare_at_amount > price.amount && (
<span className="ml-2 text-xs text-gray-500 line-through">
${price.compare_at_amount.toFixed(2)}
</span>
)}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-500">
{price.min_quantity}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-500">
{price.max_quantity || 'Unlimited'}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-500">
{price.priority}
</td>
<td className="whitespace-nowrap px-3 py-3">
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
price.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{price.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="whitespace-nowrap px-3 py-3 text-right">
<button
onClick={() => onDelete(price.id)}
className="text-gray-600 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Empty State */}
{prices.length === 0 && !showAddForm && (
<div className="py-6 text-center">
<DollarSign className="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
No price tiers configured. Add your first price tier.
</p>
</div>
)}
{/* Add Form */}
{showAddForm && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
<h4 className="mb-3 font-medium text-gray-900 dark:text-white">Add Price Tier</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">
Price Type
</label>
<select
value={newPrice.price_type}
onChange={(e) =>
setNewPrice({ ...newPrice, price_type: e.target.value as PriceType })
}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="one_time">One Time</option>
<option value="recurring">Recurring</option>
<option value="usage_based">Usage Based</option>
<option value="tiered">Tiered</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">Amount</label>
<input
type="number"
step="0.01"
min="0"
value={newPrice.amount}
onChange={(e) => setNewPrice({ ...newPrice, amount: Number(e.target.value) })}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">Currency</label>
<select
value={newPrice.currency}
onChange={(e) => setNewPrice({ ...newPrice, currency: e.target.value })}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="MXN">MXN</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">
Min Quantity
</label>
<input
type="number"
min="1"
value={newPrice.min_quantity || 1}
onChange={(e) => setNewPrice({ ...newPrice, min_quantity: Number(e.target.value) })}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
{newPrice.price_type === 'recurring' && (
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">
Billing Period
</label>
<select
value={newPrice.billing_period || 'month'}
onChange={(e) => setNewPrice({ ...newPrice, billing_period: e.target.value })}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
<option value="year">Year</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">
Billing Interval
</label>
<input
type="number"
min="1"
value={newPrice.billing_interval || 1}
onChange={(e) =>
setNewPrice({ ...newPrice, billing_interval: Number(e.target.value) })
}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => {
setShowAddForm(false);
setNewPrice({
currency: 'USD',
amount: 0,
price_type: 'one_time',
min_quantity: 1,
is_active: true,
});
}}
className="rounded px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={isLoading || newPrice.amount <= 0}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Price'}
</button>
</div>
</div>
)}
{/* Add Button */}
{!showAddForm && (
<button
onClick={() => setShowAddForm(true)}
className="inline-flex items-center gap-2 rounded-lg border border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 hover:border-blue-500 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-500 dark:hover:text-blue-400"
>
<Plus className="h-4 w-4" />
Add Price Tier
</button>
)}
</div>
);
}

View File

@ -0,0 +1,135 @@
import { Link } from 'react-router-dom';
import { Package, Edit, Trash2, Copy, Eye } from 'lucide-react';
import type { Product } from '@/services/portfolio';
interface ProductCardProps {
product: Product;
onDelete?: (id: string) => void;
onDuplicate?: (id: string) => void;
showActions?: boolean;
}
const statusColors: Record<string, string> = {
draft: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
active: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400',
discontinued: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
out_of_stock: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400',
};
export function ProductCard({
product,
onDelete,
onDuplicate,
showActions = true,
}: ProductCardProps) {
return (
<div className="group relative rounded-lg border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
{/* Image placeholder */}
<div className="mb-4 flex h-32 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
{product.featured_image_url ? (
<img
src={product.featured_image_url}
alt={product.name}
className="h-full w-full rounded-lg object-cover"
/>
) : (
<Package className="h-12 w-12 text-gray-300 dark:text-gray-600" />
)}
</div>
{/* Product info */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<Link
to={`/dashboard/portfolio/products/${product.id}`}
className="font-medium text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{product.name}
</Link>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[product.status] || statusColors.inactive}`}>
{product.status}
</span>
</div>
{product.sku && (
<p className="font-mono text-xs text-gray-500 dark:text-gray-400">
SKU: {product.sku}
</p>
)}
<div className="flex items-center justify-between">
<p className="text-lg font-semibold text-gray-900 dark:text-white">
${product.base_price?.toFixed(2) || '0.00'}
</p>
{product.compare_at_price && product.compare_at_price > product.base_price && (
<p className="text-sm text-gray-500 line-through">
${product.compare_at_price.toFixed(2)}
</p>
)}
</div>
{product.category && (
<Link
to={`/dashboard/portfolio/categories/${product.category.id}`}
className="inline-block text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{product.category.name}
</Link>
)}
</div>
{/* Actions - visible on hover */}
{showActions && (
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Link
to={`/dashboard/portfolio/products/${product.id}`}
className="rounded bg-white p-1.5 text-gray-600 shadow-sm hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
title="View"
>
<Eye className="h-4 w-4" />
</Link>
<Link
to={`/dashboard/portfolio/products/${product.id}/edit`}
className="rounded bg-white p-1.5 text-gray-600 shadow-sm hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
title="Edit"
>
<Edit className="h-4 w-4" />
</Link>
{onDuplicate && (
<button
onClick={() => onDuplicate(product.id)}
className="rounded bg-white p-1.5 text-gray-600 shadow-sm hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
title="Duplicate"
>
<Copy className="h-4 w-4" />
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(product.id)}
className="rounded bg-white p-1.5 text-red-600 shadow-sm hover:bg-red-50 dark:bg-gray-700 dark:text-red-400 dark:hover:bg-red-900/20"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
)}
{/* Badges */}
<div className="absolute left-2 top-2 flex flex-col gap-1">
{product.is_featured && (
<span className="rounded bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
Featured
</span>
)}
{!product.is_visible && (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Hidden
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,120 @@
import { Search, Filter, X } from 'lucide-react';
import type { ProductStatus, ProductType, Category } from '@/services/portfolio';
interface ProductFiltersProps {
search: string;
onSearchChange: (value: string) => void;
status: ProductStatus | 'all';
onStatusChange: (value: ProductStatus | 'all') => void;
productType?: ProductType | 'all';
onProductTypeChange?: (value: ProductType | 'all') => void;
categoryId?: string;
onCategoryChange?: (value: string) => void;
categories?: Category[];
showTypeFilter?: boolean;
showCategoryFilter?: boolean;
}
export function ProductFilters({
search,
onSearchChange,
status,
onStatusChange,
productType,
onProductTypeChange,
categoryId,
onCategoryChange,
categories = [],
showTypeFilter = false,
showCategoryFilter = false,
}: ProductFiltersProps) {
const hasActiveFilters = status !== 'all' || productType !== 'all' || categoryId;
const clearFilters = () => {
onSearchChange('');
onStatusChange('all');
if (onProductTypeChange) onProductTypeChange('all');
if (onCategoryChange) onCategoryChange('');
};
return (
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
{search && (
<button
onClick={() => onSearchChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Status Filter */}
<select
value={status}
onChange={(e) => onStatusChange(e.target.value as ProductStatus | 'all')}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="all">All Status</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="discontinued">Discontinued</option>
<option value="out_of_stock">Out of Stock</option>
</select>
{/* Product Type Filter */}
{showTypeFilter && onProductTypeChange && (
<select
value={productType || 'all'}
onChange={(e) => onProductTypeChange(e.target.value as ProductType | 'all')}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="all">All Types</option>
<option value="physical">Physical</option>
<option value="digital">Digital</option>
<option value="service">Service</option>
<option value="subscription">Subscription</option>
<option value="bundle">Bundle</option>
</select>
)}
{/* Category Filter */}
{showCategoryFilter && onCategoryChange && (
<select
value={categoryId || ''}
onChange={(e) => onCategoryChange(e.target.value)}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
)}
{/* Clear Filters */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Filter className="h-4 w-4" />
Clear Filters
</button>
)}
</div>
);
}

View File

@ -0,0 +1,307 @@
import { useState } from 'react';
import { Plus, Edit, Trash2, X, Save } from 'lucide-react';
import type { Variant, CreateVariantDto, UpdateVariantDto } from '@/services/portfolio';
interface VariantListProps {
variants: Variant[];
onAdd: (data: CreateVariantDto) => Promise<void>;
onUpdate?: (variantId: string, data: UpdateVariantDto) => Promise<void>;
onDelete: (variantId: string) => void;
isLoading?: boolean;
}
interface EditingVariant {
id: string | null;
data: CreateVariantDto | UpdateVariantDto;
}
export function VariantList({
variants,
onAdd,
onUpdate,
onDelete,
isLoading = false,
}: VariantListProps) {
const [showAddForm, setShowAddForm] = useState(false);
const [editingVariant, setEditingVariant] = useState<EditingVariant | null>(null);
const [newVariant, setNewVariant] = useState<CreateVariantDto>({
attributes: {},
is_active: true,
});
const handleAdd = async () => {
await onAdd(newVariant);
setShowAddForm(false);
setNewVariant({ attributes: {}, is_active: true });
};
const handleUpdate = async () => {
if (!editingVariant || !editingVariant.id || !onUpdate) return;
await onUpdate(editingVariant.id, editingVariant.data);
setEditingVariant(null);
};
const startEditing = (variant: Variant) => {
setEditingVariant({
id: variant.id,
data: {
name: variant.name || undefined,
sku: variant.sku || undefined,
price: variant.price || undefined,
stock_quantity: variant.stock_quantity,
is_active: variant.is_active,
},
});
};
return (
<div className="space-y-4">
{/* Variants Table */}
{variants.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Name/SKU
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Price
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Stock
</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">
Status
</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-gray-500">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{variants.map((variant) =>
editingVariant?.id === variant.id ? (
<tr key={variant.id} className="bg-blue-50 dark:bg-blue-900/10">
<td className="whitespace-nowrap px-3 py-2">
<input
type="text"
value={editingVariant.data.name || ''}
onChange={(e) =>
setEditingVariant({
...editingVariant,
data: { ...editingVariant.data, name: e.target.value },
})
}
className="w-full rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Variant name"
/>
</td>
<td className="whitespace-nowrap px-3 py-2">
<input
type="number"
step="0.01"
value={editingVariant.data.price || ''}
onChange={(e) =>
setEditingVariant({
...editingVariant,
data: {
...editingVariant.data,
price: e.target.value ? Number(e.target.value) : undefined,
},
})
}
className="w-24 rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0.00"
/>
</td>
<td className="whitespace-nowrap px-3 py-2">
<input
type="number"
value={editingVariant.data.stock_quantity || 0}
onChange={(e) =>
setEditingVariant({
...editingVariant,
data: {
...editingVariant.data,
stock_quantity: Number(e.target.value),
},
})
}
className="w-20 rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</td>
<td className="whitespace-nowrap px-3 py-2">
<select
value={editingVariant.data.is_active ? 'active' : 'inactive'}
onChange={(e) =>
setEditingVariant({
...editingVariant,
data: {
...editingVariant.data,
is_active: e.target.value === 'active',
},
})
}
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</td>
<td className="whitespace-nowrap px-3 py-2 text-right">
<button
onClick={handleUpdate}
disabled={isLoading}
className="mr-2 text-green-600 hover:text-green-700"
>
<Save className="h-4 w-4" />
</button>
<button
onClick={() => setEditingVariant(null)}
className="text-gray-600 hover:text-gray-700"
>
<X className="h-4 w-4" />
</button>
</td>
</tr>
) : (
<tr key={variant.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="whitespace-nowrap px-3 py-3">
<div>
<p className="font-medium text-gray-900 dark:text-white">
{variant.name || 'Default'}
</p>
<p className="text-xs text-gray-500">{variant.sku || 'No SKU'}</p>
</div>
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-900 dark:text-white">
{variant.price ? `$${variant.price.toFixed(2)}` : 'Base price'}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-500">
{variant.stock_quantity}
</td>
<td className="whitespace-nowrap px-3 py-3">
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
variant.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{variant.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="whitespace-nowrap px-3 py-3 text-right">
{onUpdate && (
<button
onClick={() => startEditing(variant)}
className="mr-2 text-gray-600 hover:text-blue-600"
>
<Edit className="h-4 w-4" />
</button>
)}
<button
onClick={() => onDelete(variant.id)}
className="text-gray-600 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
)}
{/* Add Form */}
{showAddForm && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
<h4 className="mb-3 font-medium text-gray-900 dark:text-white">Add New Variant</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">Name</label>
<input
type="text"
value={newVariant.name || ''}
onChange={(e) => setNewVariant({ ...newVariant, name: e.target.value })}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., Large, Red"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">SKU</label>
<input
type="text"
value={newVariant.sku || ''}
onChange={(e) => setNewVariant({ ...newVariant, sku: e.target.value })}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="PROD-VAR-001"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">
Price Override
</label>
<input
type="number"
step="0.01"
value={newVariant.price || ''}
onChange={(e) =>
setNewVariant({
...newVariant,
price: e.target.value ? Number(e.target.value) : undefined,
})
}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0.00"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-600 dark:text-gray-400">Stock</label>
<input
type="number"
value={newVariant.stock_quantity || 0}
onChange={(e) =>
setNewVariant({ ...newVariant, stock_quantity: Number(e.target.value) })
}
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => {
setShowAddForm(false);
setNewVariant({ attributes: {}, is_active: true });
}}
className="rounded px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={isLoading}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Variant'}
</button>
</div>
</div>
)}
{/* Add Button */}
{!showAddForm && (
<button
onClick={() => setShowAddForm(true)}
className="inline-flex items-center gap-2 rounded-lg border border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 hover:border-blue-500 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-500 dark:hover:text-blue-400"
>
<Plus className="h-4 w-4" />
Add Variant
</button>
)}
</div>
);
}

View File

@ -0,0 +1,6 @@
export { CategoryTree } from './CategoryTree';
export { CategoryForm } from './CategoryForm';
export { ProductCard } from './ProductCard';
export { ProductFilters } from './ProductFilters';
export { VariantList } from './VariantList';
export { PriceTable } from './PriceTable';

View File

@ -22,6 +22,7 @@ import {
Target,
Network,
Key,
Package,
} from 'lucide-react';
import { useState } from 'react';
import clsx from 'clsx';
@ -32,6 +33,7 @@ const navigation = [
{ name: 'AI Assistant', href: '/dashboard/ai', icon: Bot },
{ name: 'Goals', href: '/dashboard/goals', icon: Target },
{ name: 'MLM', href: '/dashboard/mlm', icon: Network },
{ name: 'Portfolio', href: '/dashboard/portfolio', icon: Package },
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
{ name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag },

View File

@ -0,0 +1,290 @@
import { useState } from 'react';
import {
useGoalAssignments,
useDeleteGoalAssignment,
useUpdateAssignmentStatus,
} from '@/hooks/useGoals';
import type { AssignmentFilters, Assignment, AssignmentStatus } from '@/services/goals';
import { GoalFilters } from '@/components/goals';
export default function AssignmentsPage() {
const [filters, setFilters] = useState<AssignmentFilters>({ page: 1, limit: 20 });
const { data, isLoading, error } = useGoalAssignments(filters);
const deleteAssignment = useDeleteGoalAssignment();
const updateStatus = useUpdateAssignmentStatus();
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this assignment?')) {
await deleteAssignment.mutateAsync(id);
}
};
const handleStatusChange = async (id: string, status: AssignmentStatus) => {
await updateStatus.mutateAsync({ id, status });
};
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 assignments</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();
};
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">Assignments</span>
</li>
</ol>
</nav>
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Goal Assignments</h1>
</div>
{/* Filters */}
<GoalFilters
type="assignments"
filters={filters}
onFilterChange={(newFilters) => setFilters(newFilters as AssignmentFilters)}
/>
{/* Summary Stats */}
{data && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div className="bg-white shadow rounded-lg p-4">
<div className="text-2xl font-bold text-gray-900">{data.total}</div>
<div className="text-sm text-gray-500">Total Assignments</div>
</div>
<div className="bg-white shadow rounded-lg p-4">
<div className="text-2xl font-bold text-blue-600">
{data.items?.filter((a) => a.status === 'active').length || 0}
</div>
<div className="text-sm text-gray-500">Active</div>
</div>
<div className="bg-white shadow rounded-lg p-4">
<div className="text-2xl font-bold text-green-600">
{data.items?.filter((a) => a.status === 'achieved').length || 0}
</div>
<div className="text-sm text-gray-500">Achieved</div>
</div>
<div className="bg-white shadow rounded-lg p-4">
<div className="text-2xl font-bold text-purple-600">
{data.items
? (data.items.reduce((sum, a) => sum + a.progressPercentage, 0) / (data.items.length || 1)).toFixed(1)
: 0}%
</div>
<div className="text-sm text-gray-500">Avg Progress</div>
</div>
</div>
)}
{/* Assignments 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">Goal</th>
<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">Duration</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((assignment: Assignment) => {
const target = assignment.customTarget ?? assignment.definition?.targetValue ?? 0;
const unit = assignment.definition?.unit || '';
const userName = assignment.user
? `${assignment.user.firstName || ''} ${assignment.user.lastName || ''}`.trim() || assignment.user.email
: 'Unknown User';
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.definition?.name || 'Unknown Goal'}
</div>
<div className="text-xs text-gray-500">
Target: {target.toLocaleString()} {unit}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{userName}</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()} {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 text-sm text-gray-500">
<div>{formatDate(assignment.definition?.startsAt || null)}</div>
<div className="text-xs">to {formatDate(assignment.definition?.endsAt || null)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={assignment.status}
onChange={(e) => handleStatusChange(assignment.id, e.target.value as AssignmentStatus)}
disabled={updateStatus.isPending}
className={`px-2 py-1 text-xs 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>
</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/assignments/${assignment.id}`}
className="text-blue-600 hover:text-blue-900"
>
View
</a>
<button
onClick={() => handleDelete(assignment.id)}
disabled={deleteAssignment.isPending}
className="text-red-600 hover:text-red-900"
>
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="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 found</h3>
<p className="mt-1 text-sm text-gray-500">Assign goals to users from the goal definitions page.</p>
<div className="mt-6">
<a
href="/dashboard/goals/definitions"
className="text-blue-600 hover:text-blue-900"
>
View Goal Definitions
</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>
)}
{/* 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/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/reports"
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">Reports</span>
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
import { useParams, useNavigate } from 'react-router-dom';
import {
useGoalDefinition,
useCreateGoalDefinition,
useUpdateGoalDefinition,
} from '@/hooks/useGoals';
import type { CreateDefinitionDto } from '@/services/goals';
import { GoalForm } from '@/components/goals';
export default function DefinitionFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEditMode = !!id;
const { data: existingGoal, isLoading: loadingGoal, error: loadError } = useGoalDefinition(id || '');
const createGoal = useCreateGoalDefinition();
const updateGoal = useUpdateGoalDefinition();
const handleSubmit = async (data: CreateDefinitionDto) => {
try {
if (isEditMode && id) {
await updateGoal.mutateAsync({ id, data });
navigate(`/dashboard/goals/definitions/${id}`);
} else {
const result = await createGoal.mutateAsync(data);
navigate(`/dashboard/goals/definitions/${result.id}`);
}
} catch (error) {
console.error('Error saving goal:', error);
}
};
const handleCancel = () => {
if (isEditMode && id) {
navigate(`/dashboard/goals/definitions/${id}`);
} else {
navigate('/dashboard/goals/definitions');
}
};
if (isEditMode && loadingGoal) {
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 (isEditMode && loadError) {
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>
);
}
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">{isEditMode ? 'Edit' : 'New'}</span>
</li>
</ol>
</nav>
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">
{isEditMode ? 'Edit Goal Definition' : 'Create Goal Definition'}
</h1>
</div>
{/* Form */}
<GoalForm
initialData={existingGoal}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={createGoal.isPending || updateGoal.isPending}
/>
</div>
);
}

View File

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

View File

@ -0,0 +1,386 @@
import { useState } from 'react';
import { useNodes, useStructures, useUpdateNodeStatus } from '@/hooks/useMlm';
import type { NodeFilters, NodeStatus, Node } from '@/services/mlm';
export default function NodesPage() {
const [filters, setFilters] = useState<NodeFilters>({ limit: 20 });
const [page, setPage] = useState(1);
const { data: nodesData, isLoading, error } = useNodes({ ...filters, page });
const { data: structures } = useStructures({ isActive: true });
const updateStatus = useUpdateNodeStatus();
const handleStatusChange = async (nodeId: string, status: NodeStatus) => {
await updateStatus.mutateAsync({ id: nodeId, status });
};
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 nodes</p>
</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 nodes = nodesData?.items || [];
const total = nodesData?.total || 0;
const totalPages = Math.ceil(total / (filters.limit || 20));
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">All Nodes</li>
</ol>
</nav>
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-semibold text-gray-900">MLM Nodes</h1>
<p className="mt-1 text-sm text-gray-500">
{total} total members across all structures
</p>
</div>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<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 }));
setPage(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 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.status || ''}
onChange={(e) => {
setFilters((prev) => ({ ...prev, status: (e.target.value || undefined) as NodeStatus }));
setPage(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="pending">Pending</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Min Depth</label>
<input
type="number"
min={0}
value={filters.minDepth ?? ''}
onChange={(e) => {
setFilters((prev) => ({ ...prev, minDepth: e.target.value ? Number(e.target.value) : undefined }));
setPage(1);
}}
placeholder="Any"
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">Max Depth</label>
<input
type="number"
min={0}
value={filters.maxDepth ?? ''}
onChange={(e) => {
setFilters((prev) => ({ ...prev, maxDepth: e.target.value ? Number(e.target.value) : undefined }));
setPage(1);
}}
placeholder="Any"
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 }));
setPage(1);
}}
placeholder="Name or email..."
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>
{/* Nodes Table */}
<div className="bg-white shadow rounded-lg overflow-hidden">
<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">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">Referrals</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Earnings</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.map((node: 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-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium"
style={{ backgroundColor: node.rank?.color || '#6B7280' }}
>
{node.user?.firstName?.charAt(0) || node.user?.email?.charAt(0) || '?'}
</div>
<div className="ml-4">
<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">
{node.rank ? (
<div className="flex items-center">
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-bold mr-2"
style={{ backgroundColor: node.rank.color || '#6B7280' }}
>
{node.rank.level}
</div>
<span className="text-sm text-gray-900">{node.rank.name}</span>
</div>
) : (
<span className="text-sm text-gray-400">No Rank</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900">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">
<div className="text-sm">
<div className="text-gray-900">{node.directReferrals} direct</div>
<div className="text-xs text-gray-500">{node.totalDownline} total</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-green-600">
${node.totalEarnings.toLocaleString()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={node.status}
onChange={(e) => handleStatusChange(node.id, e.target.value as NodeStatus)}
disabled={updateStatus.isPending}
className={`text-xs font-semibold rounded-full px-2 py-1 border-0 ${getStatusColor(node.status)}`}
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
</select>
</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 Details
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Empty State */}
{nodes.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="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">No nodes found matching your filters</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-500">
Showing {(page - 1) * (filters.limit || 20) + 1} to{' '}
{Math.min(page * (filters.limit || 20), total)} of {total} results
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-700">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
</div>
{/* Summary Stats */}
<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 Members</dt>
<dd className="text-lg font-semibold text-gray-900">{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</dt>
<dd className="text-lg font-semibold text-gray-900">
{nodes.filter((n) => n.status === 'active').length}
</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-gray-900">
{nodes.filter((n) => n.status === 'pending').length}
</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="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>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Structures</dt>
<dd className="text-lg font-semibold text-gray-900">{structures?.length || 0}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,419 @@
import { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { FolderTree, Package, Plus, Edit, Trash2, ArrowLeft, Save, X } from 'lucide-react';
import {
useCategory,
useProducts,
useUpdateCategory,
useDeleteCategory,
useCreateCategory,
} from '@/hooks/usePortfolio';
import type { UpdateCategoryDto, CreateCategoryDto } from '@/services/portfolio';
export default function CategoryDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isNew = id === 'new';
const { data: category, isLoading: loadingCategory } = useCategory(isNew ? '' : (id || ''));
const { data: products, isLoading: loadingProducts } = useProducts(
isNew ? undefined : { category_id: id, limit: 20 }
);
const updateMutation = useUpdateCategory();
const deleteMutation = useDeleteCategory();
const createMutation = useCreateCategory();
const [isEditing, setIsEditing] = useState(isNew);
const [formData, setFormData] = useState<CreateCategoryDto | UpdateCategoryDto>({
name: '',
slug: '',
description: '',
color: '#3b82f6',
is_active: true,
});
// Update form when category loads
useState(() => {
if (category && !isNew) {
setFormData({
name: category.name,
slug: category.slug,
description: category.description || '',
color: category.color || '#3b82f6',
is_active: category.is_active,
parent_id: category.parent_id || undefined,
icon: category.icon || undefined,
meta_title: category.meta_title || undefined,
meta_description: category.meta_description || undefined,
});
}
});
const handleSave = async () => {
try {
if (isNew) {
const newCategory = await createMutation.mutateAsync(formData as CreateCategoryDto);
navigate(`/dashboard/portfolio/categories/${newCategory.id}`);
} else if (id) {
await updateMutation.mutateAsync({ id, data: formData as UpdateCategoryDto });
setIsEditing(false);
}
} catch (error) {
console.error('Failed to save category:', error);
}
};
const handleDelete = async () => {
if (!id || isNew) return;
if (window.confirm('Are you sure you want to delete this category? Products in this category will be uncategorized.')) {
await deleteMutation.mutateAsync(id);
navigate('/dashboard/portfolio/categories');
}
};
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
if (!isNew && loadingCategory) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
);
}
if (!isNew && !category) {
return (
<div className="text-center py-12">
<FolderTree className="mx-auto h-12 w-12 text-gray-300" />
<p className="mt-4 text-gray-500">Category not found</p>
<Link to="/dashboard/portfolio/categories" className="mt-2 text-blue-600 hover:text-blue-700">
Back to categories
</Link>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/dashboard/portfolio/categories"
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ArrowLeft className="h-5 w-5 text-gray-500" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{isNew ? 'New Category' : (isEditing ? 'Edit Category' : category?.name)}
</h1>
{!isNew && !isEditing && (
<p className="text-gray-500 dark:text-gray-400">
{category?.description || 'No description'}
</p>
)}
</div>
</div>
<div className="flex gap-2">
{!isNew && !isEditing && (
<>
<button
onClick={() => setIsEditing(true)}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Edit className="h-4 w-4" />
Edit
</button>
<button
onClick={handleDelete}
className="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-600 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</>
)}
{(isNew || isEditing) && (
<>
<button
onClick={() => isNew ? navigate('/dashboard/portfolio/categories') : setIsEditing(false)}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<X className="h-4 w-4" />
Cancel
</button>
<button
onClick={handleSave}
disabled={createMutation.isPending || updateMutation.isPending || !formData.name}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
<Save className="h-4 w-4" />
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : 'Save'}
</button>
</>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Category Form / Details */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Category Information
</h2>
{isEditing || isNew ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name *
</label>
<input
type="text"
value={formData.name || ''}
onChange={(e) => {
const name = e.target.value;
setFormData({
...formData,
name,
slug: isNew ? generateSlug(name) : formData.slug,
});
}}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Category name"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Slug *
</label>
<input
type="text"
value={formData.slug || ''}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="category-slug"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Category description..."
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Color
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="h-10 w-10 cursor-pointer rounded border border-gray-300"
/>
<input
type="text"
value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Status
</label>
<select
value={formData.is_active ? 'active' : 'inactive'}
onChange={(e) => setFormData({ ...formData, is_active: e.target.value === 'active' })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
</div>
) : (
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Slug</dt>
<dd className="mt-1 font-mono text-sm text-gray-900 dark:text-white">{category?.slug}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd className="mt-1">
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
category?.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{category?.is_active ? 'Active' : 'Inactive'}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Color</dt>
<dd className="mt-1 flex items-center gap-2">
<span
className="h-5 w-5 rounded"
style={{ backgroundColor: category?.color || '#3b82f6' }}
/>
<span className="font-mono text-sm text-gray-900 dark:text-white">
{category?.color || '#3b82f6'}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{category?.created_at ? new Date(category.created_at).toLocaleDateString() : '-'}
</dd>
</div>
</dl>
)}
</div>
{/* Products in Category */}
{!isNew && (
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Products ({products?.total || 0})
</h2>
<Link
to={`/dashboard/portfolio/products/new?category=${id}`}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
Add Product
</Link>
</div>
<div className="p-4">
{loadingProducts ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
) : products?.items?.length ? (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{products.items.map((product) => (
<li key={product.id}>
<Link
to={`/dashboard/portfolio/products/${product.id}`}
className="flex items-center justify-between py-3 hover:bg-gray-50 dark:hover:bg-gray-700 -mx-4 px-4"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<Package className="h-5 w-5 text-gray-400" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{product.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{product.sku || 'No SKU'} - ${product.base_price?.toFixed(2) || '0.00'}
</p>
</div>
</div>
<span className={`rounded-full px-2 py-1 text-xs font-medium ${
product.status === 'active'
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{product.status}
</span>
</Link>
</li>
))}
</ul>
) : (
<div className="py-8 text-center">
<Package className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-gray-500 dark:text-gray-400">
No products in this category yet.
</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{!isNew && (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Details</h3>
<dl className="space-y-3">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">ID</dt>
<dd className="mt-1 font-mono text-xs text-gray-900 dark:text-white">{category?.id}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Products</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{category?.product_count || 0}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Created</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{category?.created_at ? new Date(category.created_at).toLocaleDateString() : '-'}
</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Updated</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{category?.updated_at ? new Date(category.updated_at).toLocaleDateString() : '-'}
</dd>
</div>
</dl>
</div>
)}
{/* Quick Links */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Quick Links</h3>
<div className="space-y-2">
<Link
to="/dashboard/portfolio"
className="flex items-center gap-2 rounded-lg p-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<FolderTree className="h-4 w-4" />
Portfolio Dashboard
</Link>
<Link
to="/dashboard/portfolio/categories"
className="flex items-center gap-2 rounded-lg p-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<FolderTree className="h-4 w-4" />
All Categories
</Link>
<Link
to="/dashboard/portfolio/products"
className="flex items-center gap-2 rounded-lg p-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Package className="h-4 w-4" />
All Products
</Link>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,657 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import {
Package,
Edit,
Trash2,
ArrowLeft,
Copy,
FolderTree,
Plus,
DollarSign,
Boxes,
} from 'lucide-react';
import {
useProduct,
useDeleteProduct,
useDuplicateProduct,
useUpdateProductStatus,
useProductVariants,
useProductPrices,
useCreateVariant,
useDeleteVariant,
useCreatePrice,
useDeletePrice,
} from '@/hooks/usePortfolio';
import type { ProductStatus, CreateVariantDto, CreatePriceDto } from '@/services/portfolio';
export default function ProductDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: product, isLoading } = useProduct(id || '');
const { data: variants } = useProductVariants(id || '');
const { data: prices } = useProductPrices(id || '');
const deleteMutation = useDeleteProduct();
const duplicateMutation = useDuplicateProduct();
const updateStatusMutation = useUpdateProductStatus();
const createVariantMutation = useCreateVariant();
const deleteVariantMutation = useDeleteVariant();
const createPriceMutation = useCreatePrice();
const deletePriceMutation = useDeletePrice();
const [showVariantModal, setShowVariantModal] = useState(false);
const [showPriceModal, setShowPriceModal] = useState(false);
const [variantData, setVariantData] = useState<CreateVariantDto>({
attributes: {},
is_active: true,
});
const [priceData, setPriceData] = useState<CreatePriceDto>({
currency: 'USD',
amount: 0,
price_type: 'one_time',
is_active: true,
});
const handleDelete = async () => {
if (!id) return;
if (window.confirm('Are you sure you want to delete this product?')) {
await deleteMutation.mutateAsync(id);
navigate('/dashboard/portfolio/products');
}
};
const handleDuplicate = async () => {
if (!id) return;
const newProduct = await duplicateMutation.mutateAsync(id);
navigate(`/dashboard/portfolio/products/${newProduct.id}`);
};
const handleStatusChange = async (newStatus: ProductStatus) => {
if (!id) return;
await updateStatusMutation.mutateAsync({ id, data: { status: newStatus } });
};
const handleCreateVariant = async () => {
if (!id) return;
await createVariantMutation.mutateAsync({ productId: id, data: variantData });
setShowVariantModal(false);
setVariantData({ attributes: {}, is_active: true });
};
const handleDeleteVariant = async (variantId: string) => {
if (!id) return;
if (window.confirm('Are you sure you want to delete this variant?')) {
await deleteVariantMutation.mutateAsync({ productId: id, variantId });
}
};
const handleCreatePrice = async () => {
if (!id) return;
await createPriceMutation.mutateAsync({ productId: id, data: priceData });
setShowPriceModal(false);
setPriceData({ currency: 'USD', amount: 0, price_type: 'one_time', is_active: true });
};
const handleDeletePrice = async (priceId: string) => {
if (!id) return;
if (window.confirm('Are you sure you want to delete this price?')) {
await deletePriceMutation.mutateAsync({ productId: id, priceId });
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
draft: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
active: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400',
discontinued: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
out_of_stock: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
);
}
if (!product) {
return (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-gray-300" />
<p className="mt-4 text-gray-500">Product not found</p>
<Link to="/dashboard/portfolio/products" className="mt-2 text-blue-600 hover:text-blue-700">
Back to products
</Link>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/dashboard/portfolio/products"
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ArrowLeft className="h-5 w-5 text-gray-500" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{product.name}</h1>
<p className="text-gray-500 dark:text-gray-400">
{product.sku || 'No SKU'} - {product.product_type}
</p>
</div>
</div>
<div className="flex gap-2">
<select
value={product.status}
onChange={(e) => handleStatusChange(e.target.value as ProductStatus)}
disabled={updateStatusMutation.isPending}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="discontinued">Discontinued</option>
<option value="out_of_stock">Out of Stock</option>
</select>
<button
onClick={handleDuplicate}
disabled={duplicateMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Copy className="h-4 w-4" />
Duplicate
</button>
<Link
to={`/dashboard/portfolio/products/${id}/edit`}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Edit className="h-4 w-4" />
Edit
</Link>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-600 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Product Info */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Product Information
</h2>
<span className={`rounded-full px-3 py-1 text-sm font-medium ${getStatusColor(product.status)}`}>
{product.status}
</span>
</div>
<dl className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Type</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white capitalize">{product.product_type}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">SKU</dt>
<dd className="mt-1 font-mono text-sm text-gray-900 dark:text-white">{product.sku || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Base Price</dt>
<dd className="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
${product.base_price?.toFixed(2) || '0.00'} {product.currency}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Category</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{product.category ? (
<Link
to={`/dashboard/portfolio/categories/${product.category.id}`}
className="text-blue-600 hover:text-blue-700"
>
{product.category.name}
</Link>
) : (
'Uncategorized'
)}
</dd>
</div>
<div className="col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Description</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{product.description || 'No description'}
</dd>
</div>
{product.tags && product.tags.length > 0 && (
<div className="col-span-2">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">Tags</dt>
<dd className="mt-1 flex flex-wrap gap-2">
{product.tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-700 dark:text-gray-300"
>
{tag}
</span>
))}
</dd>
</div>
)}
</dl>
</div>
{/* Variants */}
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center gap-2">
<Boxes className="h-5 w-5 text-gray-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Variants ({variants?.length || 0})
</h2>
</div>
<button
onClick={() => setShowVariantModal(true)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
Add Variant
</button>
</div>
<div className="p-4">
{variants?.length ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Name/SKU</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Price</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Stock</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Status</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{variants.map((variant) => (
<tr key={variant.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="whitespace-nowrap px-3 py-3">
<div>
<p className="font-medium text-gray-900 dark:text-white">
{variant.name || 'Default'}
</p>
<p className="text-xs text-gray-500">{variant.sku || 'No SKU'}</p>
</div>
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-900 dark:text-white">
{variant.price ? `$${variant.price.toFixed(2)}` : 'Base price'}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-500">
{variant.stock_quantity}
</td>
<td className="whitespace-nowrap px-3 py-3">
<span className={`rounded-full px-2 py-1 text-xs font-medium ${
variant.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{variant.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="whitespace-nowrap px-3 py-3 text-right">
<button
onClick={() => handleDeleteVariant(variant.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-8 text-center">
<Boxes className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-gray-500 dark:text-gray-400">No variants yet.</p>
</div>
)}
</div>
</div>
{/* Prices */}
<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-gray-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Price Tiers ({prices?.length || 0})
</h2>
</div>
<button
onClick={() => setShowPriceModal(true)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<Plus className="h-4 w-4" />
Add Price
</button>
</div>
<div className="p-4">
{prices?.length ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Type</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Amount</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Min Qty</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Status</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{prices.map((price) => (
<tr key={price.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="whitespace-nowrap px-3 py-3 text-sm capitalize text-gray-900 dark:text-white">
{price.price_type.replace('_', ' ')}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm font-medium text-gray-900 dark:text-white">
${price.amount.toFixed(2)} {price.currency}
</td>
<td className="whitespace-nowrap px-3 py-3 text-sm text-gray-500">
{price.min_quantity}
</td>
<td className="whitespace-nowrap px-3 py-3">
<span className={`rounded-full px-2 py-1 text-xs font-medium ${
price.is_active
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}>
{price.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="whitespace-nowrap px-3 py-3 text-right">
<button
onClick={() => handleDeletePrice(price.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-8 text-center">
<DollarSign className="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="mt-4 text-gray-500 dark:text-gray-400">No price tiers yet.</p>
</div>
)}
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Product Details */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Details</h3>
<dl className="space-y-3">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">ID</dt>
<dd className="mt-1 font-mono text-xs text-gray-900 dark:text-white">{product.id}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Barcode</dt>
<dd className="mt-1 font-mono text-xs text-gray-900 dark:text-white">{product.barcode || '-'}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Visibility</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{product.is_visible ? 'Visible' : 'Hidden'}
</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Featured</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{product.is_featured ? 'Yes' : 'No'}
</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Created</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{new Date(product.created_at).toLocaleDateString()}
</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Updated</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{new Date(product.updated_at).toLocaleDateString()}
</dd>
</div>
</dl>
</div>
{/* Inventory */}
{product.track_inventory && (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Inventory</h3>
<dl className="space-y-3">
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Stock</dt>
<dd className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{product.stock_quantity}
</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Low Stock Alert</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">{product.low_stock_threshold}</dd>
</div>
<div>
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">Backorders</dt>
<dd className="mt-1 text-sm text-gray-900 dark:text-white">
{product.allow_backorder ? 'Allowed' : 'Not allowed'}
</dd>
</div>
</dl>
</div>
)}
{/* Quick Links */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Quick Links</h3>
<div className="space-y-2">
<Link
to="/dashboard/portfolio"
className="flex items-center gap-2 rounded-lg p-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<FolderTree className="h-4 w-4" />
Portfolio Dashboard
</Link>
<Link
to="/dashboard/portfolio/products"
className="flex items-center gap-2 rounded-lg p-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Package className="h-4 w-4" />
All Products
</Link>
</div>
</div>
</div>
</div>
{/* Add Variant Modal */}
{showVariantModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Variant</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</label>
<input
type="text"
value={variantData.name || ''}
onChange={(e) => setVariantData({ ...variantData, name: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="e.g., Large, Red"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
SKU
</label>
<input
type="text"
value={variantData.sku || ''}
onChange={(e) => setVariantData({ ...variantData, sku: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="PROD-VAR-001"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Price Override
</label>
<input
type="number"
step="0.01"
value={variantData.price || ''}
onChange={(e) => setVariantData({ ...variantData, price: e.target.value ? Number(e.target.value) : undefined })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="0.00"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Stock
</label>
<input
type="number"
value={variantData.stock_quantity || 0}
onChange={(e) => setVariantData({ ...variantData, stock_quantity: Number(e.target.value) })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowVariantModal(false)}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleCreateVariant}
disabled={createVariantMutation.isPending}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{createVariantMutation.isPending ? 'Adding...' : 'Add Variant'}
</button>
</div>
</div>
</div>
)}
{/* Add Price Modal */}
{showPriceModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Add Price Tier</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Price Type
</label>
<select
value={priceData.price_type}
onChange={(e) => setPriceData({ ...priceData, price_type: e.target.value as CreatePriceDto['price_type'] })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="one_time">One Time</option>
<option value="recurring">Recurring</option>
<option value="usage_based">Usage Based</option>
<option value="tiered">Tiered</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Amount
</label>
<input
type="number"
step="0.01"
value={priceData.amount}
onChange={(e) => setPriceData({ ...priceData, amount: Number(e.target.value) })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Currency
</label>
<select
value={priceData.currency}
onChange={(e) => setPriceData({ ...priceData, currency: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="MXN">MXN</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Minimum Quantity
</label>
<input
type="number"
value={priceData.min_quantity || 1}
onChange={(e) => setPriceData({ ...priceData, min_quantity: Number(e.target.value) })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowPriceModal(false)}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleCreatePrice}
disabled={createPriceMutation.isPending}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{createPriceMutation.isPending ? 'Adding...' : 'Add Price'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,653 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
import { ArrowLeft, Save, Package } from 'lucide-react';
import { useProduct, useCreateProduct, useUpdateProduct, useCategories } from '@/hooks/usePortfolio';
import type { CreateProductDto, UpdateProductDto, ProductType, ProductStatus } from '@/services/portfolio';
export default function ProductFormPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isNew = !id;
const { data: product, isLoading: loadingProduct } = useProduct(id || '');
const { data: categories } = useCategories({ limit: 100 });
const createMutation = useCreateProduct();
const updateMutation = useUpdateProduct();
const initialCategoryId = searchParams.get('category');
const [formData, setFormData] = useState<CreateProductDto>({
name: '',
slug: '',
product_type: 'physical',
status: 'draft',
base_price: 0,
currency: 'USD',
is_visible: true,
is_featured: false,
track_inventory: true,
stock_quantity: 0,
low_stock_threshold: 10,
allow_backorder: false,
category_id: initialCategoryId || undefined,
});
const [activeTab, setActiveTab] = useState<'general' | 'inventory' | 'seo'>('general');
useEffect(() => {
if (product && !isNew) {
setFormData({
name: product.name,
slug: product.slug,
sku: product.sku || undefined,
barcode: product.barcode || undefined,
description: product.description || undefined,
short_description: product.short_description || undefined,
product_type: product.product_type,
status: product.status,
base_price: product.base_price,
cost_price: product.cost_price || undefined,
compare_at_price: product.compare_at_price || undefined,
currency: product.currency,
track_inventory: product.track_inventory,
stock_quantity: product.stock_quantity,
low_stock_threshold: product.low_stock_threshold,
allow_backorder: product.allow_backorder,
weight: product.weight || undefined,
weight_unit: product.weight_unit,
length: product.length || undefined,
width: product.width || undefined,
height: product.height || undefined,
dimension_unit: product.dimension_unit,
meta_title: product.meta_title || undefined,
meta_description: product.meta_description || undefined,
tags: product.tags,
is_visible: product.is_visible,
is_featured: product.is_featured,
category_id: product.category_id || undefined,
});
}
}, [product, isNew]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isNew) {
const newProduct = await createMutation.mutateAsync(formData);
navigate(`/dashboard/portfolio/products/${newProduct.id}`);
} else if (id) {
await updateMutation.mutateAsync({ id, data: formData as UpdateProductDto });
navigate(`/dashboard/portfolio/products/${id}`);
}
} catch (error) {
console.error('Failed to save product:', error);
}
};
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
const isLoading = !isNew && loadingProduct;
const isSaving = createMutation.isPending || updateMutation.isPending;
const isValid = formData.name && formData.slug;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/dashboard/portfolio/products"
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ArrowLeft className="h-5 w-5 text-gray-500" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{isNew ? 'New Product' : 'Edit Product'}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{isNew ? 'Create a new product in your catalog' : `Editing: ${product?.name}`}
</p>
</div>
</div>
<div className="flex gap-2">
<Link
to="/dashboard/portfolio/products"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</Link>
<button
onClick={handleSubmit}
disabled={isSaving || !isValid}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Save Product'}
</button>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-4">
{(['general', 'inventory', 'seo'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`border-b-2 px-4 py-2 text-sm font-medium capitalize transition-colors ${
activeTab === tab
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
{tab}
</button>
))}
</nav>
</div>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* General Tab */}
{activeTab === 'general' && (
<>
{/* Basic Info */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Basic Information
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => {
const name = e.target.value;
setFormData({
...formData,
name,
slug: isNew ? generateSlug(name) : formData.slug,
});
}}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Product name"
required
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Slug *
</label>
<input
type="text"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="product-slug"
required
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Short Description
</label>
<input
type="text"
value={formData.short_description || ''}
onChange={(e) => setFormData({ ...formData, short_description: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Brief product summary"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Detailed product description..."
/>
</div>
</div>
</div>
{/* Pricing */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Pricing</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Base Price *
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
min="0"
value={formData.base_price}
onChange={(e) => setFormData({ ...formData, base_price: Number(e.target.value) })}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-7 pr-3 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
required
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Cost Price
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
min="0"
value={formData.cost_price || ''}
onChange={(e) => setFormData({ ...formData, cost_price: e.target.value ? Number(e.target.value) : undefined })}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-7 pr-3 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Compare at Price
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
step="0.01"
min="0"
value={formData.compare_at_price || ''}
onChange={(e) => setFormData({ ...formData, compare_at_price: e.target.value ? Number(e.target.value) : undefined })}
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-7 pr-3 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Original price"
/>
</div>
</div>
</div>
</div>
{/* Identifiers */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Identifiers</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
SKU
</label>
<input
type="text"
value={formData.sku || ''}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="PROD-001"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Barcode
</label>
<input
type="text"
value={formData.barcode || ''}
onChange={(e) => setFormData({ ...formData, barcode: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="123456789012"
/>
</div>
</div>
</div>
</>
)}
{/* Inventory Tab */}
{activeTab === 'inventory' && (
<>
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Inventory Settings
</h2>
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="track_inventory"
checked={formData.track_inventory}
onChange={(e) => setFormData({ ...formData, track_inventory: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="track_inventory" className="text-sm text-gray-700 dark:text-gray-300">
Track inventory for this product
</label>
</div>
{formData.track_inventory && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Stock Quantity
</label>
<input
type="number"
min="0"
value={formData.stock_quantity}
onChange={(e) => setFormData({ ...formData, stock_quantity: Number(e.target.value) })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Low Stock Threshold
</label>
<input
type="number"
min="0"
value={formData.low_stock_threshold}
onChange={(e) => setFormData({ ...formData, low_stock_threshold: Number(e.target.value) })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div className="flex items-end">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="allow_backorder"
checked={formData.allow_backorder}
onChange={(e) => setFormData({ ...formData, allow_backorder: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="allow_backorder" className="text-sm text-gray-700 dark:text-gray-300">
Allow backorders
</label>
</div>
</div>
</div>
)}
</div>
</div>
{/* Shipping */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Shipping</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Weight
</label>
<div className="flex gap-2">
<input
type="number"
step="0.01"
min="0"
value={formData.weight || ''}
onChange={(e) => setFormData({ ...formData, weight: e.target.value ? Number(e.target.value) : undefined })}
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<select
value={formData.weight_unit || 'kg'}
onChange={(e) => setFormData({ ...formData, weight_unit: e.target.value })}
className="w-20 rounded-lg border border-gray-300 bg-white px-2 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="kg">kg</option>
<option value="lb">lb</option>
<option value="oz">oz</option>
<option value="g">g</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Dimensions (L x W x H)
</label>
<div className="flex gap-2">
<input
type="number"
step="0.01"
min="0"
value={formData.length || ''}
onChange={(e) => setFormData({ ...formData, length: e.target.value ? Number(e.target.value) : undefined })}
className="flex-1 rounded-lg border border-gray-300 bg-white px-2 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="L"
/>
<input
type="number"
step="0.01"
min="0"
value={formData.width || ''}
onChange={(e) => setFormData({ ...formData, width: e.target.value ? Number(e.target.value) : undefined })}
className="flex-1 rounded-lg border border-gray-300 bg-white px-2 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="W"
/>
<input
type="number"
step="0.01"
min="0"
value={formData.height || ''}
onChange={(e) => setFormData({ ...formData, height: e.target.value ? Number(e.target.value) : undefined })}
className="flex-1 rounded-lg border border-gray-300 bg-white px-2 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="H"
/>
<select
value={formData.dimension_unit || 'cm'}
onChange={(e) => setFormData({ ...formData, dimension_unit: e.target.value })}
className="w-16 rounded-lg border border-gray-300 bg-white px-2 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="cm">cm</option>
<option value="in">in</option>
<option value="m">m</option>
</select>
</div>
</div>
</div>
</div>
</>
)}
{/* SEO Tab */}
{activeTab === 'seo' && (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
Search Engine Optimization
</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Meta Title
</label>
<input
type="text"
value={formData.meta_title || ''}
onChange={(e) => setFormData({ ...formData, meta_title: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="SEO title (defaults to product name)"
/>
<p className="mt-1 text-xs text-gray-500">
{(formData.meta_title || formData.name || '').length}/60 characters
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Meta Description
</label>
<textarea
value={formData.meta_description || ''}
onChange={(e) => setFormData({ ...formData, meta_description: e.target.value })}
rows={3}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="SEO description for search engines"
/>
<p className="mt-1 text-xs text-gray-500">
{(formData.meta_description || '').length}/160 characters
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Tags
</label>
<input
type="text"
value={formData.tags?.join(', ') || ''}
onChange={(e) => setFormData({
...formData,
tags: e.target.value.split(',').map(t => t.trim()).filter(Boolean),
})}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="tag1, tag2, tag3"
/>
<p className="mt-1 text-xs text-gray-500">Separate tags with commas</p>
</div>
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Status</h3>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Product Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProductStatus })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="discontinued">Discontinued</option>
<option value="out_of_stock">Out of Stock</option>
</select>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_visible"
checked={formData.is_visible}
onChange={(e) => setFormData({ ...formData, is_visible: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="is_visible" className="text-sm text-gray-700 dark:text-gray-300">
Visible in catalog
</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_featured"
checked={formData.is_featured}
onChange={(e) => setFormData({ ...formData, is_featured: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="is_featured" className="text-sm text-gray-700 dark:text-gray-300">
Featured product
</label>
</div>
</div>
</div>
{/* Organization */}
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Organization</h3>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Product Type
</label>
<select
value={formData.product_type}
onChange={(e) => setFormData({ ...formData, product_type: e.target.value as ProductType })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="physical">Physical</option>
<option value="digital">Digital</option>
<option value="service">Service</option>
<option value="subscription">Subscription</option>
<option value="bundle">Bundle</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Category
</label>
<select
value={formData.category_id || ''}
onChange={(e) => setFormData({ ...formData, category_id: e.target.value || undefined })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">Uncategorized</option>
{categories?.items?.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Currency
</label>
<select
value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="USD">USD - US Dollar</option>
<option value="EUR">EUR - Euro</option>
<option value="MXN">MXN - Mexican Peso</option>
</select>
</div>
</div>
</div>
{/* Quick Links */}
{!isNew && (
<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Quick Links</h3>
<div className="space-y-2">
<Link
to={`/dashboard/portfolio/products/${id}`}
className="flex items-center gap-2 rounded-lg p-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Package className="h-4 w-4" />
View Product
</Link>
</div>
</div>
)}
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,6 @@
export { default as PortfolioPage } from './PortfolioPage';
export { default as CategoriesPage } from './CategoriesPage';
export { default as CategoryDetailPage } from './CategoryDetailPage';
export { default as ProductsPage } from './ProductsPage';
export { default as ProductDetailPage } from './ProductDetailPage';
export { default as ProductFormPage } from './ProductFormPage';

View File

@ -49,8 +49,10 @@ const MyEarningsPage = lazy(() => import('@/pages/dashboard/commissions').then(m
// 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 DefinitionFormPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.DefinitionFormPage })));
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 GoalAssignmentsPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.AssignmentsPage })));
const AssignmentDetailPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.AssignmentDetailPage })));
const GoalReportsPage = lazy(() => import('@/pages/dashboard/goals').then(m => ({ default: m.ReportsPage })));
@ -58,6 +60,7 @@ const GoalReportsPage = lazy(() => import('@/pages/dashboard/goals').then(m => (
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 NodesPage = lazy(() => import('@/pages/dashboard/mlm').then(m => ({ default: m.NodesPage })));
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 })));
@ -68,6 +71,14 @@ const RolesPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ defau
const RoleDetailPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.RoleDetailPage })));
const PermissionsPage = lazy(() => import('@/pages/dashboard/rbac').then(m => ({ default: m.PermissionsPage })));
// Lazy loaded pages - Portfolio
const PortfolioPage = lazy(() => import('@/pages/dashboard/portfolio').then(m => ({ default: m.PortfolioPage })));
const CategoriesPage = lazy(() => import('@/pages/dashboard/portfolio').then(m => ({ default: m.CategoriesPage })));
const CategoryDetailPage = lazy(() => import('@/pages/dashboard/portfolio').then(m => ({ default: m.CategoryDetailPage })));
const ProductsPage = lazy(() => import('@/pages/dashboard/portfolio').then(m => ({ default: m.ProductsPage })));
const ProductDetailPage = lazy(() => import('@/pages/dashboard/portfolio').then(m => ({ default: m.ProductDetailPage })));
const ProductFormPage = lazy(() => import('@/pages/dashboard/portfolio').then(m => ({ default: m.ProductFormPage })));
// Lazy loaded pages - Notifications
const NotificationsPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.NotificationsPage })));
const NotificationTemplatesPage = lazy(() => import('@/pages/dashboard/notifications').then(m => ({ default: m.TemplatesPage })));
@ -193,18 +204,22 @@ export function AppRouter() {
{/* Goals routes */}
<Route path="goals" element={<SuspensePage><GoalsPage /></SuspensePage>} />
<Route path="goals/definitions" element={<SuspensePage><GoalDefinitionsPage /></SuspensePage>} />
<Route path="goals/definitions/new" element={<SuspensePage><DefinitionFormPage /></SuspensePage>} />
<Route path="goals/definitions/:id" element={<SuspensePage><GoalDetailPage /></SuspensePage>} />
<Route path="goals/my-goals" element={<SuspensePage><MyGoalsPage /></SuspensePage>} />
<Route path="goals/definitions/:id/edit" element={<SuspensePage><DefinitionFormPage /></SuspensePage>} />
<Route path="goals/assignments" element={<SuspensePage><GoalAssignmentsPage /></SuspensePage>} />
<Route path="goals/assignments/:id" element={<SuspensePage><AssignmentDetailPage /></SuspensePage>} />
<Route path="goals/my-goals" element={<SuspensePage><MyGoalsPage /></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/nodes" element={<SuspensePage><NodesPage /></SuspensePage>} />
<Route path="mlm/nodes/:id" element={<SuspensePage><NodeDetailPage /></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>} />
{/* RBAC routes */}
@ -217,6 +232,17 @@ export function AppRouter() {
{/* Notifications routes */}
<Route path="notifications" element={<SuspensePage><NotificationsPage /></SuspensePage>} />
<Route path="notifications/templates" element={<SuspensePage><NotificationTemplatesPage /></SuspensePage>} />
{/* Portfolio routes */}
<Route path="portfolio" element={<SuspensePage><PortfolioPage /></SuspensePage>} />
<Route path="portfolio/categories" element={<SuspensePage><CategoriesPage /></SuspensePage>} />
<Route path="portfolio/categories/new" element={<SuspensePage><CategoryDetailPage /></SuspensePage>} />
<Route path="portfolio/categories/:id" element={<SuspensePage><CategoryDetailPage /></SuspensePage>} />
<Route path="portfolio/categories/:id/edit" element={<SuspensePage><CategoryDetailPage /></SuspensePage>} />
<Route path="portfolio/products" element={<SuspensePage><ProductsPage /></SuspensePage>} />
<Route path="portfolio/products/new" element={<SuspensePage><ProductFormPage /></SuspensePage>} />
<Route path="portfolio/products/:id" element={<SuspensePage><ProductDetailPage /></SuspensePage>} />
<Route path="portfolio/products/:id/edit" element={<SuspensePage><ProductFormPage /></SuspensePage>} />
</Route>
{/* Superadmin routes */}