## 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>
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
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;
|