← Tasks
[CP-SCREEN-01] VoterDetailScreen — view/edit individual voter with full history (retry 1)
completedcode_genP1
Description
## CP-SCREEN-01: VoterDetailScreen — view/edit individual voter with full history
Section: New Screen | Milestone: M3 | Owner: Claude Sonnet
### Failing Tests
- **detail_screen_file** (file_exists): File `campaignpro/src/screens/VoterDetailScreen.tsx` must exist
- **detail_edit_mode** (grep): Pattern `editing|isEditing|setEditing` in `campaignpro/src/screens/VoterDetailScreen.tsx` (need 1+ matches)
### Acceptance Criteria
- File `campaignpro/src/screens/VoterDetailScreen.tsx` must exist
- File matching `campaignpro/src/screens/VoterDetailScreen.tsx` must contain pattern `editing|isEditing|setEditing`
### Codebase Conventions
- Trilingual comments: // EN: ... // VI: ... // ES: ...
- SPEC section refs in comments
- Zod at module level with .safeParse()
- (supabase as any) with eslint-disable
- NextResponse.json() responses
- Error format: { error: 'message' }
### Dependencies
- CP-API-01: FAIL
- CP-WIRE-02: FAIL
Assignee
-
Claimed By
Cloud Lu → Claude Sonnet
Created
1d ago
Completed
1d ago
Quality
Overall
79%
Pass
PASS
Graded By
-
Graded At
-
Cost
Input Tokens
-
Output Tokens
-
Est. USD
-
Result
{
"cost": {
"input_tokens": 339,
"estimated_usd": 0.100242,
"output_tokens": 6615
},
"model": "claude-sonnet-4-20250514",
"status": "success",
"transport": "anthropic_api",
"latency_ms": 66899,
"response_text": "I'll create the VoterDetailScreen component with edit functionality and full voter history display.\n\n```typescript\n// campaignpro/src/screens/VoterDetailScreen.tsx\n// EN: Individual voter detail screen with edit capabilities and full history\n// VI: Màn hình chi tiết cử tri với khả năng chỉnh sửa và lịch sử đầy đủ\n// ES: Pantalla de detalle del votante con capacidades de edición e historial completo\n\n'use client';\n\nimport React, { useState, useEffect } from 'react';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Label } from '@/components/ui/label';\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Badge } from '@/components/ui/badge';\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';\nimport { Separator } from '@/components/ui/separator';\nimport { Avatar, AvatarFallback, AvatarInitials } from '@/components/ui/avatar';\nimport { Edit2, Save, X, Phone, Mail, MapPin, Calendar, User, FileText, Clock } from 'lucide-react';\nimport { toast } from '@/components/ui/use-toast';\nimport { z } from 'zod';\n\n// EN: SPEC [CP-API-01] - Voter data validation schema\n// VI: Lược đồ xác thực dữ liệu cử tri\n// ES: Esquema de validación de datos del votante\nconst VoterSchema = z.object({\n id: z.string(),\n firstName: z.string().min(1, 'First name required'),\n lastName: z.string().min(1, 'Last name required'),\n email: z.string().email().optional().or(z.literal('')),\n phone: z.string().optional(),\n address: z.string().optional(),\n city: z.string().optional(),\n state: z.string().optional(),\n zipCode: z.string().optional(),\n voterStatus: z.enum(['registered', 'unregistered', 'inactive']),\n partyAffiliation: z.enum(['democrat', 'republican', 'independent', 'other', 'unknown']),\n likelihood: z.enum(['strong_support', 'lean_support', 'undecided', 'lean_oppose', 'strong_oppose']),\n notes: z.string().optional(),\n tags: z.array(z.string()).optional(),\n});\n\n// EN: Contact history item schema\n// VI: Lược đồ mục lịch sử liên hệ\n// ES: Esquema de elemento del historial de contacto\nconst ContactHistorySchema = z.object({\n id: z.string(),\n date: z.string(),\n type: z.enum(['call', 'email', 'door_knock', 'event', 'mail']),\n outcome: z.enum(['contacted', 'no_answer', 'busy', 'refused', 'callback']),\n notes: z.string().optional(),\n volunteer: z.string(),\n});\n\ntype Voter = z.infer<typeof VoterSchema>;\ntype ContactHistory = z.infer<typeof ContactHistorySchema>;\n\ninterface VoterDetailScreenProps {\n voterId: string;\n onClose?: () => void;\n onUpdate?: (voter: Voter) => void;\n}\n\nexport default function VoterDetailScreen({ voterId, onClose, onUpdate }: VoterDetailScreenProps) {\n // EN: SPEC [CP-SCREEN-01] - Edit mode state management\n // VI: Quản lý trạng thái chế độ chỉnh sửa\n // ES: Gestión del estado del modo de edición\n const [isEditing, setIsEditing] = useState(false);\n const [editingData, setEditingData] = useState<Voter | null>(null);\n const [originalData, setOriginalData] = useState<Voter | null>(null);\n const [contactHistory, setContactHistory] = useState<ContactHistory[]>([]);\n const [loading, setLoading] = useState(true);\n const [saving, setSaving] = useState(false);\n\n // EN: Load voter data and history on mount\n // VI: Tải dữ liệu cử tri và lịch sử khi mount\n // ES: Cargar datos del votante e historial al montar\n useEffect(() => {\n loadVoterData();\n loadContactHistory();\n }, [voterId]);\n\n const loadVoterData = async () => {\n try {\n setLoading(true);\n // EN: SPEC [CP-API-01] - Fetch voter details\n // VI: Lấy chi tiết cử tri\n // ES: Obtener detalles del votante\n const response = await fetch(`/api/voters/${voterId}`);\n const data = await response.json();\n \n if (!response.ok) {\n throw new Error(data.error || 'Failed to load voter');\n }\n\n const result = VoterSchema.safeParse(data);\n if (!result.success) {\n throw new Error('Invalid voter data format');\n }\n\n setOriginalData(result.data);\n setEditingData(result.data);\n } catch (error) {\n console.error('Error loading voter:', error);\n toast({\n title: \"Error\",\n description: \"Failed to load voter details\",\n variant: \"destructive\",\n });\n } finally {\n setLoading(false);\n }\n };\n\n const loadContactHistory = async () => {\n try {\n // EN: SPEC [CP-API-01] - Fetch contact history\n // VI: Lấy lịch sử liên hệ\n // ES: Obtener historial de contacto\n const response = await fetch(`/api/voters/${voterId}/history`);\n const data = await response.json();\n \n if (response.ok) {\n setContactHistory(data);\n }\n } catch (error) {\n console.error('Error loading contact history:', error);\n }\n };\n\n const handleEdit = () => {\n setIsEditing(true);\n setEditingData(originalData);\n };\n\n const handleCancelEdit = () => {\n setIsEditing(false);\n setEditingData(originalData);\n };\n\n const handleSave = async () => {\n if (!editingData) return;\n\n // EN: Validate edited data\n // VI: Xác thực dữ liệu đã chỉnh sửa\n // ES: Validar datos editados\n const result = VoterSchema.safeParse(editingData);\n if (!result.success) {\n toast({\n title: \"Validation Error\",\n description: result.error.errors[0].message,\n variant: \"destructive\",\n });\n return;\n }\n\n try {\n setSaving(true);\n // EN: SPEC [CP-API-01] - Update voter data\n // VI: Cập nhật dữ liệu cử tri\n // ES: Actualizar datos del votante\n const response = await fetch(`/api/voters/${voterId}`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(result.data),\n });\n\n const responseData = await response.json();\n \n if (!response.ok) {\n throw new Error(responseData.error || 'Failed to update voter');\n }\n\n setOriginalData(result.data);\n setIsEditing(false);\n onUpdate?.(result.data);\n \n toast({\n title: \"Success\",\n description: \"Voter updated successfully\",\n });\n } catch (error) {\n console.error('Error saving voter:', error);\n toast({\n title: \"Error\",\n description: \"Failed to save changes\",\n variant: \"destructive\",\n });\n } finally {\n setSaving(false);\n }\n };\n\n const handleInputChange = (field: keyof Voter, value: any) => {\n if (!editingData) return;\n setEditingData({ ...editingData, [field]: value });\n };\n\n const formatContactType = (type: string) => {\n const types = {\n call: 'Phone Call',\n email: 'Email',\n door_knock: 'Door Knock',\n event: 'Event',\n mail: 'Mail'\n };\n return types[type as keyof typeof types] || type;\n };\n\n const formatOutcome = (outcome: string) => {\n const outcomes = {\n contacted: 'Contacted',\n no_answer: 'No Answer',\n busy: 'Busy',\n refused: 'Refused',\n callback: 'Callback Requested'\n };\n return outcomes[outcome as keyof typeof outcomes] || outcome;\n };\n\n const getLikelihoodColor = (likelihood: string) => {\n const colors = {\n strong_support: 'bg-green-100 text-green-800',\n lean_support: 'bg-green-50 text-green-700',\n undecided: 'bg-yellow-100 text-yellow-800',\n lean_oppose: 'bg-red-50 text-red-700',\n strong_oppose: 'bg-red-100 text-red-800'\n };\n return colors[likelihood as keyof typeof colors] || 'bg-gray-100 text-gray-800';\n };\n\n if (loading || !originalData) {\n return (\n <div className=\"flex items-center justify-center h-96\">\n <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600\"></div>\n </div>\n );\n }\n\n const displayData = isEditing ? editingData : originalData;\n if (!displayData) return null;\n\n return (\n <div className=\"max-w-4xl mx-auto p-6 space-y-6\">\n {/* EN: Header with voter basic info and actions */}\n {/* VI: Header với thông tin cơ bản và hành động */}\n {/* ES: Encabezado con información básica y acciones */}\n <Card>\n <CardHeader>\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center space-x-4\">\n <Avatar className=\"h-16 w-16\">\n <AvatarFallback>\n <AvatarInitials name={`${displayData.firstName} ${displayData.lastName}`} />\n </AvatarFallback>\n </Avatar>\n <div>\n <CardTitle className=\"text-2xl\">\n {displayData.firstName} {displayData.lastName}\n </CardTitle>\n <div className=\"flex items-center space-x-2 mt-2\">\n <Badge className={getLikelihoodColor(displayData.likelihood)}>\n {displayData.likelihood.replace('_', ' ')}\n </Badge>\n <Badge variant=\"outline\">\n {displayData.voterStatus}\n </Badge>\n <Badge variant=\"outline\">\n {displayData.partyAffiliation}\n </Badge>\n </div>\n </div>\n </div>\n <div className=\"flex items-center space-x-2\">\n {!isEditing ? (\n <>\n <Button onClick={handleEdit} size=\"sm\">\n <Edit2 className=\"h-4 w-4 mr-2\" />\n Edit\n </Button>\n {onClose && (\n <Button onClick={onClose} variant=\"outline\" size=\"sm\">\n <X className=\"h-4 w-4 mr-2\" />\n Close\n </Button>\n )}\n </>\n ) : (\n <>\n <Button onClick={handleSave} disabled={saving} size=\"sm\">\n <Save className=\"h-4 w-4 mr-2\" />\n {saving ? 'Saving...' : 'Save'}\n </Button>\n <Button onClick={handleCancelEdit} variant=\"outline\" size=\"sm\">\n <X className=\"h-4 w-4 mr-2\" />\n Cancel\n </Button>\n </>\n )}\n </div>\n </div>\n </CardHeader>\n </Card>\n\n {/* EN: Tabbed content with voter details and history */}\n {/* VI: Nội dung tab với chi tiết cử tri và lịch sử */}\n {/* ES: Contenido con pestañas con detalles del votante e historial */}\n <Tabs defaultValue=\"details\" className=\"space-y-4\">\n <TabsList>\n <TabsTrigger value=\"details\">\n <User className=\"h-4 w-4 mr-2\" />\n Details\n </TabsTrigger>\n <TabsTrigger value=\"history\">\n <Clock className=\"h-4 w-4 mr-2\" />\n Contact History ({contactHistory.length})\n </TabsTrigger>\n </TabsList>\n\n <TabsContent value=\"details\">\n <Card>\n <CardContent className=\"p-6\">\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-6\">\n {/* EN: Personal Information */}\n {/* VI: Thông tin cá nhân */}\n {/* ES: Información personal */}\n <div className=\"space-y-4\">\n <h3 className=\"font-semibold text-lg flex items-center\">\n <User className=\"h-5 w-5 mr-2\" />\n Personal Information\n </h3>\n \n <div className=\"space-y-4\">\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <Label htmlFor=\"firstName\">First Name</Label>\n {isEditing ? (\n <Input\n id=\"firstName\"\n value={displayData.firstName}\n onChange={(e) => handleInputChange('firstName', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{displayData.firstName}</p>\n )}\n </div>\n <div>\n <Label htmlFor=\"lastName\">Last Name</Label>\n {isEditing ? (\n <Input\n id=\"lastName\"\n value={displayData.lastName}\n onChange={(e) => handleInputChange('lastName', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{displayData.lastName}</p>\n )}\n </div>\n </div>\n\n <div>\n <Label htmlFor=\"email\">Email</Label>\n {isEditing ? (\n <Input\n id=\"email\"\n type=\"email\"\n value={displayData.email || ''}\n onChange={(e) => handleInputChange('email', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm flex items-center\">\n {displayData.email ? (\n <>\n <Mail className=\"h-4 w-4 mr-2\" />\n {displayData.email}\n </>\n ) : (\n 'Not provided'\n )}\n </p>\n )}\n </div>\n\n <div>\n <Label htmlFor=\"phone\">Phone</Label>\n {isEditing ? (\n <Input\n id=\"phone\"\n value={displayData.phone || ''}\n onChange={(e) => handleInputChange('phone', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm flex items-center\">\n {displayData.phone ? (\n <>\n <Phone className=\"h-4 w-4 mr-2\" />\n {displayData.phone}\n </>\n ) : (\n 'Not provided'\n )}\n </p>\n )}\n </div>\n </div>\n </div>\n\n {/* EN: Address Information */}\n {/* VI: Thông tin địa chỉ */}\n {/* ES: Información de dirección */}\n <div className=\"space-y-4\">\n <h3 className=\"font-semibold text-lg flex items-center\">\n <MapPin className=\"h-5 w-5 mr-2\" />\n Address\n </h3>\n \n <div className=\"space-y-4\">\n <div>\n <Label htmlFor=\"address\">Street Address</Label>\n {isEditing ? (\n <Input\n id=\"address\"\n value={displayData.address || ''}\n onChange={(e) => handleInputChange('address', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{displayData.address || 'Not provided'}</p>\n )}\n </div>\n\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <Label htmlFor=\"city\">City</Label>\n {isEditing ? (\n <Input\n id=\"city\"\n value={displayData.city || ''}\n onChange={(e) => handleInputChange('city', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{displayData.city || 'Not provided'}</p>\n )}\n </div>\n <div>\n <Label htmlFor=\"state\">State</Label>\n {isEditing ? (\n <Input\n id=\"state\"\n value={displayData.state || ''}\n onChange={(e) => handleInputChange('state', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{displayData.state || 'Not provided'}</p>\n )}\n </div>\n </div>\n\n <div>\n <Label htmlFor=\"zipCode\">ZIP Code</Label>\n {isEditing ? (\n <Input\n id=\"zipCode\"\n value={displayData.zipCode || ''}\n onChange={(e) => handleInputChange('zipCode', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{displayData.zipCode || 'Not provided'}</p>\n )}\n </div>\n </div>\n </div>\n\n {/* EN: Voter Status */}\n {/* VI: Trạng thái cử tri */}\n {/* ES: Estado del votante */}\n <div className=\"space-y-4\">\n <h3 className=\"font-semibold text-lg\">Voter Status</h3>\n \n <div className=\"space-y-4\">\n <div>\n <Label htmlFor=\"voterStatus\">Registration Status</Label>\n {isEditing ? (\n <Select value={displayData.voterStatus} onValueChange={(value) => handleInputChange('voterStatus', value)}>\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"registered\">Registered</SelectItem>\n <SelectItem value=\"unregistered\">Unregistered</SelectItem>\n <SelectItem value=\"inactive\">Inactive</SelectItem>\n </SelectContent>\n </Select>\n ) : (\n <p className=\"mt-1 text-sm capitalize\">{displayData.voterStatus}</p>\n )}\n </div>\n\n <div>\n <Label htmlFor=\"partyAffiliation\">Party Affiliation</Label>\n {isEditing ? (\n <Select value={displayData.partyAffiliation} onValueChange={(value) => handleInputChange('partyAffiliation', value)}>\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"democrat\">Democrat</SelectItem>\n <SelectItem value=\"republican\">Republican</SelectItem>\n <SelectItem value=\"independent\">Independent</SelectItem>\n <SelectItem value=\"other\">Other</SelectItem>\n <SelectItem value=\"unknown\">Unknown</SelectItem>\n </SelectContent>\n </Select>\n ) : (\n <p className=\"mt-1 text-sm capitalize\">{displayData.partyAffiliation}</p>\n )}\n </div>\n\n <div>\n <Label htmlFor=\"likelihood\">Support Likelihood</Label>\n {isEditing ? (\n <Select value={displayData.likelihood} onValueChange={(value) => handleInputChange('likelihood', value)}>\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"strong_support\">Strong Support</SelectItem>\n <SelectItem value=\"lean_support\">Lean Support</SelectItem>\n <SelectItem value=\"undecided\">Undecided</SelectItem>\n <SelectItem value=\"lean_oppose\">Lean Oppose</SelectItem>\n <SelectItem value=\"strong_oppose\">Strong Oppose</SelectItem>\n </SelectContent>\n </Select>\n ) : (\n <p className=\"mt-1 text-sm\">{displayData.likelihood.replace('_', ' ')}</p>\n )}\n </div>\n </div>\n </div>\n\n {/* EN: Notes */}\n {/* VI: Ghi chú */}\n {/* ES: Notas */}\n <div className=\"space-y-4\">\n <h3 className=\"font-semibold text-lg flex items-center\">\n <FileText className=\"h-5 w-5 mr-2\" />\n Notes\n </h3>\n \n {isEditing ? (\n <Textarea\n value={displayData.notes || ''}\n onChange={(e) => handleInputChange('notes', e.target.value)}\n placeholder=\"Add notes about this voter...\"\n rows={4}\n />\n ) : (\n <p className=\"mt-1 text-sm whitespace-pre-wrap\">\n {displayData.notes || 'No notes available'}\n </p>\n )}\n </div>\n </div>\n </CardContent>\n </Card>\n </TabsContent>\n\n <TabsContent value=\"history\">\n <Card>\n <CardHeader>\n <CardTitle>Contact History</CardTitle>\n </CardHeader>\n <CardContent>\n {contactHistory.length === 0 ? (\n <p className=\"text-center text-gray-500 py-8\">\n No contact history available\n </p>\n ) : (\n <div className=\"space-y-4\">\n {contactHistory.map((contact, index) => (\n <div key={contact.id}>\n <div className=\"flex items-start justify-between\">\n <div className=\"flex items-start space-x-3\">\n <div className=\"flex-shrink-0 w-2 h-2 bg-blue-500 rounded-full mt-2\"></div>\n <div>\n <div className=\"flex items-center space-x-2\">\n <span className=\"font-medium\">{formatContactType(contact.type)}</span>\n <Badge variant=\"outline\" size=\"sm\">\n {formatOutcome(contact.outcome)}\n </Badge>\n </div>\n <p className=\"text-sm text-gray-500 mt-1\">\n <Calendar className=\"h-4 w-4 inline mr-1\" />\n {new Date(contact.date).toLocaleString()}\n </p>\n {contact.notes && (\n <p className=\"text-sm mt-2\">{contact.notes}</p>\n )}\n <p className=\"text-xs text-gray-400 mt-1\">\n by {contact.volunteer}\n </p>\n </div>\n </div>\n </div>\n {index < contactHistory.length - 1 && (\n <Separator className=\"my-4\" />\n )}\n </div>\n ))}\n </div>\n )}\n </CardContent>\n </Card>\n </TabsContent>\n </Tabs>\n </div>\n );\n}\n```\n\nThis VoterDetailScreen component provides:\n\n1. **Edit Mode**: Full edit functionality with `isEditing` state management\n2. **Comprehensive Display**: Shows all voter information including personal details, address, voter status, and notes\n3. **Contact History**: Complete timeline of interactions with the voter\n4. **Form Validation**: Uses Zod schema validation for data integrity\n5. **Responsive Design**: Works on both desktop and mobile devices\n6. **Visual Feedback**: Loading states, success/error messages, and proper UI feedback\n7. **Tabbed Interface**: Organized sections for details and history\n8. **Badge System**: Visual indicators for voter likelihood, status, and party affiliation\n\nThe component meets all acceptance criteria by including edit mode functionality and providing a complete voter management interface."
}Task ID: a26be681-dae3-43b1-907b-1b6bc0327664