58 KiB
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
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:
-
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)
-
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
-
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
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
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:
{
"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
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
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:
{
"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:
{
"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:
{
"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 filteringsortBy(optional): Field to sort by (default: "address")order(optional): "asc" or "desc" (default: "asc")
Response:
{
"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:
// 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:
{
"url": "https://example.com/campaign",
"size": 200
}
Parameters:
url(required): Target URL for QR codesize(optional): QR code dimension in pixels (default: 200, max: 500)
Response:
{
"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:
@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
- Log in as SUPER_ADMIN or MAP_ADMIN
- Click Settings in sidebar
- Click Map Settings submenu
- Scroll to "Walk Sheet Configuration" section
Step 2: Set Title and Subtitle
Walk Sheet Title: "Toronto Canvass Walk Sheet"
Walk Sheet Subtitle: "Ward 10 - November 2025 Campaign"
Step 3: Configure QR Codes
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
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
- Click Map in sidebar
- Click Walk Sheet submenu
- Walk sheet generator page loads
Step 2: Select Cut
- Click Select Cut dropdown
- Choose cut from list (e.g., "Downtown Core")
- Loading indicator shows while fetching locations
- Location count displayed (e.g., "150 locations")
Step 3: Preview Walk Sheet
Walk sheet displays:
┌─────────────────────────────────────────────┐
│ 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
- Click Print button (top-right corner)
- Browser print dialog opens
- Configure print settings:
- Destination: Printer or "Save as PDF"
- Pages: All
- Layout: Portrait
- Margins: Default
- Background graphics: Enabled
- 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
- Click Map → Cuts in sidebar
- Cuts table loads with list of all cuts
Step 2: Open Cut Export
- Find cut row (e.g., "Downtown Core")
- Click Export button in Actions column
- New tab opens with export report
Step 3: Review Statistics
Export report shows:
┌─────────────────────────────────────────────┐
│ 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
- Click Export CSV button
- File downloads:
cut-42-downtown-core-2026-02-13.csv - Open in spreadsheet for further analysis
CSV Format:
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
┌─────────────────────────────────────────────┐
│ [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
@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:
┌───────────────────┬──────┬────────────┬─────────┬────────┬─────────┐
│ 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:
[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:
┌─────────────────────────────────────────────┐
│ 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:
- Total Locations: Count of locations within cut
- Total Units: Sum of addresses across all locations
- Geocoded Locations: Locations with lat/lng (% of total)
- Missing Coordinates: Locations without lat/lng
- Residential Units: Units with buildingUse = 1
- Commercial Units: Units with buildingUse != 1
- Support Level Breakdown: Count by level (1-5)
- Cut Area: Approximate area in square kilometers
Statistics Calculation:
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:
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:
"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
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
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
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
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:
- Verify endpoint accessibility:
curl -X POST http://localhost:4000/api/qr/generate \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","size":200}'
- Check qrcode package installed:
cd api
npm list qrcode
# If not installed:
npm install qrcode
npm install --save-dev @types/qrcode
- Verify route registration in server.ts:
import qrRoutes from './modules/qr/qr.routes';
app.use('/api/qr', qrRoutes);
- Check URL validation:
// 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)
];
- Test with simple URL:
// 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:
-
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)
-
Test print preview first:
// Add print preview button for debugging
const handlePrintPreview = () => {
const printWindow = window.open('', '_blank');
printWindow?.document.write(document.documentElement.outerHTML);
printWindow?.print();
};
- Check @page margins:
@media print {
@page {
size: A4 portrait;
margin: 0.5in; /* Adjust if content cut off */
}
}
- Prevent table row breaks:
@media print {
tr {
page-break-inside: avoid;
page-break-after: auto;
}
thead {
display: table-header-group; /* Repeat on each page */
}
}
-
Test in different browsers:
- Chrome/Edge: Best print CSS support
- Firefox: Good, but some layout differences
- Safari: May require webkit prefixes
-
Adjust font sizes if content overflows:
@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:
- Verify cutId in API request:
console.log('Fetching locations for cut:', selectedCutId);
const { data } = await api.get(`/locations?cutId=${selectedCutId}`);
console.log('Received locations:', data.data.length);
- Check point-in-polygon filter:
// 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);
- Test with simple rectangular cut:
{
"type": "Polygon",
"coordinates": [
[
[-79.40, 43.64],
[-79.36, 43.64],
[-79.36, 43.66],
[-79.40, 43.66],
[-79.40, 43.64]
]
]
}
- Verify GeoJSON coordinate order:
// Correct: [longitude, latitude]
const point = [loc.longitude, loc.latitude]; // ✓
// Incorrect: [latitude, longitude]
const point = [loc.latitude, loc.longitude]; // ✗
- 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:
- Implement pagination:
const LOCATIONS_PER_PAGE = 50;
const [currentPage, setCurrentPage] = useState(1);
const paginatedLocations = locations.slice(
(currentPage - 1) * LOCATIONS_PER_PAGE,
currentPage * LOCATIONS_PER_PAGE
);
- Add location count warning:
{locations.length > 200 && (
<Alert
message="Large Cut"
description={`This cut has ${locations.length} locations. Consider splitting into smaller sheets.`}
type="warning"
showIcon
/>
)}
- Use virtual scrolling for preview:
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>
)}
/>
- Optimize QR code generation:
// Generate QR codes only when print button clicked
const [qrCodesGenerated, setQrCodesGenerated] = useState(false);
const handlePrint = async () => {
if (!qrCodesGenerated) {
await generateQRCodes();
setQrCodesGenerated(true);
}
window.print();
};
- Split large cuts into multiple sheets:
// 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:
- Lazy load QR codes:
// 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();
}, []);
- Cache QR codes in localStorage:
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()
}));
};
- Debounce cut selection:
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/🆔 ~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:
-- 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:
// 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: avoidsparingly - 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