[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:
parent
193b26f6f1
commit
891689a4f4
193
src/components/goals/GoalAssignmentCard.tsx
Normal file
193
src/components/goals/GoalAssignmentCard.tsx
Normal 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;
|
||||
155
src/components/goals/GoalCard.tsx
Normal file
155
src/components/goals/GoalCard.tsx
Normal 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;
|
||||
155
src/components/goals/GoalFilters.tsx
Normal file
155
src/components/goals/GoalFilters.tsx
Normal 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;
|
||||
390
src/components/goals/GoalForm.tsx
Normal file
390
src/components/goals/GoalForm.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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;
|
||||
123
src/components/goals/GoalProgressBar.tsx
Normal file
123
src/components/goals/GoalProgressBar.tsx
Normal 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;
|
||||
123
src/components/goals/GoalsKPIGrid.tsx
Normal file
123
src/components/goals/GoalsKPIGrid.tsx
Normal 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;
|
||||
139
src/components/goals/ProgressLogForm.tsx
Normal file
139
src/components/goals/ProgressLogForm.tsx
Normal 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;
|
||||
79
src/components/goals/ProgressLogList.tsx
Normal file
79
src/components/goals/ProgressLogList.tsx
Normal 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;
|
||||
8
src/components/goals/index.ts
Normal file
8
src/components/goals/index.ts
Normal 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';
|
||||
106
src/components/mlm/CommissionsSummary.tsx
Normal file
106
src/components/mlm/CommissionsSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/mlm/DownlineList.tsx
Normal file
92
src/components/mlm/DownlineList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/mlm/MLMStatsCard.tsx
Normal file
44
src/components/mlm/MLMStatsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/mlm/NetworkTree.tsx
Normal file
107
src/components/mlm/NetworkTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/mlm/NodeCard.tsx
Normal file
97
src/components/mlm/NodeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/mlm/NodeStatusBadge.tsx
Normal file
24
src/components/mlm/NodeStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/mlm/RankBadge.tsx
Normal file
34
src/components/mlm/RankBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/mlm/StructureCard.tsx
Normal file
94
src/components/mlm/StructureCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/components/mlm/index.ts
Normal file
8
src/components/mlm/index.ts
Normal 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';
|
||||
199
src/components/portfolio/CategoryForm.tsx
Normal file
199
src/components/portfolio/CategoryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
src/components/portfolio/CategoryTree.tsx
Normal file
181
src/components/portfolio/CategoryTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
src/components/portfolio/PriceTable.tsx
Normal file
268
src/components/portfolio/PriceTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/portfolio/ProductCard.tsx
Normal file
135
src/components/portfolio/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/portfolio/ProductFilters.tsx
Normal file
120
src/components/portfolio/ProductFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src/components/portfolio/VariantList.tsx
Normal file
307
src/components/portfolio/VariantList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/portfolio/index.ts
Normal file
6
src/components/portfolio/index.ts
Normal 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';
|
||||
@ -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 },
|
||||
|
||||
290
src/pages/dashboard/goals/AssignmentsPage.tsx
Normal file
290
src/pages/dashboard/goals/AssignmentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/pages/dashboard/goals/DefinitionFormPage.tsx
Normal file
95
src/pages/dashboard/goals/DefinitionFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
386
src/pages/dashboard/mlm/NodesPage.tsx
Normal file
386
src/pages/dashboard/mlm/NodesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
419
src/pages/dashboard/portfolio/CategoryDetailPage.tsx
Normal file
419
src/pages/dashboard/portfolio/CategoryDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
657
src/pages/dashboard/portfolio/ProductDetailPage.tsx
Normal file
657
src/pages/dashboard/portfolio/ProductDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
653
src/pages/dashboard/portfolio/ProductFormPage.tsx
Normal file
653
src/pages/dashboard/portfolio/ProductFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/pages/dashboard/portfolio/index.ts
Normal file
6
src/pages/dashboard/portfolio/index.ts
Normal 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';
|
||||
@ -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 */}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user