diff --git a/src/components/goals/GoalAssignmentCard.tsx b/src/components/goals/GoalAssignmentCard.tsx new file mode 100644 index 0000000..59c4b8e --- /dev/null +++ b/src/components/goals/GoalAssignmentCard.tsx @@ -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 = { + 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 ( +
+
+
+ {goalName} + + {assignment.status} + +
+

{userName}

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

{goalName}

+

+ Assigned to: {userName} +

+ {assignment.customTarget && ( +

+ Custom target: {assignment.customTarget.toLocaleString()} {unit} +

+ )} +
+
+ {onStatusChange ? ( + + ) : ( + + {assignment.status} + + )} + {daysRemaining !== null && daysRemaining > 0 && ( + + {daysRemaining} days left + + )} + {daysRemaining !== null && daysRemaining <= 0 && ( + Ended + )} +
+
+ + {/* Progress Bar */} +
+ +
+ + {/* Duration Info */} +
+ Start: {formatDate(assignment.definition?.startsAt || null)} + End: {formatDate(assignment.definition?.endsAt || null)} +
+ + {assignment.notes && ( +
+ {assignment.notes} +
+ )} + + {/* Actions */} +
+ {assignment.lastUpdatedAt && ( + + Last updated: {formatDate(assignment.lastUpdatedAt)} + + )} +
+ {assignment.status === 'active' && onUpdateProgress && ( + + )} + + View Details + + {onDelete && ( + + )} +
+
+
+
+ ); +} + +export default GoalAssignmentCard; diff --git a/src/components/goals/GoalCard.tsx b/src/components/goals/GoalCard.tsx new file mode 100644 index 0000000..75fbd85 --- /dev/null +++ b/src/components/goals/GoalCard.tsx @@ -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 = { + draft: 'bg-gray-100 text-gray-800', + active: 'bg-green-100 text-green-800', + paused: 'bg-yellow-100 text-yellow-800', + completed: 'bg-blue-100 text-blue-800', + cancelled: 'bg-red-100 text-red-800', + }; + return colors[status] || 'bg-gray-100 text-gray-800'; + }; + + const getPeriodLabel = (period: PeriodType) => { + const labels: Record = { + daily: 'Daily', + weekly: 'Weekly', + monthly: 'Monthly', + quarterly: 'Quarterly', + yearly: 'Yearly', + custom: 'Custom', + }; + return labels[period] || period; + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString(); + }; + + return ( +
+
+
+

{goal.name}

+
+ {goal.type} + | + {getPeriodLabel(goal.period)} +
+
+ {onStatusChange ? ( + + ) : ( + + {goal.status} + + )} +
+ + {goal.description && ( +

{goal.description}

+ )} + +
+
+ Target + + {goal.targetValue.toLocaleString()} {goal.unit || ''} + +
+
+ Duration + + {formatDate(goal.startsAt)} - {formatDate(goal.endsAt)} + +
+
+ Assigned + {goal.assignmentCount ?? 0} users +
+
+ + {goal.category && ( +
+ + {goal.category} + +
+ )} + + {goal.milestones && goal.milestones.length > 0 && ( +
+
Milestones
+ +
+ )} + +
+ + View Details + +
+ {onDuplicate && ( + + )} + {onDelete && ( + + )} +
+
+
+ ); +} + +export default GoalCard; diff --git a/src/components/goals/GoalFilters.tsx b/src/components/goals/GoalFilters.tsx new file mode 100644 index 0000000..9f135c5 --- /dev/null +++ b/src/components/goals/GoalFilters.tsx @@ -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 ( +
+
+
+ + +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ ); + } + + // Assignment filters + const assignmentFilters = filters as AssignmentFilters; + return ( +
+
+
+ + +
+
+ + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ ); +} + +export default GoalFilters; diff --git a/src/components/goals/GoalForm.tsx b/src/components/goals/GoalForm.tsx new file mode 100644 index 0000000..775aa2e --- /dev/null +++ b/src/components/goals/GoalForm.tsx @@ -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({ + 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 ( +
+ {/* Basic Information */} +
+

Basic Information

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