2236 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<number, number>;
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<Cut[]>([]);
const [selectedCutId, setSelectedCutId] = useState<number | null>(null);
const [locations, setLocations] = useState<Location[]>([]);
const [settings, setSettings] = useState<MapSettings | null>(null);
const [qrCodes, setQrCodes] = useState<Record<number, string>>({});
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<Cut[]>('/cuts');
setCuts(data);
};
const fetchSettings = async () => {
const { data } = await api.get<MapSettings>('/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<number, string> = {};
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) => (
<Space direction="vertical" size={0}>
{record.addresses.map(addr => (
<Text key={addr.id}>{addr.unitNumber || '-'}</Text>
))}
</Space>
)
},
{
title: 'Name',
key: 'name',
width: '20%',
render: (_: any, record: Location) => (
<Space direction="vertical" size={0}>
{record.addresses.map(addr => (
<Text key={addr.id}>
{addr.firstName && addr.lastName
? `${addr.firstName} ${addr.lastName}`
: '-'}
</Text>
))}
</Space>
)
},
{
title: 'Support',
key: 'support',
width: '10%',
render: (_: any, record: Location) => (
<Space direction="vertical" size={0}>
{record.addresses.map(addr => (
<Text
key={addr.id}
className={addr.supportLevel ? `support-level-${addr.supportLevel}` : ''}
>
{addr.supportLevel || '-'}
</Text>
))}
</Space>
)
},
{
title: 'Notes',
key: 'notes',
width: '15%',
render: (_: any, record: Location) => (
<Space direction="vertical" size={0}>
{record.addresses.map(addr => (
<Text key={addr.id} ellipsis={{ tooltip: addr.notes }}>
{addr.notes || '-'}
</Text>
))}
</Space>
)
},
{
title: 'Visited',
key: 'visited',
width: '5%',
render: (_: any, record: Location) => (
<Space direction="vertical" size={0}>
{record.addresses.map(addr => (
<div key={addr.id} className="visited-checkbox" />
))}
</Space>
)
}
];
const selectedCut = cuts.find(c => c.id === selectedCutId);
return (
<div className="walk-sheet-page">
{/* Controls - hidden when printing */}
<div className="no-print" style={{ marginBottom: 24 }}>
<Space>
<Select
style={{ width: 300 }}
placeholder="Select a cut"
value={selectedCutId}
onChange={setSelectedCutId}
options={cuts.map(cut => ({
label: cut.name,
value: cut.id
}))}
/>
<Button
type="primary"
icon={<PrinterOutlined />}
onClick={handlePrint}
disabled={!selectedCutId || loading}
>
Print
</Button>
</Space>
</div>
{/* Walk Sheet Content - printed */}
{selectedCutId && settings && (
<>
{/* Header */}
<div className="walk-sheet-header">
<Title level={2} className="walk-sheet-title">
{settings.walkSheetTitle}
</Title>
{settings.walkSheetSubtitle && (
<Text className="walk-sheet-subtitle">
{settings.walkSheetSubtitle}
</Text>
)}
<div style={{ marginTop: 8 }}>
<Text strong>Cut: </Text>
<Text>{selectedCut?.name}</Text>
<br />
<Text strong>Date: </Text>
<Text>{new Date().toLocaleDateString()}</Text>
</div>
</div>
{/* Address Table */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : (
<Table
dataSource={locations}
columns={columns}
pagination={false}
rowKey="id"
bordered
/>
)}
{/* QR Codes */}
{Object.keys(qrCodes).length > 0 && (
<Row gutter={16} className="qr-code-section">
{[1, 2, 3].map(index => {
const qrUrl = settings[`qrCode${index}Url` as keyof MapSettings];
const qrLabel = settings[`qrCode${index}Label` as keyof MapSettings];
if (!qrUrl || !qrCodes[index]) return null;
return (
<Col key={index} span={8} className="qr-code-item">
<img src={qrCodes[index]} alt={`QR Code ${index}`} />
<div className="qr-code-label">{qrLabel}</div>
</Col>
);
})}
</Row>
)}
{/* Footer */}
{settings.walkSheetFooter && (
<div className="walk-sheet-footer">
{settings.walkSheetFooter}
</div>
)}
</>
)}
{/* Print Styles */}
<style>{`
@media print {
.no-print {
display: none !important;
}
@page {
size: A4 portrait;
margin: 0.5in;
}
body {
font-size: 10pt;
line-height: 1.4;
}
.walk-sheet-header {
text-align: center;
margin-bottom: 20px;
border-bottom: 2px solid #000;
padding-bottom: 10px;
}
.walk-sheet-title {
font-size: 16pt !important;
margin-bottom: 5px !important;
}
.walk-sheet-subtitle {
font-size: 12pt;
}
table {
page-break-inside: avoid;
}
th, td {
font-size: 9pt !important;
padding: 6px !important;
}
.visited-checkbox {
width: 15px;
height: 15px;
border: 1px solid #000;
display: inline-block;
}
.support-level-1 { color: #e74c3c; }
.support-level-2 { color: #f39c12; }
.support-level-3 { color: #95a5a6; }
.support-level-4 { color: #3498db; }
.support-level-5 { color: #27ae60; }
.qr-code-section {
display: flex;
justify-content: space-around;
margin: 20px 0;
page-break-inside: avoid;
}
.qr-code-item {
text-align: center;
}
.qr-code-item img {
width: 150px;
height: 150px;
}
.qr-code-label {
font-size: 9pt;
font-weight: bold;
margin-top: 5px;
}
.walk-sheet-footer {
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #000;
font-size: 9pt;
white-space: pre-wrap;
}
}
`}</style>
</div>
);
};
export default WalkSheetPage;
```
### QR Code API - qr.routes.ts
```typescript
import { Router } from 'express';
import QRCode from 'qrcode';
import { z } from 'zod';
import { validate } from '@/middleware/validate';
import rateLimit from 'express-rate-limit';
const router = Router();
// Rate limiter: 100 requests per 15 minutes
const qrLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many QR code requests, please try again later'
});
const generateQRSchema = z.object({
body: z.object({
url: z.string().url('Must be a valid URL'),
size: z.number().int().min(50).max(500).optional().default(200)
})
});
/**
* POST /api/qr/generate
* Generate QR code PNG from URL
* Public endpoint (no authentication)
*/
router.post(
'/generate',
qrLimiter,
validate(generateQRSchema),
async (req, res, next) => {
try {
const { url, size } = req.body;
// Generate QR code as data URL
const png = await QRCode.toDataURL(url, {
width: size,
margin: 4,
errorCorrectionLevel: 'M',
type: 'image/png'
});
res.json({ png });
} catch (error) {
next(error);
}
}
);
export default router;
```
### MapSettingsPage.tsx - QR Code Configuration
```typescript
import React, { useEffect } from 'react';
import { Form, Input, Button, message, Divider, Space, Typography } from 'antd';
import { api } from '@/lib/api';
import type { MapSettings } from '@/types/api';
const { Title, Text } = Typography;
const MapSettingsPage: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
setLoading(true);
try {
const { data } = await api.get<MapSettings>('/map-settings');
form.setFieldsValue(data);
} catch (error) {
message.error('Failed to load map settings');
} finally {
setLoading(false);
}
};
const handleSubmit = async (values: Partial<MapSettings>) => {
setLoading(true);
try {
await api.put('/map-settings', values);
message.success('Settings saved successfully');
} catch (error) {
message.error('Failed to save settings');
} finally {
setLoading(false);
}
};
return (
<div>
<Title level={2}>Map Settings</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
disabled={loading}
>
<Divider orientation="left">Walk Sheet Configuration</Divider>
<Form.Item
label="Walk Sheet Title"
name="walkSheetTitle"
rules={[
{ required: true, message: 'Title is required' },
{ max: 100, message: 'Maximum 100 characters' }
]}
>
<Input placeholder="Walk Sheet" />
</Form.Item>
<Form.Item
label="Walk Sheet Subtitle"
name="walkSheetSubtitle"
rules={[{ max: 200, message: 'Maximum 200 characters' }]}
>
<Input placeholder="Ward 10 - November 2025" />
</Form.Item>
<Form.Item
label="Walk Sheet Footer"
name="walkSheetFooter"
rules={[{ max: 500, message: 'Maximum 500 characters' }]}
>
<Input.TextArea
rows={4}
placeholder="Contact information, instructions, etc."
/>
</Form.Item>
<Divider orientation="left">QR Code 1</Divider>
<Form.Item
label="QR Code 1 URL"
name="qrCode1Url"
rules={[{ type: 'url', message: 'Must be a valid URL' }]}
>
<Input placeholder="https://example.com/campaign" />
</Form.Item>
<Form.Item
label="QR Code 1 Label"
name="qrCode1Label"
rules={[{ max: 50, message: 'Maximum 50 characters' }]}
>
<Input placeholder="Campaign Page" />
</Form.Item>
<Divider orientation="left">QR Code 2</Divider>
<Form.Item
label="QR Code 2 URL"
name="qrCode2Url"
rules={[{ type: 'url', message: 'Must be a valid URL' }]}
>
<Input placeholder="https://example.com/volunteer" />
</Form.Item>
<Form.Item
label="QR Code 2 Label"
name="qrCode2Label"
rules={[{ max: 50, message: 'Maximum 50 characters' }]}
>
<Input placeholder="Volunteer Info" />
</Form.Item>
<Divider orientation="left">QR Code 3</Divider>
<Form.Item
label="QR Code 3 URL"
name="qrCode3Url"
rules={[{ type: 'url', message: 'Must be a valid URL' }]}
>
<Input placeholder="https://example.com/donate" />
</Form.Item>
<Form.Item
label="QR Code 3 Label"
name="qrCode3Label"
rules={[{ max: 50, message: 'Maximum 50 characters' }]}
>
<Input placeholder="Donate Now" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={loading}>
Save Settings
</Button>
<Button onClick={fetchSettings}>Reset</Button>
</Space>
</Form.Item>
</Form>
</div>
);
};
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<Cut | null>(null);
const [locations, setLocations] = useState<Location[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchData();
}, [cutId]);
const fetchData = async () => {
setLoading(true);
try {
const [cutRes, locsRes] = await Promise.all([
api.get<Cut>(`/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 (
<div className="cut-export-page">
<div className="no-print" style={{ marginBottom: 24 }}>
<Space>
<Button icon={<PrinterOutlined />} onClick={handlePrint}>
Print
</Button>
<Button icon={<DownloadOutlined />} onClick={exportToCSV}>
Export CSV
</Button>
</Space>
</div>
<div className="cut-export-header">
<h1>Cut Export Report</h1>
<h2>{cut?.name}</h2>
<p>Generated: {new Date().toLocaleString()}</p>
</div>
<Card title="Statistics" style={{ marginBottom: 24 }}>
<Row gutter={16}>
<Col span={6}>
<Statistic title="Total Locations" value={stats.totalLocations} />
</Col>
<Col span={6}>
<Statistic title="Total Units" value={stats.totalUnits} />
</Col>
<Col span={6}>
<Statistic
title="Geocoded"
value={stats.geocoded}
suffix={`(${stats.geocodedPercent.toFixed(1)}%)`}
/>
</Col>
<Col span={6}>
<Statistic
title="Residential"
value={stats.residential}
suffix={`(${stats.residentialPercent.toFixed(1)}%)`}
/>
</Col>
</Row>
</Card>
<Table
dataSource={locations}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ pageSize: 50 }}
/>
<style>{`
@media print {
.no-print { display: none !important; }
@page { size: A4 landscape; margin: 0.5in; }
body { font-size: 10pt; }
}
`}</style>
</div>
);
};
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 && (
<Alert
message="Large Cut"
description={`This cut has ${locations.length} locations. Consider splitting into smaller sheets.`}
type="warning"
showIcon
/>
)}
```
3. **Use virtual scrolling for preview:**
```typescript
import { List } from 'react-virtualized';
// Render only visible rows during preview
<List
height={600}
rowCount={locations.length}
rowHeight={40}
rowRenderer={({ index, style }) => (
<div style={style}>{renderLocationRow(locations[index])}</div>
)}
/>
```
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<string, Location[]>);
// 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);
<Select onChange={debouncedFetchLocations} />
```
### Server-Side Performance
**API Response Times:**
- GET /api/map-settings: ~50ms (singleton query)
- GET /api/cuts/:id: ~100ms (single record + geojson)
- GET /api/locations?cutId=X: ~500ms-2s (depends on cut size)
- POST /api/qr/generate: ~50ms (QRCode.toDataURL is fast)
**Database Optimization:**
```sql
-- Index for cut location queries
CREATE INDEX idx_locations_coords ON "Location"(latitude, longitude);
-- Index for address sorting
CREATE INDEX idx_locations_address ON "Location"(address);
-- Composite index for geocoded locations
CREATE INDEX idx_locations_geocoded ON "Location"(latitude, longitude)
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
```
**Query Optimization:**
```typescript
// Use select to limit fields
const locations = await prisma.location.findMany({
where: { /* filters */ },
select: {
id: true,
address: true,
latitude: true,
longitude: true,
postalCode: true,
addresses: {
select: {
id: true,
unitNumber: true,
firstName: true,
lastName: true,
supportLevel: true,
notes: true
},
orderBy: { unitNumber: 'asc' }
}
}
});
```
### Print Performance
**Print Dialog Load Time:**
- Small walk sheets (<50 locations): Instant
- Medium (50-200 locations): 1-2 seconds
- Large (200-500 locations): 3-5 seconds
- Very large (500+ locations): Consider pagination
**Browser Print Limits:**
- Chrome: ~1000 table rows before slowdown
- Firefox: ~800 table rows
- Safari: ~600 table rows
**Optimization:**
- Use `page-break-inside: avoid` sparingly
- Minimize complex CSS in print rules
- Avoid large images (QR codes already optimized at 150px)
- Split very large cuts into multiple PDFs
## Related Documentation
### Backend Documentation
- **QR Code Generation:** `api/src/modules/qr/qr.routes.ts`
- QRCode.toDataURL() wrapper
- Rate limiting (100/15min)
- Size validation (50-500px)
- **Map Settings:** `api/src/modules/map/settings/`
- MapSettings singleton CRUD
- Walk sheet configuration
- QR code URL storage
- **Cuts API:** `api/src/modules/map/cuts/`
- Cut CRUD operations
- GeoJSON polygon storage
- Point-in-polygon filtering
- **Locations API:** `api/src/modules/map/locations/`
- Location CRUD with cut filtering
- Address relations
- Support level tracking
### Frontend Documentation
- **Walk Sheet Page:** `admin/src/pages/WalkSheetPage.tsx`
- Cut selection dropdown
- Location table rendering
- QR code display
- Print functionality
- **Cut Export Page:** `admin/src/pages/CutExportPage.tsx`
- Statistics calculation
- CSV export
- Print layout
- **Map Settings Page:** `admin/src/pages/MapSettingsPage.tsx`
- Walk sheet configuration form
- QR code URL/label inputs
- Settings persistence
### Database Documentation
- **Models:** `api/prisma/schema.prisma`
- MapSettings (singleton)
- Cut (geojson polygon)
- Location (geocoded addresses)
- Address (unit-level data)
### External Resources
- **QRCode.js:** https://github.com/soldair/node-qrcode
- PNG generation API
- Error correction levels
- Size/margin options
- **CSS Print Media:** https://developer.mozilla.org/en-US/docs/Web/CSS/@media/print
- @media print rules
- @page configuration
- page-break properties
- **GeoJSON Specification:** https://geojson.org/
- Polygon format
- Coordinate order ([lng, lat])
- MultiPolygon support