← 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: PASS
Assignee
-
Claimed By
Cloud Lu → Claude Sonnet
Created
1d ago
Completed
1d ago
Quality
Overall
97%
Pass
PASS
Graded By
-
Graded At
-
Cost
Input Tokens
-
Output Tokens
-
Est. USD
-
Result
{
"cost": {
"input_tokens": 339,
"estimated_usd": 0.077112,
"output_tokens": 5073
},
"model": "claude-sonnet-4-20250514",
"status": "success",
"transport": "anthropic_api",
"latency_ms": 57101,
"response_text": "I'll create the VoterDetailScreen component that allows viewing and editing individual voter information with full history.\n\n```typescript\n// campaignpro/src/screens/VoterDetailScreen.tsx\n'use client'\n\nimport React, { useState, useEffect } from 'react'\nimport { useParams, useRouter } from 'next/navigation'\nimport { z } from 'zod'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Badge } from '@/components/ui/badge'\nimport { Separator } from '@/components/ui/separator'\nimport { ArrowLeft, Edit, Save, X, Calendar, MapPin, Phone, Mail, User } from 'lucide-react'\n\n// EN: Voter detail data validation schema\n// VI: Schema xác thực dữ liệu chi tiết cử tri\n// ES: Esquema de validación de datos detallados del votante\nconst VoterDetailSchema = z.object({\n id: z.string(),\n firstName: z.string().min(1, 'First name is required'),\n lastName: z.string().min(1, 'Last name is 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 zipCode: z.string().optional(),\n voterStatus: z.enum(['registered', 'unregistered', 'pending']),\n politicalAffiliation: z.enum(['democrat', 'republican', 'independent', 'unknown']),\n notes: z.string().optional(),\n createdAt: z.string(),\n updatedAt: z.string()\n})\n\n// EN: Contact history entry validation\n// VI: Xác thực mục lịch sử liên lạc\n// ES: Validación de entrada del historial de contacto\nconst ContactHistorySchema = z.object({\n id: z.string(),\n type: z.enum(['call', 'email', 'visit', 'event']),\n date: z.string(),\n notes: z.string(),\n outcome: z.enum(['positive', 'negative', 'neutral']),\n userId: z.string(),\n userName: z.string()\n})\n\ntype VoterDetail = z.infer<typeof VoterDetailSchema>\ntype ContactHistory = z.infer<typeof ContactHistorySchema>\n\ninterface VoterDetailScreenProps {\n voterId?: string\n}\n\nexport default function VoterDetailScreen({ voterId }: VoterDetailScreenProps) {\n const params = useParams()\n const router = useRouter()\n const id = voterId || (params?.id as string)\n \n // EN: Component state management\n // VI: Quản lý trạng thái component\n // ES: Gestión del estado del componente\n const [voter, setVoter] = useState<VoterDetail | null>(null)\n const [contactHistory, setContactHistory] = useState<ContactHistory[]>([])\n const [isEditing, setIsEditing] = useState(false)\n const [loading, setLoading] = useState(true)\n const [saving, setSaving] = useState(false)\n const [error, setError] = useState<string>('')\n \n // EN: Form data for editing mode\n // VI: Dữ liệu form cho chế độ chỉnh sửa\n // ES: Datos del formulario para modo de edición\n const [editForm, setEditForm] = useState<Partial<VoterDetail>>({})\n\n // EN: Load voter data and history on component mount - SPEC: CP-WIRE-02\n // VI: Tải dữ liệu cử tri và lịch sử khi component được mount - SPEC: CP-WIRE-02\n // ES: Cargar datos del votante e historial al montar el componente - SPEC: CP-WIRE-02\n useEffect(() => {\n if (!id) return\n\n const loadVoterData = async () => {\n try {\n setLoading(true)\n setError('')\n\n // EN: Fetch voter details\n // VI: Lấy chi tiết cử tri\n // ES: Obtener detalles del votante\n const voterResponse = await fetch(`/api/voters/${id}`)\n if (!voterResponse.ok) {\n throw new Error('Failed to load voter data')\n }\n \n const voterData = await voterResponse.json()\n const voterValidation = VoterDetailSchema.safeParse(voterData)\n \n if (!voterValidation.success) {\n console.error('Voter data validation failed:', voterValidation.error)\n throw new Error('Invalid voter data format')\n }\n \n setVoter(voterValidation.data)\n setEditForm(voterValidation.data)\n\n // EN: Fetch contact history\n // VI: Lấy lịch sử liên lạc\n // ES: Obtener historial de contacto\n const historyResponse = await fetch(`/api/voters/${id}/history`)\n if (historyResponse.ok) {\n const historyData = await historyResponse.json()\n const validHistory = historyData.filter((item: any) => {\n const validation = ContactHistorySchema.safeParse(item)\n return validation.success\n })\n setContactHistory(validHistory)\n }\n } catch (err) {\n console.error('Error loading voter data:', err)\n setError(err instanceof Error ? err.message : 'Failed to load voter data')\n } finally {\n setLoading(false)\n }\n }\n\n loadVoterData()\n }, [id])\n\n // EN: Handle form input changes during editing\n // VI: Xử lý thay đổi input form khi chỉnh sửa\n // ES: Manejar cambios de entrada del formulario durante la edición\n const handleInputChange = (field: keyof VoterDetail, value: string) => {\n setEditForm(prev => ({\n ...prev,\n [field]: value\n }))\n }\n\n // EN: Save voter changes - SPEC: CP-API-01\n // VI: Lưu thay đổi cử tri - SPEC: CP-API-01\n // ES: Guardar cambios del votante - SPEC: CP-API-01\n const handleSave = async () => {\n if (!voter || !editForm) return\n\n try {\n setSaving(true)\n setError('')\n\n const updateData = { ...editForm, updatedAt: new Date().toISOString() }\n const validation = VoterDetailSchema.safeParse(updateData)\n \n if (!validation.success) {\n throw new Error('Please check all required fields')\n }\n\n const response = await fetch(`/api/voters/${id}`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(validation.data)\n })\n\n if (!response.ok) {\n const errorData = await response.json()\n throw new Error(errorData.error || 'Failed to save changes')\n }\n\n const updatedVoter = await response.json()\n setVoter(updatedVoter)\n setIsEditing(false)\n } catch (err) {\n console.error('Error saving voter:', err)\n setError(err instanceof Error ? err.message : 'Failed to save changes')\n } finally {\n setSaving(false)\n }\n }\n\n // EN: Cancel editing and revert changes\n // VI: Hủy chỉnh sửa và hoàn nguyên thay đổi\n // ES: Cancelar edición y revertir cambios\n const handleCancel = () => {\n setEditForm(voter || {})\n setIsEditing(false)\n setError('')\n }\n\n // EN: Format contact type for display\n // VI: Định dạng loại liên lạc để hiển thị\n // ES: Formatear tipo de contacto para mostrar\n const formatContactType = (type: string) => {\n const typeMap = {\n call: 'Phone Call',\n email: 'Email',\n visit: 'Door Visit',\n event: 'Event'\n }\n return typeMap[type as keyof typeof typeMap] || type\n }\n\n // EN: Get status badge color\n // VI: Lấy màu badge trạng thái\n // ES: Obtener color de insignia de estado\n const getStatusBadgeVariant = (status: string) => {\n switch (status) {\n case 'registered': return 'default'\n case 'pending': return 'secondary'\n case 'unregistered': return 'destructive'\n default: return 'outline'\n }\n }\n\n if (loading) {\n return (\n <div className=\"flex items-center justify-center min-h-screen\">\n <div className=\"text-center\">\n <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4\"></div>\n <p>Loading voter details...</p>\n </div>\n </div>\n )\n }\n\n if (!voter) {\n return (\n <div className=\"flex items-center justify-center min-h-screen\">\n <Card className=\"w-full max-w-md\">\n <CardContent className=\"pt-6\">\n <div className=\"text-center\">\n <User className=\"h-12 w-12 text-muted-foreground mx-auto mb-4\" />\n <h2 className=\"text-xl font-semibold mb-2\">Voter Not Found</h2>\n <p className=\"text-muted-foreground mb-4\">\n The requested voter could not be found.\n </p>\n <Button onClick={() => router.back()}>\n <ArrowLeft className=\"h-4 w-4 mr-2\" />\n Go Back\n </Button>\n </div>\n </CardContent>\n </Card>\n </div>\n )\n }\n\n return (\n <div className=\"container mx-auto px-4 py-6 max-w-4xl\">\n {/* EN: Header section with navigation and actions */}\n {/* VI: Phần header với điều hướng và hành động */}\n {/* ES: Sección de encabezado con navegación y acciones */}\n <div className=\"flex items-center justify-between mb-6\">\n <div className=\"flex items-center gap-4\">\n <Button\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => router.back()}\n >\n <ArrowLeft className=\"h-4 w-4 mr-2\" />\n Back\n </Button>\n <div>\n <h1 className=\"text-2xl font-bold\">\n {voter.firstName} {voter.lastName}\n </h1>\n <p className=\"text-muted-foreground\">Voter Details</p>\n </div>\n </div>\n \n <div className=\"flex gap-2\">\n {isEditing ? (\n <>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={handleCancel}\n disabled={saving}\n >\n <X className=\"h-4 w-4 mr-2\" />\n Cancel\n </Button>\n <Button\n size=\"sm\"\n onClick={handleSave}\n disabled={saving}\n >\n <Save className=\"h-4 w-4 mr-2\" />\n {saving ? 'Saving...' : 'Save'}\n </Button>\n </>\n ) : (\n <Button\n size=\"sm\"\n onClick={() => setIsEditing(true)}\n >\n <Edit className=\"h-4 w-4 mr-2\" />\n Edit\n </Button>\n )}\n </div>\n </div>\n\n {error && (\n <div className=\"bg-destructive/15 text-destructive px-4 py-3 rounded-md mb-6\">\n {error}\n </div>\n )}\n\n <div className=\"grid gap-6 md:grid-cols-2\">\n {/* EN: Personal Information Card */}\n {/* VI: Card thông tin cá nhân */}\n {/* ES: Tarjeta de información personal */}\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <User className=\"h-5 w-5\" />\n Personal Information\n </CardTitle>\n </CardHeader>\n <CardContent 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={editForm.firstName || ''}\n onChange={(e) => handleInputChange('firstName', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{voter.firstName}</p>\n )}\n </div>\n <div>\n <Label htmlFor=\"lastName\">Last Name</Label>\n {isEditing ? (\n <Input\n id=\"lastName\"\n value={editForm.lastName || ''}\n onChange={(e) => handleInputChange('lastName', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{voter.lastName}</p>\n )}\n </div>\n </div>\n\n <div>\n <Label>Voter Status</Label>\n <div className=\"mt-1\">\n <Badge variant={getStatusBadgeVariant(voter.voterStatus)}>\n {voter.voterStatus.charAt(0).toUpperCase() + voter.voterStatus.slice(1)}\n </Badge>\n </div>\n </div>\n\n <div>\n <Label>Political Affiliation</Label>\n <p className=\"mt-1 text-sm capitalize\">{voter.politicalAffiliation}</p>\n </div>\n </CardContent>\n </Card>\n\n {/* EN: Contact Information Card */}\n {/* VI: Card thông tin liên lạc */}\n {/* ES: Tarjeta de información de contacto */}\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Phone className=\"h-5 w-5\" />\n Contact Information\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n <div>\n <Label htmlFor=\"email\" className=\"flex items-center gap-2\">\n <Mail className=\"h-4 w-4\" />\n Email\n </Label>\n {isEditing ? (\n <Input\n id=\"email\"\n type=\"email\"\n value={editForm.email || ''}\n onChange={(e) => handleInputChange('email', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{voter.email || 'Not provided'}</p>\n )}\n </div>\n\n <div>\n <Label htmlFor=\"phone\">Phone</Label>\n {isEditing ? (\n <Input\n id=\"phone\"\n value={editForm.phone || ''}\n onChange={(e) => handleInputChange('phone', e.target.value)}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{voter.phone || 'Not provided'}</p>\n )}\n </div>\n\n <div>\n <Label htmlFor=\"address\" className=\"flex items-center gap-2\">\n <MapPin className=\"h-4 w-4\" />\n Address\n </Label>\n {isEditing ? (\n <Textarea\n id=\"address\"\n value={editForm.address || ''}\n onChange={(e) => handleInputChange('address', e.target.value)}\n rows={3}\n />\n ) : (\n <p className=\"mt-1 text-sm\">{voter.address || 'Not provided'}</p>\n )}\n </div>\n </CardContent>\n </Card>\n </div>\n\n {/* EN: Notes Section */}\n {/* VI: Phần ghi chú */}\n {/* ES: Sección de notas */}\n <Card className=\"mt-6\">\n <CardHeader>\n <CardTitle>Notes</CardTitle>\n </CardHeader>\n <CardContent>\n {isEditing ? (\n <Textarea\n value={editForm.notes || ''}\n onChange={(e) => handleInputChange('notes', e.target.value)}\n placeholder=\"Add notes about this voter...\"\n rows={4}\n />\n ) : (\n <p className=\"text-sm\">\n {voter.notes || 'No notes available'}\n </p>\n )}\n </CardContent>\n </Card>\n\n {/* EN: Contact History Section */}\n {/* VI: Phần lịch sử liên lạc */}\n {/* ES: Sección de historial de contacto */}\n <Card className=\"mt-6\">\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Calendar className=\"h-5 w-5\" />\n Contact History\n </CardTitle>\n </CardHeader>\n <CardContent>\n {contactHistory.length === 0 ? (\n <p className=\"text-muted-foreground text-center 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-1\">\n <div className=\"flex items-center gap-2 mb-1\">\n <Badge variant=\"outline\">\n {formatContactType(contact.type)}\n </Badge>\n <Badge variant={contact.outcome === 'positive' ? 'default' : \n contact.outcome === 'negative' ? 'destructive' : 'secondary'}>\n {contact.outcome}\n </Badge>\n </div>\n <p className=\"text-sm text-muted-foreground mb-2\">\n {new Date(contact.date).toLocaleDateString()} by {contact.userName}\n </p>\n <p className=\"text-sm\">{contact.notes}</p>\n </div>\n </div>\n {index < contactHistory.length - 1 && (\n <Separator className=\"mt-4\" />\n )}\n </div>\n ))}\n </div>\n )}\n </CardContent>\n </Card>\n </div>\n )\n}\n```\n\nThis VoterDetailScreen component provides:\n\n1. **Complete voter detail view** with personal and contact information\n2. **Edit mode functionality** with `isEditing` state management\n3. **Full contact history** display with filtering and formatting\n4. **Form validation** using Zod schemas\n5. **Error handling** and loading states\n6. **Responsive design** with proper card layouts\n7. **Trilingual comments** following codebase conventions\n8. **SPEC section references** for CP-WIRE-02 and CP-API-01\n9. **Proper TypeScript types** and safe parsing\n10. **UI components** from the design system\n\nThe component handles both viewing and editing modes, manages form state properly, and integrates with the API endpoints for voter data and contact history."
}Task ID: 13c2fc71-7e5f-40b5-8ac2-0d78a6d49c62