template-saas-frontend-v2/src/components/goals/GoalForm.tsx
Adrian Flores Cortes 891689a4f4 [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>
2026-02-03 20:10:38 -06:00

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"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* Tags */}
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Tags</h3>
<div className="flex items-end gap-4 mb-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700">Tag Name</label>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="Press Enter to add"
/>
</div>
<button
type="button"
onClick={addTag}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Add Tag
</button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag: string) => (
<span
key={tag}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-gray-100 text-gray-800"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-2 text-gray-600 hover:text-gray-900"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : initialData ? 'Update Goal' : 'Create Goal'}
</button>
</div>
</form>
);
}
export default GoalForm;