# Walk Sheets & QR Codes ## Overview The Walk Sheets system provides printable door-to-door canvassing materials with integrated QR code support. This feature enables campaign organizers to generate professional walk sheets for volunteers, complete with address lists, cut boundaries, and quick-access QR codes to campaign resources. **Key Features:** - Browser-based printing (no server-side PDF generation) - Customizable headers, footers, and QR codes - Cut-based address filtering - Point-in-polygon location selection - Print-optimized layout (A4/Letter) - Cut export reports with statistics - Multi-unit building support - Support level indicators **Use Cases:** - Door-to-door canvassing - Volunteer shift materials - Cut logistics planning - Campaign resource distribution - Field data collection **Architecture Highlights:** - Frontend-only printing (window.print()) - QR code generation via public API - MapSettings singleton for configuration - Point-in-polygon filtering for cut locations - CSS @media print rules for layout ## Architecture ```mermaid flowchart TB subgraph Admin Interface Admin[Admin User] Settings[MapSettingsPage] WalkSheet[WalkSheetPage] CutExport[CutExportPage] end subgraph API Layer MapSettingsAPI["/api/map-settings"] CutsAPI["/api/cuts/:id"] LocationsAPI["/api/locations?cutId="] QRAPI["/api/qr/generate"] end subgraph Database MapSettingsDB[(MapSettings)] CutsDB[(Cuts)] LocationsDB[(Locations)] end subgraph Print System Preview[Print Preview] Browser[Browser Print Dialog] PDF[PDF Output] end Admin --> Settings Admin --> WalkSheet Admin --> CutExport Settings --> MapSettingsAPI WalkSheet --> MapSettingsAPI WalkSheet --> CutsAPI WalkSheet --> LocationsAPI WalkSheet --> QRAPI CutExport --> CutsAPI CutExport --> LocationsAPI MapSettingsAPI --> MapSettingsDB CutsAPI --> CutsDB LocationsAPI --> LocationsDB WalkSheet --> Preview CutExport --> Preview Preview --> Browser Browser --> PDF QRAPI --> QRGen[QR Code PNG Generator] QRGen --> Base64[Base64 Data URL] Base64 --> WalkSheet ``` **Data Flow:** 1. **Configuration Phase:** - Admin configures walk sheet settings (title, subtitle, footer, QR codes) - Settings stored in MapSettings singleton - QR code URLs and labels defined (up to 3) 2. **Generation Phase:** - Admin selects cut from dropdown - Frontend fetches cut details and settings - Point-in-polygon filter retrieves locations within cut - QR codes generated via POST /api/qr/generate - Walk sheet rendered with all components 3. **Print Phase:** - window.print() triggered - Browser print dialog opens - Print CSS rules applied (hide nav, adjust layout) - User selects printer or "Save as PDF" ## Database Models ### MapSettings Model ```prisma model MapSettings { id Int @id @default(1) // Singleton // Walk Sheet Configuration walkSheetTitle String @default("Walk Sheet") walkSheetSubtitle String @default("") walkSheetFooter String @default("") // QR Code 1 qrCode1Url String? qrCode1Label String? // QR Code 2 qrCode2Url String? qrCode2Label String? // QR Code 3 qrCode3Url String? qrCode3Label String? // Other map settings defaultCenterLat Float @default(43.6532) defaultCenterLng Float @default(-79.3832) defaultZoom Int @default(12) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` **Singleton Pattern:** - Always ID = 1 - Created during seed if not exists - Single source of truth for walk sheet config ### Cut Model ```prisma model Cut { id Int @id @default(autoincrement()) name String description String? geojson Json // GeoJSON Polygon or MultiPolygon color String @default("#3498db") visible Boolean @default(true) shifts Shift[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` **GeoJSON Structure:** ```json { "type": "Polygon", "coordinates": [ [ [-79.38, 43.65], [-79.37, 43.65], [-79.37, 43.66], [-79.38, 43.66], [-79.38, 43.65] ] ] } ``` ### Location Model ```prisma model Location { id Int @id @default(autoincrement()) address String latitude Float? longitude Float? postalCode String? province String? // Geocoding metadata geocodeConfidence Int? // 0-100 geocodeProvider String? // GOOGLE, MAPBOX, etc. // NAR import fields locGuid String? @unique federalDistrict String? buildingUse Int? // 1 = Residential addresses Address[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` ### Address Model ```prisma model Address { id Int @id @default(autoincrement()) locationId Int location Location @relation(fields: [locationId], references: [id], onDelete: Cascade) unitNumber String? firstName String? lastName String? supportLevel Int? // 1-5 scale notes String? // NAR import addrGuid String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([locationId]) } ``` **Support Level Scale:** - 1 = Strong Opposition - 2 = Lean Opposition - 3 = Undecided - 4 = Lean Support - 5 = Strong Support ## API Endpoints ### GET /api/map-settings Fetch walk sheet configuration. **Authentication:** Required (SUPER_ADMIN, MAP_ADMIN) **Response:** ```json { "id": 1, "walkSheetTitle": "Toronto Canvass Walk Sheet", "walkSheetSubtitle": "Ward 10 - November 2025", "walkSheetFooter": "Questions? Call HQ at 416-555-1234", "qrCode1Url": "https://example.com/campaign", "qrCode1Label": "Campaign Page", "qrCode2Url": "https://example.com/volunteer", "qrCode2Label": "Volunteer Portal", "qrCode3Url": "https://example.com/donate", "qrCode3Label": "Donate Now", "defaultCenterLat": 43.6532, "defaultCenterLng": -79.3832, "defaultZoom": 12, "createdAt": "2025-01-15T10:00:00Z", "updatedAt": "2025-02-10T14:30:00Z" } ``` ### PUT /api/map-settings Update walk sheet configuration. **Authentication:** Required (SUPER_ADMIN, MAP_ADMIN) **Request Body:** ```json { "walkSheetTitle": "Updated Title", "walkSheetSubtitle": "Updated Subtitle", "walkSheetFooter": "Updated footer text with contact info", "qrCode1Url": "https://newurl.com", "qrCode1Label": "New Label" } ``` **Response:** Updated MapSettings object **Validation:** - walkSheetTitle: 1-100 characters - walkSheetSubtitle: 0-200 characters - walkSheetFooter: 0-500 characters - qrCode URLs: valid HTTP/HTTPS URLs - qrCode labels: 0-50 characters ### GET /api/cuts/:id Fetch cut details for walk sheet. **Authentication:** Required **Response:** ```json { "id": 42, "name": "Downtown Core", "description": "High-density residential area", "geojson": { "type": "Polygon", "coordinates": [[...]] }, "color": "#3498db", "visible": true, "createdAt": "2025-01-20T09:00:00Z", "updatedAt": "2025-02-01T11:00:00Z" } ``` ### GET /api/locations?cutId=:id Fetch locations within cut boundary. **Authentication:** Required **Query Parameters:** - `cutId` (required): Cut ID for filtering - `sortBy` (optional): Field to sort by (default: "address") - `order` (optional): "asc" or "desc" (default: "asc") **Response:** ```json { "data": [ { "id": 1001, "address": "123 Main St", "latitude": 43.6532, "longitude": -79.3832, "postalCode": "M5H 2N2", "addresses": [ { "id": 5001, "unitNumber": "101", "firstName": "John", "lastName": "Smith", "supportLevel": 4, "notes": "Lawn sign requested" }, { "id": 5002, "unitNumber": "102", "firstName": "Jane", "lastName": "Doe", "supportLevel": 5, "notes": null } ] } ], "total": 150 } ``` **Filtering Logic:** ```typescript // Point-in-polygon filter const locations = await prisma.location.findMany({ where: { AND: [ { latitude: { not: null } }, { longitude: { not: null } } ] }, include: { addresses: { orderBy: { unitNumber: 'asc' } } }, orderBy: { address: 'asc' } }); // Filter using point-in-polygon const filtered = locations.filter(loc => isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson) ); ``` ### POST /api/qr/generate Generate QR code PNG from URL. **Authentication:** None (public endpoint) **Request Body:** ```json { "url": "https://example.com/campaign", "size": 200 } ``` **Parameters:** - `url` (required): Target URL for QR code - `size` (optional): QR code dimension in pixels (default: 200, max: 500) **Response:** ```json { "png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." } ``` **Error Responses:** - 400: Invalid URL format - 400: Size must be between 50-500 - 500: QR code generation failed **Rate Limiting:** 100 requests per 15 minutes per IP ## Configuration ### Environment Variables | Variable | Type | Default | Description | |----------|------|---------|-------------| | N/A | | | Walk sheet settings stored in database | ### MapSettings Configuration Access via: **Admin → Settings → Map Settings** | Setting | Type | Default | Max Length | Description | |---------|------|---------|------------|-------------| | walkSheetTitle | string | "Walk Sheet" | 100 | Header title for walk sheets | | walkSheetSubtitle | string | "" | 200 | Subtitle below title (ward, date, etc.) | | walkSheetFooter | string | "" | 500 | Footer text (contact info, instructions) | | qrCode1Url | string | null | 2048 | First QR code target URL | | qrCode1Label | string | null | 50 | First QR code label | | qrCode2Url | string | null | 2048 | Second QR code target URL | | qrCode2Label | string | null | 50 | Second QR code label | | qrCode3Url | string | null | 2048 | Third QR code target URL | | qrCode3Label | string | null | 50 | Third QR code label | **QR Code URL Examples:** - Campaign page: `https://example.com/campaigns/123` - Volunteer portal: `https://example.com/volunteer` - Donation page: `https://example.com/donate` - Social media: `https://facebook.com/campaignpage` - Google Form: `https://forms.google.com/...` **QR Code Label Best Practices:** - Keep short (2-4 words) - Action-oriented ("Donate Now", "Get Updates") - Mobile-friendly (scanned on phones) - Clear purpose ("Campaign Details", "Volunteer Info") ### Print Configuration **CSS Variables:** ```css @media print { --print-margin: 0.5in; --print-font-size: 10pt; --print-header-size: 16pt; --print-qr-size: 150px; --print-table-border: 1px solid #000; } ``` **Page Setup:** - Size: A4 (210mm × 297mm) or Letter (8.5" × 11") - Orientation: Portrait - Margins: 0.5 inches (12.7mm) - Print background: Enabled (for borders) - Scale: 100% (no auto-fit) ## Admin Workflow ### Configure Walk Sheet Settings **Step 1: Navigate to Map Settings** 1. Log in as SUPER_ADMIN or MAP_ADMIN 2. Click **Settings** in sidebar 3. Click **Map Settings** submenu 4. Scroll to "Walk Sheet Configuration" section **Step 2: Set Title and Subtitle** ```plaintext Walk Sheet Title: "Toronto Canvass Walk Sheet" Walk Sheet Subtitle: "Ward 10 - November 2025 Campaign" ``` **Step 3: Configure QR Codes** ```plaintext QR Code 1: URL: https://example.com/campaign/123 Label: Campaign Page QR Code 2: URL: https://example.com/volunteer Label: Volunteer Sign-Up QR Code 3: URL: https://example.com/donate Label: Donate Now ``` **Step 4: Set Footer Text** ```plaintext Walk Sheet Footer: Questions? Call HQ at 416-555-1234 Emergency? Text volunteer coordinator at 416-555-5678 Return completed sheets to campaign office by 8 PM ``` **Step 5: Save Settings** - Click **Save** button - Success notification appears - Settings applied to all future walk sheets ### Generate Walk Sheet **Step 1: Navigate to Walk Sheet Page** 1. Click **Map** in sidebar 2. Click **Walk Sheet** submenu 3. Walk sheet generator page loads **Step 2: Select Cut** 1. Click **Select Cut** dropdown 2. Choose cut from list (e.g., "Downtown Core") 3. Loading indicator shows while fetching locations 4. Location count displayed (e.g., "150 locations") **Step 3: Preview Walk Sheet** Walk sheet displays: ```plaintext ┌─────────────────────────────────────────────┐ │ Toronto Canvass Walk Sheet │ │ Ward 10 - November 2025 Campaign │ │ Cut: Downtown Core │ │ Date: February 13, 2026 │ └─────────────────────────────────────────────┘ ┌───────────────────┬──────┬────────┬─────────┐ │ Address │ Unit │ Notes │ Visited │ ├───────────────────┼──────┼────────┼─────────┤ │ 100 Adelaide St E │ 101 │ Lawn │ □ │ │ 100 Adelaide St E │ 102 │ │ □ │ │ 102 Adelaide St E │ │ │ □ │ │ 105 Bay St │ 1A │ Strong │ □ │ └───────────────────┴──────┴────────┴─────────┘ [QR Code] [QR Code] [QR Code] Campaign Page Volunteer Info Donate Now Questions? Call HQ at 416-555-1234 Emergency? Text volunteer coordinator at 416-555-5678 Return completed sheets to campaign office by 8 PM ``` **Step 4: Print Walk Sheet** 1. Click **Print** button (top-right corner) 2. Browser print dialog opens 3. Configure print settings: - Destination: Printer or "Save as PDF" - Pages: All - Layout: Portrait - Margins: Default - Background graphics: Enabled 4. Click **Print** or **Save** **Step 5: Distribute to Volunteers** - Print multiple copies for shift volunteers - Include shift assignment sheet - Provide pens for checkboxes and notes - Brief volunteers on walk sheet usage ### Generate Cut Export Report **Step 1: Navigate to Cuts Page** 1. Click **Map** → **Cuts** in sidebar 2. Cuts table loads with list of all cuts **Step 2: Open Cut Export** 1. Find cut row (e.g., "Downtown Core") 2. Click **Export** button in Actions column 3. New tab opens with export report **Step 3: Review Statistics** Export report shows: ```plaintext ┌─────────────────────────────────────────────┐ │ Cut Export Report │ │ Cut: Downtown Core │ │ Generated: February 13, 2026 10:30 AM │ └─────────────────────────────────────────────┘ Statistics: Total Locations: 150 Total Units: 287 Residential: 280 (97.6%) Commercial: 7 (2.4%) Geocoded: 148 (98.7%) Missing Coordinates: 2 (1.3%) ┌─────────────────┬──────┬───────┬──────────┐ │ Address │ Lat │ Lng │ Units │ ├─────────────────┼──────┼───────┼──────────┤ │ 100 Adelaide E │ 43.6 │ -79.3 │ 2 │ │ 102 Adelaide E │ 43.6 │ -79.3 │ 1 │ └─────────────────┴──────┴───────┴──────────┘ [Export CSV Button] [Print Button] ``` **Step 4: Export to CSV** 1. Click **Export CSV** button 2. File downloads: `cut-42-downtown-core-2026-02-13.csv` 3. Open in spreadsheet for further analysis **CSV Format:** ```csv Address,Latitude,Longitude,Postal Code,Units,Residential "100 Adelaide St E",43.6532,-79.3832,"M5H 2N2",2,true "102 Adelaide St E",43.6540,-79.3825,"M5H 2N3",1,true ``` ## Print Layout ### Page Structure ```plaintext ┌─────────────────────────────────────────────┐ │ [HEADER SECTION] │ │ - Walk Sheet Title │ │ - Subtitle │ │ - Cut Name │ │ - Generated Date │ ├─────────────────────────────────────────────┤ │ [ADDRESS TABLE] │ │ - Sortable by street name │ │ - Multi-unit grouped │ │ - Support level indicators │ │ - Notes column │ │ - Visited checkbox │ ├─────────────────────────────────────────────┤ │ [QR CODE SECTION] │ │ - Up to 3 QR codes │ │ - Labels below each code │ │ - Horizontal layout │ ├─────────────────────────────────────────────┤ │ [FOOTER SECTION] │ │ - Custom footer text │ │ - Contact information │ │ - Instructions │ └─────────────────────────────────────────────┘ ``` ### CSS Print Rules **Component: WalkSheetPage.tsx** ```css @media print { /* Hide non-printable elements */ .no-print, .ant-layout-header, .ant-layout-sider, button, .ant-select, .ant-form, nav { display: none !important; } /* Page setup */ @page { size: A4 portrait; margin: 0.5in; } body { font-size: 10pt; line-height: 1.4; color: #000; background: #fff; } /* Header styling */ .walk-sheet-header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #000; padding-bottom: 10px; } .walk-sheet-title { font-size: 16pt; font-weight: bold; margin-bottom: 5px; } .walk-sheet-subtitle { font-size: 12pt; color: #333; } /* Table styling */ table { width: 100%; border-collapse: collapse; page-break-inside: avoid; margin-bottom: 20px; } th, td { border: 1px solid #000; padding: 6px; text-align: left; } th { background-color: #f0f0f0; font-weight: bold; font-size: 9pt; } td { font-size: 9pt; } /* Prevent row breaks */ tr { page-break-inside: avoid; } /* QR code section */ .qr-code-section { display: flex; justify-content: space-around; margin: 20px 0; page-break-inside: avoid; } .qr-code-item { text-align: center; width: 150px; } .qr-code-item img { width: 150px; height: 150px; margin-bottom: 5px; } .qr-code-label { font-size: 9pt; font-weight: bold; } /* Footer styling */ .walk-sheet-footer { margin-top: 20px; padding-top: 10px; border-top: 1px solid #000; font-size: 9pt; white-space: pre-wrap; } /* Checkbox styling */ .visited-checkbox { width: 15px; height: 15px; border: 1px solid #000; display: inline-block; } /* Support level indicators */ .support-level-1 { color: #e74c3c; } /* Strong Opposition */ .support-level-2 { color: #f39c12; } /* Lean Opposition */ .support-level-3 { color: #95a5a6; } /* Undecided */ .support-level-4 { color: #3498db; } /* Lean Support */ .support-level-5 { color: #27ae60; } /* Strong Support */ } ``` ### Address Table Layout **Column Structure:** | Column | Width | Content | Sort | |--------|-------|---------|------| | Address | 40% | Street address | Alphabetical | | Unit | 10% | Unit/apartment number | Alphanumeric | | Name | 20% | First + Last name | Alphabetical | | Support | 10% | Support level (1-5) | Color-coded | | Notes | 15% | Canvasser notes | N/A | | Visited | 5% | Checkbox | N/A | **Multi-Unit Grouping:** ```plaintext ┌───────────────────┬──────┬────────────┬─────────┬────────┬─────────┐ │ Address │ Unit │ Name │ Support │ Notes │ Visited │ ├───────────────────┼──────┼────────────┼─────────┼────────┼─────────┤ │ 100 Adelaide St E │ 101 │ John Smith │ 4 │ Lawn │ □ │ │ 100 Adelaide St E │ 102 │ Jane Doe │ 5 │ │ □ │ │ 100 Adelaide St E │ 103 │ │ │ │ □ │ ├───────────────────┼──────┼────────────┼─────────┼────────┼─────────┤ │ 102 Adelaide St E │ │ │ │ │ □ │ └───────────────────┴──────┴────────────┴─────────┴────────┴─────────┘ ``` **Support Level Colors:** - 1 (Strong Opposition): Red (#e74c3c) - 2 (Lean Opposition): Orange (#f39c12) - 3 (Undecided): Gray (#95a5a6) - 4 (Lean Support): Blue (#3498db) - 5 (Strong Support): Green (#27ae60) ### QR Code Layout **Horizontal Layout:** ```plaintext [QR 150×150] [QR 150×150] [QR 150×150] Campaign Page Volunteer Info Donate Now ``` **QR Code Generation:** - Size: 150×150 pixels - Error correction: Medium (M) - Format: PNG with transparent background - Encoding: UTF-8 - Margin: 4 modules **Spacing:** - Between codes: 30px - Above section: 20px - Below section: 20px - Label margin: 5px ## Cut Export Page ### Export Report Structure **Component: CutExportPage.tsx** **Route:** `/app/map/cuts/:id/export` **Layout:** ```plaintext ┌─────────────────────────────────────────────┐ │ Cut Export Report │ │ [Cut Name] │ │ Generated: [Date Time] │ ├─────────────────────────────────────────────┤ │ STATISTICS PANEL │ │ ┌──────────────┬──────────────┐ │ │ │ Total Locs │ Geocoded │ │ │ │ 150 │ 148 (98.7%) │ │ │ ├──────────────┼──────────────┤ │ │ │ Total Units │ Residential │ │ │ │ 287 │ 280 (97.6%) │ │ │ └──────────────┴──────────────┘ │ ├─────────────────────────────────────────────┤ │ LOCATION TABLE │ │ [Sortable, filterable table] │ ├─────────────────────────────────────────────┤ │ ACTIONS │ │ [Export CSV] [Print] │ └─────────────────────────────────────────────┘ ``` ### Statistics Panel **Metrics Displayed:** 1. **Total Locations:** Count of locations within cut 2. **Total Units:** Sum of addresses across all locations 3. **Geocoded Locations:** Locations with lat/lng (% of total) 4. **Missing Coordinates:** Locations without lat/lng 5. **Residential Units:** Units with buildingUse = 1 6. **Commercial Units:** Units with buildingUse != 1 7. **Support Level Breakdown:** Count by level (1-5) 8. **Cut Area:** Approximate area in square kilometers **Statistics Calculation:** ```typescript interface CutStatistics { totalLocations: number; totalUnits: number; geocodedCount: number; geocodedPercent: number; missingCoordinates: number; residentialCount: number; residentialPercent: number; commercialCount: number; supportLevelBreakdown: Record; cutAreaKm2: number; } const calculateStats = (locations: Location[]): CutStatistics => { const totalLocations = locations.length; const totalUnits = locations.reduce((sum, loc) => sum + loc.addresses.length, 0); const geocodedCount = locations.filter(loc => loc.latitude && loc.longitude).length; const residentialCount = locations.filter(loc => loc.buildingUse === 1).length; const supportLevelBreakdown = {}; locations.forEach(loc => { loc.addresses.forEach(addr => { if (addr.supportLevel) { supportLevelBreakdown[addr.supportLevel] = (supportLevelBreakdown[addr.supportLevel] || 0) + 1; } }); }); return { totalLocations, totalUnits, geocodedCount, geocodedPercent: (geocodedCount / totalLocations) * 100, missingCoordinates: totalLocations - geocodedCount, residentialCount, residentialPercent: (residentialCount / totalLocations) * 100, commercialCount: totalLocations - residentialCount, supportLevelBreakdown, cutAreaKm2: calculatePolygonArea(cut.geojson) }; }; ``` ### Location Table **Columns:** | Column | Data | Format | |--------|------|--------| | Address | location.address | String | | Latitude | location.latitude | 6 decimals | | Longitude | location.longitude | 6 decimals | | Postal Code | location.postalCode | Uppercase | | Units | addresses.length | Integer | | Residential | buildingUse === 1 | Boolean | | Support Avg | avg(addresses.supportLevel) | 1 decimal | **Table Features:** - Sortable by all columns - Filterable by postal code prefix - Pagination (50 per page) - Export selected rows to CSV - Highlight locations with missing coordinates - Color-code by average support level ### CSV Export **Export Button Handler:** ```typescript const exportToCSV = () => { const headers = [ 'Address', 'Latitude', 'Longitude', 'Postal Code', 'Units', 'Residential', 'Support Average', 'Federal District' ]; const rows = locations.map(loc => [ loc.address, loc.latitude?.toFixed(6) || '', loc.longitude?.toFixed(6) || '', loc.postalCode || '', loc.addresses.length, loc.buildingUse === 1 ? 'Yes' : 'No', calculateAverageSupportLevel(loc.addresses).toFixed(1), loc.federalDistrict || '' ]); const csv = [headers, ...rows] .map(row => row.map(cell => `"${cell}"`).join(',')) .join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `cut-${cutId}-${cutName}-${new Date().toISOString().split('T')[0]}.csv`; link.click(); URL.revokeObjectURL(url); }; ``` **CSV Output Example:** ```csv "Address","Latitude","Longitude","Postal Code","Units","Residential","Support Average","Federal District" "100 Adelaide St E","43.653200","-79.383200","M5H 2N2","2","Yes","4.5","Toronto Centre" "102 Adelaide St E","43.654000","-79.382500","M5H 2N3","1","Yes","3.0","Toronto Centre" "105 Bay St","43.650000","-79.380000","M5J 2R8","12","Yes","4.2","Toronto Centre" ``` ## Code Examples ### WalkSheetPage.tsx - Component Structure ```typescript import React, { useEffect, useState } from 'react'; import { Select, Button, Table, Space, Spin, Typography, Row, Col } from 'antd'; import { PrinterOutlined } from '@ant-design/icons'; import { api } from '@/lib/api'; import type { Cut, Location, MapSettings } from '@/types/api'; const { Title, Text } = Typography; const WalkSheetPage: React.FC = () => { const [cuts, setCuts] = useState([]); const [selectedCutId, setSelectedCutId] = useState(null); const [locations, setLocations] = useState([]); const [settings, setSettings] = useState(null); const [qrCodes, setQrCodes] = useState>({}); const [loading, setLoading] = useState(false); useEffect(() => { fetchCuts(); fetchSettings(); }, []); useEffect(() => { if (selectedCutId) { fetchLocations(selectedCutId); } }, [selectedCutId]); useEffect(() => { if (settings) { generateQRCodes(); } }, [settings]); const fetchCuts = async () => { const { data } = await api.get('/cuts'); setCuts(data); }; const fetchSettings = async () => { const { data } = await api.get('/map-settings'); setSettings(data); }; const fetchLocations = async (cutId: number) => { setLoading(true); try { const { data } = await api.get<{ data: Location[] }>( `/locations?cutId=${cutId}&sortBy=address&order=asc` ); setLocations(data.data); } finally { setLoading(false); } }; const generateQRCodes = async () => { if (!settings) return; const codes: Record = {}; const qrUrls = [ { url: settings.qrCode1Url, index: 1 }, { url: settings.qrCode2Url, index: 2 }, { url: settings.qrCode3Url, index: 3 } ].filter(item => item.url); for (const { url, index } of qrUrls) { try { const { data } = await api.post('/qr/generate', { url, size: 150 }); codes[index] = data.png; } catch (error) { console.error(`Failed to generate QR code ${index}:`, error); } } setQrCodes(codes); }; const handlePrint = () => { window.print(); }; const columns = [ { title: 'Address', dataIndex: 'address', key: 'address', width: '40%' }, { title: 'Unit', key: 'unit', width: '10%', render: (_: any, record: Location) => ( {record.addresses.map(addr => ( {addr.unitNumber || '-'} ))} ) }, { title: 'Name', key: 'name', width: '20%', render: (_: any, record: Location) => ( {record.addresses.map(addr => ( {addr.firstName && addr.lastName ? `${addr.firstName} ${addr.lastName}` : '-'} ))} ) }, { title: 'Support', key: 'support', width: '10%', render: (_: any, record: Location) => ( {record.addresses.map(addr => ( {addr.supportLevel || '-'} ))} ) }, { title: 'Notes', key: 'notes', width: '15%', render: (_: any, record: Location) => ( {record.addresses.map(addr => ( {addr.notes || '-'} ))} ) }, { title: 'Visited', key: 'visited', width: '5%', render: (_: any, record: Location) => ( {record.addresses.map(addr => (
))} ) } ]; const selectedCut = cuts.find(c => c.id === selectedCutId); return (
{/* Controls - hidden when printing */}
QR Code 1 QR Code 2 QR Code 3
); }; export default MapSettingsPage; ``` ### CutExportPage.tsx - Statistics and CSV Export ```typescript import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { Button, Table, Card, Row, Col, Statistic, Space, message } from 'antd'; import { PrinterOutlined, DownloadOutlined } from '@ant-design/icons'; import { api } from '@/lib/api'; import type { Cut, Location } from '@/types/api'; const CutExportPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const cutId = parseInt(id); const [cut, setCut] = useState(null); const [locations, setLocations] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { fetchData(); }, [cutId]); const fetchData = async () => { setLoading(true); try { const [cutRes, locsRes] = await Promise.all([ api.get(`/cuts/${cutId}`), api.get<{ data: Location[] }>(`/locations?cutId=${cutId}`) ]); setCut(cutRes.data); setLocations(locsRes.data.data); } catch (error) { message.error('Failed to load cut data'); } finally { setLoading(false); } }; const calculateStats = () => { const totalLocations = locations.length; const totalUnits = locations.reduce((sum, loc) => sum + loc.addresses.length, 0); const geocoded = locations.filter(loc => loc.latitude && loc.longitude).length; const residential = locations.filter(loc => loc.buildingUse === 1).length; return { totalLocations, totalUnits, geocoded, geocodedPercent: totalLocations > 0 ? (geocoded / totalLocations) * 100 : 0, residential, residentialPercent: totalLocations > 0 ? (residential / totalLocations) * 100 : 0 }; }; const exportToCSV = () => { const headers = [ 'Address', 'Latitude', 'Longitude', 'Postal Code', 'Units', 'Residential' ]; const rows = locations.map(loc => [ loc.address, loc.latitude?.toFixed(6) || '', loc.longitude?.toFixed(6) || '', loc.postalCode || '', loc.addresses.length, loc.buildingUse === 1 ? 'Yes' : 'No' ]); const csv = [headers, ...rows] .map(row => row.map(cell => `"${cell}"`).join(',')) .join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `cut-${cutId}-${cut?.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`; link.click(); URL.revokeObjectURL(url); }; const handlePrint = () => { window.print(); }; const stats = calculateStats(); const columns = [ { title: 'Address', dataIndex: 'address', key: 'address' }, { title: 'Latitude', dataIndex: 'latitude', key: 'latitude', render: (val: number) => val?.toFixed(6) || 'N/A' }, { title: 'Longitude', dataIndex: 'longitude', key: 'longitude', render: (val: number) => val?.toFixed(6) || 'N/A' }, { title: 'Postal Code', dataIndex: 'postalCode', key: 'postalCode' }, { title: 'Units', key: 'units', render: (_: any, record: Location) => record.addresses.length }, { title: 'Residential', dataIndex: 'buildingUse', key: 'residential', render: (val: number) => val === 1 ? 'Yes' : 'No' } ]; return (

Cut Export Report

{cut?.name}

Generated: {new Date().toLocaleString()}

); }; export default CutExportPage; ``` ## Troubleshooting ### Problem: QR codes not generating **Symptoms:** - Empty QR code section on walk sheet - Console errors about `/api/qr/generate` - Network 404 or 500 errors **Solutions:** 1. **Verify endpoint accessibility:** ```bash curl -X POST http://localhost:4000/api/qr/generate \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com","size":200}' ``` 2. **Check qrcode package installed:** ```bash cd api npm list qrcode # If not installed: npm install qrcode npm install --save-dev @types/qrcode ``` 3. **Verify route registration in server.ts:** ```typescript import qrRoutes from './modules/qr/qr.routes'; app.use('/api/qr', qrRoutes); ``` 4. **Check URL validation:** ```typescript // URL must start with http:// or https:// const validUrls = [ 'https://example.com', // ✓ Valid 'http://example.com', // ✓ Valid 'example.com', // ✗ Invalid (missing protocol) 'ftp://example.com' // ✗ Invalid (wrong protocol) ]; ``` 5. **Test with simple URL:** ```typescript // Test with minimal payload const testQR = async () => { const { data } = await api.post('/qr/generate', { url: 'https://google.com' // size omitted (uses default 200) }); console.log('QR generated:', data.png.substring(0, 50)); }; ``` ### Problem: Print layout broken **Symptoms:** - Elements overlap when printing - Missing borders or backgrounds - Incorrect page breaks - Cut-off content **Solutions:** 1. **Enable background graphics in browser:** - Chrome: Print → More settings → Background graphics (checked) - Firefox: Print → Options → Print backgrounds (checked) - Safari: Print → Show Details → Print backgrounds (checked) 2. **Test print preview first:** ```typescript // Add print preview button for debugging const handlePrintPreview = () => { const printWindow = window.open('', '_blank'); printWindow?.document.write(document.documentElement.outerHTML); printWindow?.print(); }; ``` 3. **Check @page margins:** ```css @media print { @page { size: A4 portrait; margin: 0.5in; /* Adjust if content cut off */ } } ``` 4. **Prevent table row breaks:** ```css @media print { tr { page-break-inside: avoid; page-break-after: auto; } thead { display: table-header-group; /* Repeat on each page */ } } ``` 5. **Test in different browsers:** - Chrome/Edge: Best print CSS support - Firefox: Good, but some layout differences - Safari: May require webkit prefixes 6. **Adjust font sizes if content overflows:** ```css @media print { body { font-size: 9pt; } /* Reduce from 10pt */ th, td { font-size: 8pt; } /* Reduce from 9pt */ } ``` ### Problem: Walk sheet showing wrong cut **Symptoms:** - Selected cut shows different locations - Location count doesn't match cut - Locations outside cut boundary visible **Solutions:** 1. **Verify cutId in API request:** ```typescript console.log('Fetching locations for cut:', selectedCutId); const { data } = await api.get(`/locations?cutId=${selectedCutId}`); console.log('Received locations:', data.data.length); ``` 2. **Check point-in-polygon filter:** ```typescript // In locations.service.ts const locations = await prisma.location.findMany({ where: { AND: [ { latitude: { not: null } }, { longitude: { not: null } } ] } }); // Filter by cut boundary const cut = await prisma.cut.findUnique({ where: { id: cutId } }); const filtered = locations.filter(loc => isPointInPolygon([loc.longitude!, loc.latitude!], cut.geojson) ); console.log('Total locations:', locations.length); console.log('Within cut:', filtered.length); ``` 3. **Test with simple rectangular cut:** ```json { "type": "Polygon", "coordinates": [ [ [-79.40, 43.64], [-79.36, 43.64], [-79.36, 43.66], [-79.40, 43.66], [-79.40, 43.64] ] ] } ``` 4. **Verify GeoJSON coordinate order:** ```typescript // Correct: [longitude, latitude] const point = [loc.longitude, loc.latitude]; // ✓ // Incorrect: [latitude, longitude] const point = [loc.latitude, loc.longitude]; // ✗ ``` 5. **Check cut geojson validity:** - First and last coordinates must be identical (closed polygon) - Coordinates must be `[lng, lat]` order - Use http://geojson.io to visualize ### Problem: Large cuts slow to load **Symptoms:** - Walk sheet takes > 10 seconds to load - Browser freezes during render - Print preview crashes **Solutions:** 1. **Implement pagination:** ```typescript const LOCATIONS_PER_PAGE = 50; const [currentPage, setCurrentPage] = useState(1); const paginatedLocations = locations.slice( (currentPage - 1) * LOCATIONS_PER_PAGE, currentPage * LOCATIONS_PER_PAGE ); ``` 2. **Add location count warning:** ```typescript {locations.length > 200 && ( )} ``` 3. **Use virtual scrolling for preview:** ```typescript import { List } from 'react-virtualized'; // Render only visible rows during preview (
{renderLocationRow(locations[index])}
)} /> ``` 4. **Optimize QR code generation:** ```typescript // Generate QR codes only when print button clicked const [qrCodesGenerated, setQrCodesGenerated] = useState(false); const handlePrint = async () => { if (!qrCodesGenerated) { await generateQRCodes(); setQrCodesGenerated(true); } window.print(); }; ``` 5. **Split large cuts into multiple sheets:** ```typescript // Group by postal code prefix const groupedByPostal = locations.reduce((acc, loc) => { const prefix = loc.postalCode?.substring(0, 3) || 'Unknown'; if (!acc[prefix]) acc[prefix] = []; acc[prefix].push(loc); return acc; }, {} as Record); // Generate separate sheet per group Object.entries(groupedByPostal).forEach(([prefix, locs]) => { console.log(`${prefix}: ${locs.length} locations`); }); ``` ## Performance Considerations ### Client-Side Rendering **Walk Sheet Page Load:** - Initial load: ~500ms (fetch cuts + settings) - Cut selection: ~1-2 seconds (fetch locations + generate QR codes) - Large cuts (500+ locations): ~3-5 seconds - QR code generation: ~100ms per code (parallel) **Optimization Strategies:** 1. **Lazy load QR codes:** ```typescript // Only generate when visible const [qrCodesVisible, setQrCodesVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting && !qrCodesVisible) { generateQRCodes(); setQrCodesVisible(true); } }); observer.observe(qrSectionRef.current); return () => observer.disconnect(); }, []); ``` 2. **Cache QR codes in localStorage:** ```typescript const getCachedQR = (url: string): string | null => { const cached = localStorage.getItem(`qr:${url}`); if (cached) { const { png, timestamp } = JSON.parse(cached); // Cache valid for 24 hours if (Date.now() - timestamp < 24 * 60 * 60 * 1000) { return png; } } return null; }; const cacheQR = (url: string, png: string) => { localStorage.setItem(`qr:${url}`, JSON.stringify({ png, timestamp: Date.now() })); }; ``` 3. **Debounce cut selection:** ```typescript import { debounce } from 'lodash'; const debouncedFetchLocations = debounce((cutId: number) => { fetchLocations(cutId); }, 300);