2236 lines
58 KiB
Markdown
2236 lines
58 KiB
Markdown
# 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
|