| @@ -31,7 +31,20 @@ | |||
| "PowerShell(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\\\\database\\\\migrations\" | ForEach-Object { $_.Name })", | |||
| "Bash(composer require *)", | |||
| "PowerShell(Get-Command php -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source; Get-ChildItem \"C:\\\\\", \"C:\\\\php\", \"C:\\\\xampp\", \"C:\\\\wamp\" -ErrorAction SilentlyContinue -Depth 1 | Where-Object { $_.Name -match \"composer|php\" })", | |||
| "PowerShell($env:PATH -split \";\" | Where-Object { $_ -match \"php|laragon|xampp|wamp\" })" | |||
| "PowerShell($env:PATH -split \";\" | Where-Object { $_ -match \"php|laragon|xampp|wamp\" })", | |||
| "PowerShell(cd \"$env:TEMP\\\\temp-asp-territory\"; dir -Recurse -Depth 2 | Select-Object FullName, PSIsContainer | Format-Table -AutoSize)", | |||
| "Read(//c//**)", | |||
| "PowerShell($r = Invoke-WebRequest -Uri \"http://localhost:8080/territories\" -MaximumRedirection 0 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; $r.Headers['Location'])", | |||
| "PowerShell(docker compose ps)", | |||
| "PowerShell(docker compose logs app --tail=10 2>&1)", | |||
| "PowerShell(Start-Sleep -Seconds 3; Invoke-WebRequest -Uri \"http://localhost:8080/login\" -MaximumRedirection 0 -ErrorAction SilentlyContinue | Select-Object StatusCode, @{N='Body';E={$_.Content.Substring\\(0,[Math]::Min\\(200,$_.Content.Length\\)\\)}})", | |||
| "PowerShell(cd \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\"; docker compose up -d app 2>&1; Start-Sleep -Seconds 4; Invoke-WebRequest -Uri \"http://localhost:8080/territories\" -MaximumRedirection 0 -ErrorAction SilentlyContinue | Select-Object StatusCode, @{N='Location';E={$_.Headers.Location}})", | |||
| "PowerShell($r = Invoke-WebRequest -Uri \"http://localhost:8080/territories\" -MaximumRedirection 5 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; \\($r.Content -split \"`n\" | Select-Object -First 3\\) -join \"`n\")", | |||
| "PowerShell($r = Invoke-WebRequest -Uri \"http://localhost:8080/households\" -MaximumRedirection 5 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; $r.Content -match \"Sign in\")", | |||
| "PowerShell($r = Invoke-WebRequest -Uri \"http://localhost:8080/export\" -MaximumRedirection 5 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; $r.Content -match \"Sign in\")", | |||
| "PowerShell($r = Invoke-WebRequest -Uri \"http://localhost:8080/export\" -MaximumRedirection 5 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; $r.Content.Substring\\(0, [Math]::Min\\(300, $r.Content.Length\\)\\))", | |||
| "PowerShell(Start-Sleep -Seconds 2; $r = Invoke-WebRequest -Uri \"http://localhost:8080/export\" -MaximumRedirection 5 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; $r.Content -match \"Sign in\")", | |||
| "Bash(php -r ' *)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Repositories\HouseholdRepository; | |||
| use App\Repositories\TerritoryRepository; | |||
| use App\Services\ExportService; | |||
| use Core\Controller; | |||
| use Core\Request; | |||
| use Core\Response; | |||
| class ExportController extends Controller | |||
| { | |||
| public function generate(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $territories = (new TerritoryRepository(database()))->allOrdered(); | |||
| return $this->view('export.generate', [ | |||
| 'pageTitle' => 'Export Territories', | |||
| 'territories' => $territories, | |||
| ]); | |||
| } | |||
| public function download(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| flash('error', 'Invalid request.'); | |||
| return $this->redirect('/export'); | |||
| } | |||
| $selectedIds = $request->input('territory_ids'); | |||
| if (!is_array($selectedIds) || empty($selectedIds)) { | |||
| flash('error', 'Please select at least one territory to export.'); | |||
| return $this->redirect('/export'); | |||
| } | |||
| $ids = array_map('intval', $selectedIds); | |||
| $ids = array_filter($ids, fn(int $id) => $id > 0); | |||
| if (empty($ids)) { | |||
| flash('error', 'Please select at least one territory to export.'); | |||
| return $this->redirect('/export'); | |||
| } | |||
| $territoryRepo = new TerritoryRepository(database()); | |||
| $territories = array_filter( | |||
| array_map(fn(int $id) => $territoryRepo->find($id), $ids) | |||
| ); | |||
| if (empty($territories)) { | |||
| flash('error', 'Selected territories not found.'); | |||
| return $this->redirect('/export'); | |||
| } | |||
| $households = (new HouseholdRepository(database()))->findAllByTerritories($ids); | |||
| $zip = (new ExportService())->buildZip(array_values($territories), $households); | |||
| $filename = 'territory_export_' . date('Ymd_His') . '.zip'; | |||
| return $this->fileResponse($zip, $filename, 'application/zip'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,266 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Repositories\HouseholdRepository; | |||
| use App\Repositories\HouseholderNameRepository; | |||
| use App\Repositories\TerritoryRepository; | |||
| use Core\Controller; | |||
| use Core\Pagination; | |||
| use Core\Request; | |||
| use Core\Response; | |||
| use Core\Validator; | |||
| class HouseholdController extends Controller | |||
| { | |||
| public function index(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $search = (string) ($request->input('search') ?? ''); | |||
| $territoryId = (string) ($request->input('territory_id') ?? ''); | |||
| $doNotCall = (string) ($request->input('do_not_call') ?? ''); | |||
| $page = max(1, (int) ($request->input('page') ?? 1)); | |||
| $perPage = 20; | |||
| $repo = new HouseholdRepository(database()); | |||
| $total = $repo->countAll($search, $territoryId, $doNotCall); | |||
| $pagination = new Pagination($total, $page, $perPage); | |||
| $households = $repo->findPaged($page, $perPage, $search, $territoryId, $doNotCall); | |||
| $territories = (new TerritoryRepository(database()))->allOrdered(); | |||
| return $this->view('households.index', [ | |||
| 'pageTitle' => 'Households', | |||
| 'households' => $households, | |||
| 'territories' => $territories, | |||
| 'search' => $search, | |||
| 'territoryId' => $territoryId, | |||
| 'doNotCall' => $doNotCall, | |||
| 'pagination' => $pagination, | |||
| ]); | |||
| } | |||
| public function show(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $household = (new HouseholdRepository(database()))->findWithTerritory((int) $id); | |||
| if (!$household) { | |||
| return Response::notFound('Household not found.'); | |||
| } | |||
| $names = (new HouseholderNameRepository(database()))->findAllByHousehold((int) $id); | |||
| $maps = maps_config(); | |||
| return $this->view('households.show', [ | |||
| 'pageTitle' => e($household['address']), | |||
| 'household' => $household, | |||
| 'names' => $names, | |||
| 'maps' => $maps, | |||
| ]); | |||
| } | |||
| public function create(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $territories = (new TerritoryRepository(database()))->allOrdered(); | |||
| return $this->view('households.create', [ | |||
| 'pageTitle' => 'New Household', | |||
| 'territories' => $territories, | |||
| 'defaultTerritoryId' => (string) ($request->input('territory_id') ?? ''), | |||
| 'errors' => [], | |||
| 'old' => [], | |||
| ]); | |||
| } | |||
| public function store(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $territories = (new TerritoryRepository(database()))->allOrdered(); | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| return $this->view('households.create', [ | |||
| 'pageTitle' => 'New Household', | |||
| 'territories' => $territories, | |||
| 'defaultTerritoryId' => '', | |||
| 'errors' => ['_token' => ['Invalid request. Please try again.']], | |||
| 'old' => $request->all(), | |||
| ]); | |||
| } | |||
| $fields = $this->extractFields($request); | |||
| $validator = (new Validator()) | |||
| ->required('territory_id', $fields['territory_id'], 'Territory is required.') | |||
| ->required('address', $fields['address'], 'Address is required.') | |||
| ->maxLength('address', $fields['address'], 255) | |||
| ->optionalNumeric('street_number', $fields['street_number']) | |||
| ->optionalNumeric('latitude', $fields['latitude'], 'Latitude must be a number.') | |||
| ->optionalNumeric('longitude', $fields['longitude'], 'Longitude must be a number.'); | |||
| if ($validator->fails()) { | |||
| return $this->view('households.create', [ | |||
| 'pageTitle' => 'New Household', | |||
| 'territories' => $territories, | |||
| 'defaultTerritoryId' => $fields['territory_id'], | |||
| 'errors' => $validator->errors(), | |||
| 'old' => $request->all(), | |||
| ]); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| database()->execute( | |||
| 'INSERT INTO households | |||
| (territory_id, address, street_number, street_name, latitude, longitude, | |||
| is_business, do_not_call, do_not_call_date, do_not_call_notes, | |||
| do_not_call_private_notes, created_at, updated_at) | |||
| VALUES | |||
| (:territory_id, :address, :street_number, :street_name, :latitude, :longitude, | |||
| :is_business, :do_not_call, :do_not_call_date, :do_not_call_notes, | |||
| :do_not_call_private_notes, :now, :now)', | |||
| array_merge($fields, ['now' => $now]) | |||
| ); | |||
| flash('success', 'Household created.'); | |||
| return $this->redirect('/households'); | |||
| } | |||
| public function edit(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $household = (new HouseholdRepository(database()))->find((int) $id); | |||
| if (!$household) { | |||
| return Response::notFound('Household not found.'); | |||
| } | |||
| $territories = (new TerritoryRepository(database()))->allOrdered(); | |||
| return $this->view('households.edit', [ | |||
| 'pageTitle' => 'Edit Household', | |||
| 'household' => $household, | |||
| 'territories' => $territories, | |||
| 'errors' => [], | |||
| ]); | |||
| } | |||
| public function update(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $household = (new HouseholdRepository(database()))->find((int) $id); | |||
| if (!$household) { | |||
| return Response::notFound('Household not found.'); | |||
| } | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| flash('error', 'Invalid request.'); | |||
| return $this->redirect('/households/' . $id . '/edit'); | |||
| } | |||
| $fields = $this->extractFields($request); | |||
| $validator = (new Validator()) | |||
| ->required('territory_id', $fields['territory_id'], 'Territory is required.') | |||
| ->required('address', $fields['address'], 'Address is required.') | |||
| ->maxLength('address', $fields['address'], 255) | |||
| ->optionalNumeric('street_number', $fields['street_number']) | |||
| ->optionalNumeric('latitude', $fields['latitude'], 'Latitude must be a number.') | |||
| ->optionalNumeric('longitude', $fields['longitude'], 'Longitude must be a number.'); | |||
| $territories = (new TerritoryRepository(database()))->allOrdered(); | |||
| if ($validator->fails()) { | |||
| return $this->view('households.edit', [ | |||
| 'pageTitle' => 'Edit Household', | |||
| 'household' => array_merge($household, $fields), | |||
| 'territories' => $territories, | |||
| 'errors' => $validator->errors(), | |||
| ]); | |||
| } | |||
| database()->execute( | |||
| 'UPDATE households SET | |||
| territory_id = :territory_id, address = :address, | |||
| street_number = :street_number, street_name = :street_name, | |||
| latitude = :latitude, longitude = :longitude, | |||
| is_business = :is_business, do_not_call = :do_not_call, | |||
| do_not_call_date = :do_not_call_date, do_not_call_notes = :do_not_call_notes, | |||
| do_not_call_private_notes = :do_not_call_private_notes, | |||
| updated_at = :now | |||
| WHERE id = :id', | |||
| array_merge($fields, ['now' => date('Y-m-d H:i:s'), 'id' => $id]) | |||
| ); | |||
| flash('success', 'Household updated.'); | |||
| return $this->redirect('/households/' . $id); | |||
| } | |||
| public function delete(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $household = (new HouseholdRepository(database()))->find((int) $id); | |||
| if (!$household) { | |||
| return Response::notFound('Household not found.'); | |||
| } | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| flash('error', 'Invalid request.'); | |||
| return $this->redirect('/households'); | |||
| } | |||
| database()->execute('DELETE FROM householder_names WHERE household_id = :id', ['id' => $id]); | |||
| database()->execute('DELETE FROM households WHERE id = :id', ['id' => $id]); | |||
| flash('success', 'Household deleted.'); | |||
| return $this->redirect('/households'); | |||
| } | |||
| /** @return array<string,mixed> */ | |||
| private function extractFields(Request $request): array | |||
| { | |||
| $doNotCall = (bool) $request->input('do_not_call'); | |||
| return [ | |||
| 'territory_id' => (string) ($request->input('territory_id') ?? ''), | |||
| 'address' => trim((string) ($request->input('address') ?? '')), | |||
| 'street_number' => $request->input('street_number') !== '' ? $request->input('street_number') : null, | |||
| 'street_name' => trim((string) ($request->input('street_name') ?? '')), | |||
| 'latitude' => $request->input('latitude') !== '' ? $request->input('latitude') : null, | |||
| 'longitude' => $request->input('longitude') !== '' ? $request->input('longitude') : null, | |||
| 'is_business' => (int) (bool) $request->input('is_business'), | |||
| 'do_not_call' => (int) $doNotCall, | |||
| 'do_not_call_date' => $doNotCall ? (trim((string) ($request->input('do_not_call_date') ?? '')) ?: null) : null, | |||
| 'do_not_call_notes' => trim((string) ($request->input('do_not_call_notes') ?? '')), | |||
| 'do_not_call_private_notes' => trim((string) ($request->input('do_not_call_private_notes') ?? '')), | |||
| ]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,253 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Repositories\HouseholdRepository; | |||
| use App\Repositories\HouseholderNameRepository; | |||
| use Core\Controller; | |||
| use Core\Pagination; | |||
| use Core\Request; | |||
| use Core\Response; | |||
| use Core\Validator; | |||
| class HouseholderNameController extends Controller | |||
| { | |||
| public function index(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $search = (string) ($request->input('search') ?? ''); | |||
| $householdId = (string) ($request->input('household_id') ?? ''); | |||
| $page = max(1, (int) ($request->input('page') ?? 1)); | |||
| $perPage = 20; | |||
| $repo = new HouseholderNameRepository(database()); | |||
| $total = $repo->countAll($search, $householdId); | |||
| $pagination = new Pagination($total, $page, $perPage); | |||
| $names = $repo->findPaged($page, $perPage, $search, $householdId); | |||
| return $this->view('householder-names.index', [ | |||
| 'pageTitle' => 'Householder Names', | |||
| 'names' => $names, | |||
| 'search' => $search, | |||
| 'householdId' => $householdId, | |||
| 'pagination' => $pagination, | |||
| ]); | |||
| } | |||
| public function show(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $name = (new HouseholderNameRepository(database()))->findWithHousehold((int) $id); | |||
| if (!$name) { | |||
| return Response::notFound('Householder name not found.'); | |||
| } | |||
| return $this->view('householder-names.show', [ | |||
| 'pageTitle' => e($name['name']), | |||
| 'name' => $name, | |||
| ]); | |||
| } | |||
| public function create(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $households = (new HouseholdRepository(database()))->all(); | |||
| return $this->view('householder-names.create', [ | |||
| 'pageTitle' => 'New Householder Name', | |||
| 'households' => $households, | |||
| 'defaultHouseholdId' => (string) ($request->input('household_id') ?? ''), | |||
| 'errors' => [], | |||
| 'old' => [], | |||
| ]); | |||
| } | |||
| public function store(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $households = (new HouseholdRepository(database()))->all(); | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| return $this->view('householder-names.create', [ | |||
| 'pageTitle' => 'New Householder Name', | |||
| 'households' => $households, | |||
| 'defaultHouseholdId' => '', | |||
| 'errors' => ['_token' => ['Invalid request. Please try again.']], | |||
| 'old' => $request->all(), | |||
| ]); | |||
| } | |||
| $householdId = (string) ($request->input('household_id') ?? ''); | |||
| $name = trim((string) ($request->input('name') ?? '')); | |||
| $letterReturned = (int) (bool) $request->input('letter_returned'); | |||
| $returnDate = trim((string) ($request->input('return_date') ?? '')) ?: null; | |||
| $validator = (new Validator()) | |||
| ->required('household_id', $householdId, 'Household is required.') | |||
| ->required('name', $name, 'Name is required.') | |||
| ->maxLength('name', $name, 255); | |||
| if ($validator->fails()) { | |||
| return $this->view('householder-names.create', [ | |||
| 'pageTitle' => 'New Householder Name', | |||
| 'households' => $households, | |||
| 'defaultHouseholdId' => $householdId, | |||
| 'errors' => $validator->errors(), | |||
| 'old' => $request->all(), | |||
| ]); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| database()->execute( | |||
| 'INSERT INTO householder_names (household_id, name, letter_returned, return_date, created_at, updated_at) | |||
| VALUES (:household_id, :name, :letter_returned, :return_date, :now, :now)', | |||
| ['household_id' => $householdId, 'name' => $name, | |||
| 'letter_returned' => $letterReturned, 'return_date' => $returnDate, 'now' => $now] | |||
| ); | |||
| flash('success', "Householder name \"{$name}\" created."); | |||
| return $this->redirect('/householder-names'); | |||
| } | |||
| public function edit(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $name = (new HouseholderNameRepository(database()))->find((int) $id); | |||
| if (!$name) { | |||
| return Response::notFound('Householder name not found.'); | |||
| } | |||
| $households = (new HouseholdRepository(database()))->all(); | |||
| return $this->view('householder-names.edit', [ | |||
| 'pageTitle' => 'Edit Householder Name', | |||
| 'name' => $name, | |||
| 'households' => $households, | |||
| 'errors' => [], | |||
| ]); | |||
| } | |||
| public function update(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $repo = new HouseholderNameRepository(database()); | |||
| $name = $repo->find((int) $id); | |||
| if (!$name) { | |||
| return Response::notFound('Householder name not found.'); | |||
| } | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| flash('error', 'Invalid request.'); | |||
| return $this->redirect('/householder-names/' . $id . '/edit'); | |||
| } | |||
| $householdId = (string) ($request->input('household_id') ?? ''); | |||
| $nameVal = trim((string) ($request->input('name') ?? '')); | |||
| $letterReturned = (int) (bool) $request->input('letter_returned'); | |||
| $returnDate = trim((string) ($request->input('return_date') ?? '')) ?: null; | |||
| $validator = (new Validator()) | |||
| ->required('household_id', $householdId, 'Household is required.') | |||
| ->required('name', $nameVal, 'Name is required.') | |||
| ->maxLength('name', $nameVal, 255); | |||
| $households = (new HouseholdRepository(database()))->all(); | |||
| if ($validator->fails()) { | |||
| return $this->view('householder-names.edit', [ | |||
| 'pageTitle' => 'Edit Householder Name', | |||
| 'name' => array_merge($name, ['name' => $nameVal, 'household_id' => $householdId]), | |||
| 'households' => $households, | |||
| 'errors' => $validator->errors(), | |||
| ]); | |||
| } | |||
| database()->execute( | |||
| 'UPDATE householder_names SET household_id = :household_id, name = :name, | |||
| letter_returned = :letter_returned, return_date = :return_date, | |||
| updated_at = :now WHERE id = :id', | |||
| ['household_id' => $householdId, 'name' => $nameVal, 'letter_returned' => $letterReturned, | |||
| 'return_date' => $returnDate, 'now' => date('Y-m-d H:i:s'), 'id' => $id] | |||
| ); | |||
| flash('success', "Householder name \"{$nameVal}\" updated."); | |||
| return $this->redirect('/householder-names/' . $id); | |||
| } | |||
| public function delete(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $name = (new HouseholderNameRepository(database()))->find((int) $id); | |||
| if (!$name) { | |||
| return Response::notFound('Householder name not found.'); | |||
| } | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| flash('error', 'Invalid request.'); | |||
| return $this->redirect('/householder-names'); | |||
| } | |||
| $householdId = $name['household_id']; | |||
| database()->execute('DELETE FROM householder_names WHERE id = :id', ['id' => $id]); | |||
| flash('success', 'Householder name deleted.'); | |||
| return $this->redirect('/households/' . $householdId); | |||
| } | |||
| public function markReturned(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $repo = new HouseholderNameRepository(database()); | |||
| $name = $repo->find((int) $id); | |||
| if (!$name) { | |||
| return Response::notFound('Householder name not found.'); | |||
| } | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| flash('error', 'Invalid request.'); | |||
| return $this->redirect('/households/' . $name['household_id']); | |||
| } | |||
| $repo->toggleLetterReturned((int) $id); | |||
| flash('success', 'Letter returned status updated.'); | |||
| return $this->redirect('/households/' . $name['household_id']); | |||
| } | |||
| } | |||
| @@ -0,0 +1,214 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controllers; | |||
| use App\Repositories\TerritoryRepository; | |||
| use Core\Controller; | |||
| use Core\Pagination; | |||
| use Core\Request; | |||
| use Core\Response; | |||
| use Core\Validator; | |||
| class TerritoryController extends Controller | |||
| { | |||
| public function index(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $search = (string) ($request->input('search') ?? ''); | |||
| $page = max(1, (int) ($request->input('page') ?? 1)); | |||
| $perPage = 20; | |||
| $repo = new TerritoryRepository(database()); | |||
| $total = $repo->countAll($search); | |||
| $pagination = new Pagination($total, $page, $perPage); | |||
| $territories = $repo->findPaged($page, $perPage, $search); | |||
| $counts = $repo->householdCountsKeyed(); | |||
| return $this->view('territories.index', [ | |||
| 'pageTitle' => 'Territories', | |||
| 'territories' => $territories, | |||
| 'counts' => $counts, | |||
| 'search' => $search, | |||
| 'pagination' => $pagination, | |||
| ]); | |||
| } | |||
| public function show(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $repo = new TerritoryRepository(database()); | |||
| $territory = $repo->find((int) $id); | |||
| if (!$territory) { | |||
| return Response::notFound('Territory not found.'); | |||
| } | |||
| $streets = $repo->distinctStreets((int) $id); | |||
| $householdRepo = new \App\Repositories\HouseholdRepository(database()); | |||
| $households = $householdRepo->findAllByTerritory((int) $id); | |||
| return $this->view('territories.show', [ | |||
| 'pageTitle' => e($territory['name']), | |||
| 'territory' => $territory, | |||
| 'streets' => $streets, | |||
| 'households' => $households, | |||
| ]); | |||
| } | |||
| public function create(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| return $this->view('territories.create', [ | |||
| 'pageTitle' => 'New Territory', | |||
| 'errors' => [], | |||
| 'old' => [], | |||
| ]); | |||
| } | |||
| public function store(): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| return $this->view('territories.create', [ | |||
| 'pageTitle' => 'New Territory', | |||
| 'errors' => ['_token' => ['Invalid request. Please try again.']], | |||
| 'old' => $request->all(), | |||
| ]); | |||
| } | |||
| $name = trim((string) ($request->input('name') ?? '')); | |||
| $description = trim((string) ($request->input('description') ?? '')); | |||
| $coordinates = trim((string) ($request->input('coordinates') ?? '')); | |||
| $validator = (new Validator()) | |||
| ->required('name', $name, 'Name is required.') | |||
| ->maxLength('name', $name, 255); | |||
| if ($validator->fails()) { | |||
| return $this->view('territories.create', [ | |||
| 'pageTitle' => 'New Territory', | |||
| 'errors' => $validator->errors(), | |||
| 'old' => $request->all(), | |||
| ]); | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| database()->execute( | |||
| 'INSERT INTO territories (name, description, coordinates, created_at, updated_at) | |||
| VALUES (:name, :description, :coordinates, :now, :now)', | |||
| ['name' => $name, 'description' => $description, 'coordinates' => $coordinates, 'now' => $now] | |||
| ); | |||
| flash('success', "Territory \"{$name}\" created."); | |||
| return $this->redirect('/territories'); | |||
| } | |||
| public function edit(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $territory = (new TerritoryRepository(database()))->find((int) $id); | |||
| if (!$territory) { | |||
| return Response::notFound('Territory not found.'); | |||
| } | |||
| return $this->view('territories.edit', [ | |||
| 'pageTitle' => 'Edit Territory', | |||
| 'territory' => $territory, | |||
| 'errors' => [], | |||
| ]); | |||
| } | |||
| public function update(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $repo = new TerritoryRepository(database()); | |||
| $territory = $repo->find((int) $id); | |||
| if (!$territory) { | |||
| return Response::notFound('Territory not found.'); | |||
| } | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| return $this->view('territories.edit', [ | |||
| 'pageTitle' => 'Edit Territory', | |||
| 'territory' => $territory, | |||
| 'errors' => ['_token' => ['Invalid request. Please try again.']], | |||
| ]); | |||
| } | |||
| $name = trim((string) ($request->input('name') ?? '')); | |||
| $description = trim((string) ($request->input('description') ?? '')); | |||
| $coordinates = trim((string) ($request->input('coordinates') ?? '')); | |||
| $validator = (new Validator()) | |||
| ->required('name', $name, 'Name is required.') | |||
| ->maxLength('name', $name, 255); | |||
| if ($validator->fails()) { | |||
| return $this->view('territories.edit', [ | |||
| 'pageTitle' => 'Edit Territory', | |||
| 'territory' => array_merge($territory, ['name' => $name, 'description' => $description, 'coordinates' => $coordinates]), | |||
| 'errors' => $validator->errors(), | |||
| ]); | |||
| } | |||
| database()->execute( | |||
| 'UPDATE territories SET name = :name, description = :description, coordinates = :coordinates, | |||
| updated_at = :now WHERE id = :id', | |||
| ['name' => $name, 'description' => $description, 'coordinates' => $coordinates, | |||
| 'now' => date('Y-m-d H:i:s'), 'id' => $id] | |||
| ); | |||
| flash('success', "Territory \"{$name}\" updated."); | |||
| return $this->redirect('/territories/' . $id); | |||
| } | |||
| public function delete(int|string $id): Response | |||
| { | |||
| if ($redirect = $this->requireAuth()) { | |||
| return $redirect; | |||
| } | |||
| $request = Request::capture(); | |||
| $territory = (new TerritoryRepository(database()))->find((int) $id); | |||
| if (!$territory) { | |||
| return Response::notFound('Territory not found.'); | |||
| } | |||
| if (!verify_csrf_token($request->input('_token'))) { | |||
| flash('error', 'Invalid request.'); | |||
| return $this->redirect('/territories'); | |||
| } | |||
| database()->execute('DELETE FROM territories WHERE id = :id', ['id' => $id]); | |||
| flash('success', 'Territory deleted.'); | |||
| return $this->redirect('/territories'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,113 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use Core\Repository; | |||
| class HouseholdRepository extends Repository | |||
| { | |||
| protected string $table = 'households'; | |||
| protected string $primaryKey = 'id'; | |||
| public function countAll(string $search = '', string $territoryId = '', string $doNotCall = ''): int | |||
| { | |||
| [$sql, $params] = $this->buildFilterQuery( | |||
| 'SELECT COUNT(*) AS n FROM households h | |||
| JOIN territories t ON t.id = h.territory_id', | |||
| $search, $territoryId, $doNotCall | |||
| ); | |||
| $row = $this->database->first($sql, $params); | |||
| return (int) ($row['n'] ?? 0); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function findPaged(int $page, int $perPage, string $search = '', string $territoryId = '', string $doNotCall = ''): array | |||
| { | |||
| $offset = ($page - 1) * $perPage; | |||
| [$where, $params] = $this->buildFilterQuery( | |||
| 'SELECT h.*, t.name AS territory_name FROM households h | |||
| JOIN territories t ON t.id = h.territory_id', | |||
| $search, $territoryId, $doNotCall | |||
| ); | |||
| return $this->database->query( | |||
| $where . ' ORDER BY t.name ASC, h.street_name ASC, h.street_number ASC | |||
| LIMIT :limit OFFSET :offset', | |||
| array_merge($params, ['limit' => $perPage, 'offset' => $offset]) | |||
| ); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function findAllByTerritory(int|string $territoryId): array | |||
| { | |||
| return $this->database->query( | |||
| 'SELECT * FROM households WHERE territory_id = :id | |||
| ORDER BY street_name ASC, street_number ASC', | |||
| ['id' => $territoryId] | |||
| ); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function findAllByTerritories(array $territoryIds): array | |||
| { | |||
| if (empty($territoryIds)) { | |||
| return []; | |||
| } | |||
| $placeholders = implode(',', array_fill(0, count($territoryIds), '?')); | |||
| return $this->database->query( | |||
| "SELECT h.*, t.name AS territory_name | |||
| FROM households h | |||
| JOIN territories t ON t.id = h.territory_id | |||
| WHERE h.territory_id IN ({$placeholders}) | |||
| ORDER BY t.name ASC, h.street_name ASC, h.street_number ASC", | |||
| array_values($territoryIds) | |||
| ); | |||
| } | |||
| public function findWithTerritory(int|string $id): ?array | |||
| { | |||
| return $this->database->first( | |||
| 'SELECT h.*, t.name AS territory_name | |||
| FROM households h | |||
| JOIN territories t ON t.id = h.territory_id | |||
| WHERE h.id = :id', | |||
| ['id' => $id] | |||
| ); | |||
| } | |||
| /** @return array{string, array<string,mixed>} */ | |||
| private function buildFilterQuery(string $base, string $search, string $territoryId, string $doNotCall): array | |||
| { | |||
| $conditions = []; | |||
| $params = []; | |||
| if ($search !== '') { | |||
| $conditions[] = '(h.address LIKE :s OR h.street_name LIKE :s)'; | |||
| $params['s'] = '%' . $search . '%'; | |||
| } | |||
| if ($territoryId !== '') { | |||
| $conditions[] = 'h.territory_id = :tid'; | |||
| $params['tid'] = $territoryId; | |||
| } | |||
| if ($doNotCall === '1') { | |||
| $conditions[] = 'h.do_not_call = 1'; | |||
| } elseif ($doNotCall === '0') { | |||
| $conditions[] = 'h.do_not_call = 0'; | |||
| } | |||
| $sql = $base; | |||
| if (!empty($conditions)) { | |||
| $sql .= ' WHERE ' . implode(' AND ', $conditions); | |||
| } | |||
| return [$sql, $params]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,102 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use Core\Repository; | |||
| class HouseholderNameRepository extends Repository | |||
| { | |||
| protected string $table = 'householder_names'; | |||
| protected string $primaryKey = 'id'; | |||
| public function countAll(string $search = '', string $householdId = ''): int | |||
| { | |||
| [$sql, $params] = $this->buildFilterQuery( | |||
| 'SELECT COUNT(*) AS n FROM householder_names hn | |||
| JOIN households h ON h.id = hn.household_id', | |||
| $search, $householdId | |||
| ); | |||
| $row = $this->database->first($sql, $params); | |||
| return (int) ($row['n'] ?? 0); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function findPaged(int $page, int $perPage, string $search = '', string $householdId = ''): array | |||
| { | |||
| $offset = ($page - 1) * $perPage; | |||
| [$where, $params] = $this->buildFilterQuery( | |||
| 'SELECT hn.*, h.address AS household_address, t.name AS territory_name | |||
| FROM householder_names hn | |||
| JOIN households h ON h.id = hn.household_id | |||
| JOIN territories t ON t.id = h.territory_id', | |||
| $search, $householdId | |||
| ); | |||
| return $this->database->query( | |||
| $where . ' ORDER BY h.address ASC, hn.name ASC LIMIT :limit OFFSET :offset', | |||
| array_merge($params, ['limit' => $perPage, 'offset' => $offset]) | |||
| ); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function findAllByHousehold(int|string $householdId): array | |||
| { | |||
| return $this->database->query( | |||
| 'SELECT * FROM householder_names WHERE household_id = :id ORDER BY name ASC', | |||
| ['id' => $householdId] | |||
| ); | |||
| } | |||
| public function findWithHousehold(int|string $id): ?array | |||
| { | |||
| return $this->database->first( | |||
| 'SELECT hn.*, h.address AS household_address, h.territory_id, | |||
| t.name AS territory_name | |||
| FROM householder_names hn | |||
| JOIN households h ON h.id = hn.household_id | |||
| JOIN territories t ON t.id = h.territory_id | |||
| WHERE hn.id = :id', | |||
| ['id' => $id] | |||
| ); | |||
| } | |||
| public function toggleLetterReturned(int|string $id): bool | |||
| { | |||
| return $this->database->execute( | |||
| 'UPDATE householder_names | |||
| SET letter_returned = CASE WHEN letter_returned = 1 THEN 0 ELSE 1 END, | |||
| return_date = CASE WHEN letter_returned = 0 THEN datetime(\'now\') ELSE NULL END, | |||
| updated_at = datetime(\'now\') | |||
| WHERE id = :id', | |||
| ['id' => $id] | |||
| ); | |||
| } | |||
| /** @return array{string, array<string,mixed>} */ | |||
| private function buildFilterQuery(string $base, string $search, string $householdId): array | |||
| { | |||
| $conditions = []; | |||
| $params = []; | |||
| if ($search !== '') { | |||
| $conditions[] = 'hn.name LIKE :s'; | |||
| $params['s'] = '%' . $search . '%'; | |||
| } | |||
| if ($householdId !== '') { | |||
| $conditions[] = 'hn.household_id = :hid'; | |||
| $params['hid'] = $householdId; | |||
| } | |||
| $sql = $base; | |||
| if (!empty($conditions)) { | |||
| $sql .= ' WHERE ' . implode(' AND ', $conditions); | |||
| } | |||
| return [$sql, $params]; | |||
| } | |||
| } | |||
| @@ -0,0 +1,90 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Repositories; | |||
| use Core\Repository; | |||
| class TerritoryRepository extends Repository | |||
| { | |||
| protected string $table = 'territories'; | |||
| protected string $primaryKey = 'id'; | |||
| public function countAll(string $search = ''): int | |||
| { | |||
| if ($search !== '') { | |||
| $row = $this->database->first( | |||
| 'SELECT COUNT(*) AS n FROM territories WHERE name LIKE :s OR description LIKE :s', | |||
| ['s' => '%' . $search . '%'] | |||
| ); | |||
| } else { | |||
| $row = $this->database->first('SELECT COUNT(*) AS n FROM territories'); | |||
| } | |||
| return (int) ($row['n'] ?? 0); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function findPaged(int $page, int $perPage, string $search = ''): array | |||
| { | |||
| $offset = ($page - 1) * $perPage; | |||
| if ($search !== '') { | |||
| return $this->database->query( | |||
| 'SELECT * FROM territories WHERE name LIKE :s OR description LIKE :s | |||
| ORDER BY name ASC LIMIT :limit OFFSET :offset', | |||
| ['s' => '%' . $search . '%', 'limit' => $perPage, 'offset' => $offset] | |||
| ); | |||
| } | |||
| return $this->database->query( | |||
| 'SELECT * FROM territories ORDER BY name ASC LIMIT :limit OFFSET :offset', | |||
| ['limit' => $perPage, 'offset' => $offset] | |||
| ); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function allOrdered(): array | |||
| { | |||
| return $this->database->query('SELECT * FROM territories ORDER BY name ASC'); | |||
| } | |||
| public function householdCount(int|string $territoryId): int | |||
| { | |||
| $row = $this->database->first( | |||
| 'SELECT COUNT(*) AS n FROM households WHERE territory_id = :id', | |||
| ['id' => $territoryId] | |||
| ); | |||
| return (int) ($row['n'] ?? 0); | |||
| } | |||
| /** @return list<array<string,mixed>> */ | |||
| public function householdCountsKeyed(): array | |||
| { | |||
| $rows = $this->database->query( | |||
| 'SELECT territory_id, COUNT(*) AS n FROM households GROUP BY territory_id' | |||
| ); | |||
| $counts = []; | |||
| foreach ($rows as $row) { | |||
| $counts[(int) $row['territory_id']] = (int) $row['n']; | |||
| } | |||
| return $counts; | |||
| } | |||
| /** @return list<string> */ | |||
| public function distinctStreets(int|string $territoryId): array | |||
| { | |||
| $rows = $this->database->query( | |||
| 'SELECT DISTINCT street_name FROM households | |||
| WHERE territory_id = :id AND street_name IS NOT NULL AND street_name != \'\' | |||
| ORDER BY street_name ASC', | |||
| ['id' => $territoryId] | |||
| ); | |||
| return array_column($rows, 'street_name'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,273 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Services; | |||
| use PhpOffice\PhpSpreadsheet\Spreadsheet; | |||
| use PhpOffice\PhpSpreadsheet\Style\Fill; | |||
| use PhpOffice\PhpSpreadsheet\Writer\Xlsx; | |||
| use Dompdf\Dompdf; | |||
| use Dompdf\Options; | |||
| class ExportService | |||
| { | |||
| /** | |||
| * Build a ZIP archive containing XLSX + PDF for each territory. | |||
| * | |||
| * @param list<array<string,mixed>> $territories | |||
| * @param list<array<string,mixed>> $allHouseholds All household rows (with territory_id) | |||
| * @return string Raw ZIP binary content | |||
| */ | |||
| public function buildZip(array $territories, array $allHouseholds): string | |||
| { | |||
| $byTerritory = []; | |||
| foreach ($allHouseholds as $h) { | |||
| $tid = (int) $h['territory_id']; | |||
| $byTerritory[$tid][] = $h; | |||
| } | |||
| $zipFile = tempnam(sys_get_temp_dir(), 'territory_export_'); | |||
| $zip = new \ZipArchive(); | |||
| $zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); | |||
| foreach ($territories as $territory) { | |||
| $tid = (int) $territory['id']; | |||
| $households = $byTerritory[$tid] ?? []; | |||
| $slug = $this->slug($territory['name']); | |||
| $xlsx = $this->buildXlsx($territory, $households); | |||
| $zip->addFromString("{$slug}.xlsx", $xlsx); | |||
| $pdf = $this->buildPdf($territory, $households); | |||
| $zip->addFromString("{$slug}.pdf", $pdf); | |||
| } | |||
| $zip->close(); | |||
| $content = (string) file_get_contents($zipFile); | |||
| unlink($zipFile); | |||
| return $content; | |||
| } | |||
| /** @param list<array<string,mixed>> $households */ | |||
| private function buildXlsx(array $territory, array $households): string | |||
| { | |||
| $spreadsheet = new Spreadsheet(); | |||
| $spreadsheet->removeSheetByIndex(0); | |||
| $byStreet = $this->groupByStreet($households); | |||
| if (empty($byStreet)) { | |||
| $sheet = $spreadsheet->createSheet(); | |||
| $sheet->setTitle('No Data'); | |||
| $sheet->setCellValue('A1', 'No households found for this territory.'); | |||
| } | |||
| foreach ($byStreet as $streetName => $streetHouseholds) { | |||
| $sheet = $spreadsheet->createSheet(); | |||
| $sheet->setTitle(substr((string) $streetName, 0, 31)); | |||
| [$even, $odd] = $this->splitEvenOdd($streetHouseholds); | |||
| $headerStyle = [ | |||
| 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], | |||
| 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '1D7A6D']], | |||
| 'borders' => ['bottom' => ['borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN]], | |||
| ]; | |||
| $evenHeaders = ['#', 'Address (Even)', 'Bus.', 'DNC', 'DNC Date']; | |||
| $oddHeaders = ['#', 'Address (Odd)', 'Bus.', 'DNC', 'DNC Date']; | |||
| foreach ($evenHeaders as $col => $header) { | |||
| $cell = $this->colLetter($col + 1) . '1'; | |||
| $sheet->setCellValue($cell, $header); | |||
| $sheet->getStyle($cell)->applyFromArray($headerStyle); | |||
| } | |||
| $sheet->setCellValue('G1', ''); | |||
| foreach ($oddHeaders as $col => $header) { | |||
| $cell = $this->colLetter($col + 8) . '1'; | |||
| $sheet->setCellValue($cell, $header); | |||
| $sheet->getStyle($cell)->applyFromArray($headerStyle); | |||
| } | |||
| $row = 2; | |||
| foreach ($even as $h) { | |||
| $sheet->setCellValue("A{$row}", $h['street_number'] ?? ''); | |||
| $sheet->setCellValue("B{$row}", $h['address'] ?? ''); | |||
| $sheet->setCellValue("C{$row}", $h['is_business'] ? 'Yes' : ''); | |||
| $sheet->setCellValue("D{$row}", $h['do_not_call'] ? 'Yes' : ''); | |||
| $sheet->setCellValue("E{$row}", $h['do_not_call_date'] ?? ''); | |||
| $row++; | |||
| } | |||
| $row = 2; | |||
| foreach ($odd as $h) { | |||
| $sheet->setCellValue("H{$row}", $h['street_number'] ?? ''); | |||
| $sheet->setCellValue("I{$row}", $h['address'] ?? ''); | |||
| $sheet->setCellValue("J{$row}", $h['is_business'] ? 'Yes' : ''); | |||
| $sheet->setCellValue("K{$row}", $h['do_not_call'] ? 'Yes' : ''); | |||
| $sheet->setCellValue("L{$row}", $h['do_not_call_date'] ?? ''); | |||
| $row++; | |||
| } | |||
| foreach (range('A', 'L') as $col) { | |||
| $sheet->getColumnDimension($col)->setAutoSize(true); | |||
| } | |||
| } | |||
| $writer = new Xlsx($spreadsheet); | |||
| $tmpFile = tempnam(sys_get_temp_dir(), 'territory_xlsx_'); | |||
| $writer->save($tmpFile); | |||
| $content = (string) file_get_contents($tmpFile); | |||
| unlink($tmpFile); | |||
| return $content; | |||
| } | |||
| /** @param list<array<string,mixed>> $households */ | |||
| private function buildPdf(array $territory, array $households): string | |||
| { | |||
| $options = new Options(); | |||
| $options->set('defaultFont', 'DejaVu Sans'); | |||
| $options->set('isRemoteEnabled', false); | |||
| $dompdf = new Dompdf($options); | |||
| $dompdf->loadHtml($this->buildPdfHtml($territory, $households)); | |||
| $dompdf->setPaper('A4', 'landscape'); | |||
| $dompdf->render(); | |||
| return (string) $dompdf->output(); | |||
| } | |||
| /** @param list<array<string,mixed>> $households */ | |||
| private function buildPdfHtml(array $territory, array $households): string | |||
| { | |||
| $title = htmlspecialchars($territory['name'], ENT_QUOTES, 'UTF-8'); | |||
| $byStreet = $this->groupByStreet($households); | |||
| $html = '<!DOCTYPE html><html><head><meta charset="UTF-8"> | |||
| <style> | |||
| body { font-family: DejaVu Sans, sans-serif; font-size: 9pt; margin: 10mm; } | |||
| h1 { font-size: 14pt; margin-bottom: 4px; } | |||
| h2 { font-size: 11pt; margin: 12px 0 4px; border-bottom: 1px solid #555; padding-bottom: 2px; } | |||
| table { width: 100%; border-collapse: collapse; margin-bottom: 8px; } | |||
| th { background: #1d7a6d; color: #fff; padding: 4px 6px; font-size: 8pt; text-align: left; } | |||
| td { padding: 3px 6px; border-bottom: 1px solid #ddd; font-size: 8pt; vertical-align: top; } | |||
| .dnc { color: #c0392b; font-weight: bold; } | |||
| .sep { width: 6px; } | |||
| </style> | |||
| </head><body>'; | |||
| $html .= "<h1>Territory: {$title}</h1>"; | |||
| if (empty($byStreet)) { | |||
| $html .= '<p>No households found.</p>'; | |||
| } | |||
| foreach ($byStreet as $streetName => $streetHouseholds) { | |||
| $escapedStreet = htmlspecialchars((string) $streetName, ENT_QUOTES, 'UTF-8'); | |||
| [$even, $odd] = $this->splitEvenOdd($streetHouseholds); | |||
| $html .= "<h2>{$escapedStreet}</h2>"; | |||
| $html .= '<table><tr> | |||
| <th>#</th><th>Address (Even)</th><th>DNC</th> | |||
| <th class="sep"></th> | |||
| <th>#</th><th>Address (Odd)</th><th>DNC</th> | |||
| </tr>'; | |||
| $max = max(count($even), count($odd), 1); | |||
| $evenVals = array_values($even); | |||
| $oddVals = array_values($odd); | |||
| for ($i = 0; $i < $max; $i++) { | |||
| $html .= '<tr>'; | |||
| if (isset($evenVals[$i])) { | |||
| $h = $evenVals[$i]; | |||
| $addr = htmlspecialchars($h['address'] ?? '', ENT_QUOTES, 'UTF-8'); | |||
| $dnc = $h['do_not_call'] ? '<span class="dnc">DNC</span>' : ''; | |||
| $html .= "<td>{$h['street_number']}</td><td>{$addr}</td><td>{$dnc}</td>"; | |||
| } else { | |||
| $html .= '<td></td><td></td><td></td>'; | |||
| } | |||
| $html .= '<td class="sep"></td>'; | |||
| if (isset($oddVals[$i])) { | |||
| $h = $oddVals[$i]; | |||
| $addr = htmlspecialchars($h['address'] ?? '', ENT_QUOTES, 'UTF-8'); | |||
| $dnc = $h['do_not_call'] ? '<span class="dnc">DNC</span>' : ''; | |||
| $html .= "<td>{$h['street_number']}</td><td>{$addr}</td><td>{$dnc}</td>"; | |||
| } else { | |||
| $html .= '<td></td><td></td><td></td>'; | |||
| } | |||
| $html .= '</tr>'; | |||
| } | |||
| $html .= '</table>'; | |||
| } | |||
| $html .= '</body></html>'; | |||
| return $html; | |||
| } | |||
| /** | |||
| * @param list<array<string,mixed>> $households | |||
| * @return array<string, list<array<string,mixed>>> | |||
| */ | |||
| private function groupByStreet(array $households): array | |||
| { | |||
| $byStreet = []; | |||
| foreach ($households as $h) { | |||
| $street = trim((string) ($h['street_name'] ?? '')); | |||
| if ($street === '') { | |||
| $street = 'Unknown Street'; | |||
| } | |||
| $byStreet[$street][] = $h; | |||
| } | |||
| ksort($byStreet); | |||
| return $byStreet; | |||
| } | |||
| /** | |||
| * @param list<array<string,mixed>> $households | |||
| * @return array{list<array<string,mixed>>, list<array<string,mixed>>} | |||
| */ | |||
| private function splitEvenOdd(array $households): array | |||
| { | |||
| $even = []; | |||
| $odd = []; | |||
| foreach ($households as $h) { | |||
| $num = (int) ($h['street_number'] ?? 0); | |||
| if ($num % 2 === 0) { | |||
| $even[] = $h; | |||
| } else { | |||
| $odd[] = $h; | |||
| } | |||
| } | |||
| usort($even, fn($a, $b) => (int) ($a['street_number'] ?? 0) <=> (int) ($b['street_number'] ?? 0)); | |||
| usort($odd, fn($a, $b) => (int) ($a['street_number'] ?? 0) <=> (int) ($b['street_number'] ?? 0)); | |||
| return [$even, $odd]; | |||
| } | |||
| private function colLetter(int $n): string | |||
| { | |||
| $letter = ''; | |||
| while ($n > 0) { | |||
| $n--; | |||
| $letter = chr(65 + ($n % 26)) . $letter; | |||
| $n = (int) ($n / 26); | |||
| } | |||
| return $letter; | |||
| } | |||
| private function slug(string $name): string | |||
| { | |||
| $slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? $name); | |||
| return trim($slug, '_') ?: 'territory'; | |||
| } | |||
| } | |||
| @@ -0,0 +1,54 @@ | |||
| <div class="page-header"> | |||
| <h1>Export Territories</h1> | |||
| <a class="button button-secondary button-sm" href="/territories">← Territories</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <div class="panel-header"> | |||
| <h2>Select Territories to Export</h2> | |||
| <p>Each selected territory will be exported as an XLSX workbook (one sheet per street, split by even/odd house numbers) and a PDF. All files are packaged in a ZIP archive.</p> | |||
| </div> | |||
| <?php if (empty($territories)): ?> | |||
| <div class="empty-state"> | |||
| <p>No territories found. <a href="/territories/new">Create a territory</a> first.</p> | |||
| </div> | |||
| <?php else: ?> | |||
| <form method="POST" action="/export"> | |||
| <?= csrf_field() ?> | |||
| <div style="margin-bottom:1rem"> | |||
| <button type="button" class="button button-secondary button-sm" | |||
| onclick="document.querySelectorAll('.territory-check').forEach(cb => cb.checked = true)"> | |||
| Select All | |||
| </button> | |||
| <button type="button" class="button button-secondary button-sm" | |||
| onclick="document.querySelectorAll('.territory-check').forEach(cb => cb.checked = false)"> | |||
| Deselect All | |||
| </button> | |||
| </div> | |||
| <div class="territory-checklist"> | |||
| <?php foreach ($territories as $t): ?> | |||
| <label class="checkbox-label territory-item"> | |||
| <input type="checkbox" class="territory-check" | |||
| name="territory_ids[]" | |||
| value="<?= e($t['id']) ?>"> | |||
| <?= e($t['name']) ?> | |||
| <?php if ($t['description']): ?> | |||
| <span class="text-secondary" style="font-size:0.85rem"> | |||
| — <?= e((string) ($t['description'] ?? '')) ?> | |||
| </span> | |||
| <?php endif; ?> | |||
| </label> | |||
| <?php endforeach; ?> | |||
| </div> | |||
| <div class="form-actions" style="margin-top:1.5rem"> | |||
| <button class="button button-primary" type="submit"> | |||
| Download ZIP | |||
| </button> | |||
| </div> | |||
| </form> | |||
| <?php endif; ?> | |||
| </div> | |||
| @@ -0,0 +1,64 @@ | |||
| <div class="page-header"> | |||
| <h1>New Householder Name</h1> | |||
| <a class="button button-secondary button-sm" href="/householder-names">← Back</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <?php if (!empty($errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/householder-names" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-grid"> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="household_id">Household <span style="color:#c0392b">*</span></label> | |||
| <select class="input <?= isset($errors['household_id']) ? 'input-error' : '' ?>" | |||
| id="household_id" name="household_id" required> | |||
| <option value="">Select a household…</option> | |||
| <?php foreach ($households as $h): ?> | |||
| <option value="<?= e($h['id']) ?>" | |||
| <?= ((string) ($old['household_id'] ?? $defaultHouseholdId)) === (string) $h['id'] ? 'selected' : '' ?>> | |||
| <?= e($h['address']) ?> | |||
| </option> | |||
| <?php endforeach; ?> | |||
| </select> | |||
| <?php if (isset($errors['household_id'])): ?> | |||
| <span class="field-error"><?= e($errors['household_id'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="name">Name <span style="color:#c0392b">*</span></label> | |||
| <input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>" | |||
| type="text" id="name" name="name" | |||
| value="<?= e((string) ($old['name'] ?? '')) ?>" required autofocus> | |||
| <?php if (isset($errors['name'])): ?> | |||
| <span class="field-error"><?= e($errors['name'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label class="checkbox-label"> | |||
| <input type="checkbox" name="letter_returned" value="1" | |||
| id="lr_check" | |||
| <?= !empty($old['letter_returned']) ? 'checked' : '' ?> | |||
| onchange="document.getElementById('lr_date_field').style.display=this.checked?'grid':'none'"> | |||
| Letter Returned | |||
| </label> | |||
| </div> | |||
| <div id="lr_date_field" class="field" style="grid-column:1/-1;display:<?= !empty($old['letter_returned']) ? 'grid' : 'none' ?>"> | |||
| <label for="return_date">Return Date</label> | |||
| <input class="input" type="datetime-local" id="return_date" name="return_date" | |||
| value="<?= e((string) ($old['return_date'] ?? '')) ?>"> | |||
| </div> | |||
| </div> | |||
| <div class="form-actions" style="margin-top:1.5rem"> | |||
| <button class="button button-primary" type="submit">Create</button> | |||
| <a class="button button-secondary" href="/householder-names">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,65 @@ | |||
| <div class="page-header"> | |||
| <h1>Edit Householder Name</h1> | |||
| <a class="button button-secondary button-sm" | |||
| href="/householder-names/<?= e($name['id']) ?>">← Back</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <?php if (!empty($errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/householder-names/<?= e($name['id']) ?>" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-grid"> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="household_id">Household <span style="color:#c0392b">*</span></label> | |||
| <select class="input <?= isset($errors['household_id']) ? 'input-error' : '' ?>" | |||
| id="household_id" name="household_id" required> | |||
| <option value="">Select a household…</option> | |||
| <?php foreach ($households as $h): ?> | |||
| <option value="<?= e($h['id']) ?>" | |||
| <?= (string) $name['household_id'] === (string) $h['id'] ? 'selected' : '' ?>> | |||
| <?= e($h['address']) ?> | |||
| </option> | |||
| <?php endforeach; ?> | |||
| </select> | |||
| <?php if (isset($errors['household_id'])): ?> | |||
| <span class="field-error"><?= e($errors['household_id'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="name">Name <span style="color:#c0392b">*</span></label> | |||
| <input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>" | |||
| type="text" id="name" name="name" | |||
| value="<?= e((string) ($name['name'] ?? '')) ?>" required autofocus> | |||
| <?php if (isset($errors['name'])): ?> | |||
| <span class="field-error"><?= e($errors['name'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label class="checkbox-label"> | |||
| <input type="checkbox" name="letter_returned" value="1" | |||
| id="lr_check" | |||
| <?= $name['letter_returned'] ? 'checked' : '' ?> | |||
| onchange="document.getElementById('lr_date_field').style.display=this.checked?'grid':'none'"> | |||
| Letter Returned | |||
| </label> | |||
| </div> | |||
| <div id="lr_date_field" class="field" style="grid-column:1/-1;display:<?= $name['letter_returned'] ? 'grid' : 'none' ?>"> | |||
| <label for="return_date">Return Date</label> | |||
| <input class="input" type="datetime-local" id="return_date" name="return_date" | |||
| value="<?= e((string) ($name['return_date'] ?? '')) ?>"> | |||
| </div> | |||
| </div> | |||
| <div class="form-actions" style="margin-top:1.5rem"> | |||
| <button class="button button-primary" type="submit">Save Changes</button> | |||
| <a class="button button-secondary" href="/householder-names/<?= e($name['id']) ?>">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,97 @@ | |||
| <div class="page-header"> | |||
| <h1>Householder Names</h1> | |||
| <a class="button button-primary button-sm" href="/householder-names/new">+ New Name</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <form class="filter-bar" method="GET" action="/householder-names"> | |||
| <div class="field" style="flex:1;min-width:200px"> | |||
| <label for="search">Search name</label> | |||
| <input class="input" type="search" id="search" name="search" | |||
| value="<?= e($search) ?>" placeholder="Name…"> | |||
| </div> | |||
| <div class="form-actions" style="align-self:flex-end"> | |||
| <button class="button button-primary button-sm" type="submit">Filter</button> | |||
| <?php if ($search !== ''): ?> | |||
| <a class="button button-secondary button-sm" href="/householder-names">Clear</a> | |||
| <?php endif; ?> | |||
| </div> | |||
| </form> | |||
| <?php if (empty($names)): ?> | |||
| <div class="empty-state"> | |||
| <p>No names found.</p> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="table-responsive"> | |||
| <table class="data-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>Name</th> | |||
| <th>Household</th> | |||
| <th>Territory</th> | |||
| <th>Letter Returned</th> | |||
| <th>Added</th> | |||
| <th>Actions</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <?php foreach ($names as $n): ?> | |||
| <tr> | |||
| <td> | |||
| <a href="/householder-names/<?= e($n['id']) ?>"> | |||
| <?= e($n['name']) ?> | |||
| </a> | |||
| </td> | |||
| <td> | |||
| <a href="/households/<?= e($n['household_id']) ?>"> | |||
| <?= e($n['household_address']) ?> | |||
| </a> | |||
| </td> | |||
| <td class="text-secondary"><?= e($n['territory_name']) ?></td> | |||
| <td> | |||
| <?php if ($n['letter_returned']): ?> | |||
| <span class="badge badge-success">Returned</span> | |||
| <?php else: ?> | |||
| <span class="text-secondary">—</span> | |||
| <?php endif; ?> | |||
| </td> | |||
| <td class="text-secondary" style="font-size:0.85rem"> | |||
| <?= e((string) ($n['created_at'] ?? '')) ?> | |||
| </td> | |||
| <td class="table-actions"> | |||
| <a class="button button-secondary button-sm" | |||
| href="/householder-names/<?= e($n['id']) ?>">View</a> | |||
| <a class="button button-secondary button-sm" | |||
| href="/householder-names/<?= e($n['id']) ?>/edit">Edit</a> | |||
| <form method="POST" | |||
| action="/householder-names/<?= e($n['id']) ?>/delete" | |||
| class="inline-form" | |||
| onsubmit="return confirm('Delete this name?')"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-danger button-sm" type="submit">Delete</button> | |||
| </form> | |||
| </td> | |||
| </tr> | |||
| <?php endforeach; ?> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <?php if ($pagination->hasPages()): ?> | |||
| <div class="pagination"> | |||
| <?php if ($pagination->previousPage()): ?> | |||
| <a class="page-link" href="<?= e(paginate_url($pagination->previousPage())) ?>">‹ Prev</a> | |||
| <?php endif; ?> | |||
| <?php foreach ($pagination->pageRange() as $p): ?> | |||
| <a class="page-link <?= $p === $pagination->page ? 'is-active' : '' ?>" | |||
| href="<?= e(paginate_url($p)) ?>"><?= $p ?></a> | |||
| <?php endforeach; ?> | |||
| <?php if ($pagination->nextPage()): ?> | |||
| <a class="page-link" href="<?= e(paginate_url($pagination->nextPage())) ?>">Next ›</a> | |||
| <?php endif; ?> | |||
| <span class="pagination-meta"><?= number_format($pagination->total) ?> total</span> | |||
| </div> | |||
| <?php endif; ?> | |||
| <?php endif; ?> | |||
| </div> | |||
| @@ -0,0 +1,59 @@ | |||
| <div class="page-header"> | |||
| <h1><?= e($name['name']) ?></h1> | |||
| <div class="page-header-actions"> | |||
| <a class="button button-secondary button-sm" | |||
| href="/householder-names/<?= e($name['id']) ?>/edit">Edit</a> | |||
| <form method="POST" action="/householder-names/<?= e($name['id']) ?>/delete" | |||
| class="inline-form" | |||
| onsubmit="return confirm('Delete this name?')"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-danger button-sm" type="submit">Delete</button> | |||
| </form> | |||
| <a class="button button-secondary button-sm" | |||
| href="/households/<?= e($name['household_id']) ?>">← Household</a> | |||
| </div> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <dl class="detail-list"> | |||
| <dt>Household</dt> | |||
| <dd> | |||
| <a href="/households/<?= e($name['household_id']) ?>"> | |||
| <?= e($name['household_address']) ?> | |||
| </a> | |||
| </dd> | |||
| <dt>Territory</dt> | |||
| <dd> | |||
| <a href="/territories/<?= e($name['territory_id']) ?>"> | |||
| <?= e($name['territory_name']) ?> | |||
| </a> | |||
| </dd> | |||
| <dt>Letter Returned</dt> | |||
| <dd> | |||
| <?php if ($name['letter_returned']): ?> | |||
| <span class="badge badge-success">Yes</span> | |||
| <?php if ($name['return_date']): ?> | |||
| <span class="text-secondary">(<?= e($name['return_date']) ?>)</span> | |||
| <?php endif; ?> | |||
| <?php else: ?> | |||
| No | |||
| <?php endif; ?> | |||
| </dd> | |||
| <dt>Added</dt> | |||
| <dd class="text-secondary"><?= e((string) ($name['created_at'] ?? '')) ?></dd> | |||
| </dl> | |||
| <div class="form-actions" style="margin-top:1.25rem"> | |||
| <form method="POST" | |||
| action="/householder-names/<?= e($name['id']) ?>/mark-returned" | |||
| class="inline-form"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-secondary" type="submit"> | |||
| <?= $name['letter_returned'] ? 'Unmark as Returned' : 'Mark Letter Returned' ?> | |||
| </button> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,123 @@ | |||
| <div class="page-header"> | |||
| <h1>New Household</h1> | |||
| <a class="button button-secondary button-sm" href="/households">← Back</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <?php if (!empty($errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/households" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-grid"> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="territory_id">Territory <span style="color:#c0392b">*</span></label> | |||
| <select class="input <?= isset($errors['territory_id']) ? 'input-error' : '' ?>" | |||
| id="territory_id" name="territory_id" required> | |||
| <option value="">Select a territory…</option> | |||
| <?php foreach ($territories as $t): ?> | |||
| <option value="<?= e($t['id']) ?>" | |||
| <?= ((string) ($old['territory_id'] ?? $defaultTerritoryId)) === (string) $t['id'] ? 'selected' : '' ?>> | |||
| <?= e($t['name']) ?> | |||
| </option> | |||
| <?php endforeach; ?> | |||
| </select> | |||
| <?php if (isset($errors['territory_id'])): ?> | |||
| <span class="field-error"><?= e($errors['territory_id'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="address">Address <span style="color:#c0392b">*</span></label> | |||
| <input class="input <?= isset($errors['address']) ? 'input-error' : '' ?>" | |||
| type="text" id="address" name="address" | |||
| value="<?= e((string) ($old['address'] ?? '')) ?>" required autofocus> | |||
| <?php if (isset($errors['address'])): ?> | |||
| <span class="field-error"><?= e($errors['address'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="street_number">Street Number</label> | |||
| <input class="input <?= isset($errors['street_number']) ? 'input-error' : '' ?>" | |||
| type="number" id="street_number" name="street_number" | |||
| value="<?= e((string) ($old['street_number'] ?? '')) ?>"> | |||
| <?php if (isset($errors['street_number'])): ?> | |||
| <span class="field-error"><?= e($errors['street_number'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="street_name">Street Name</label> | |||
| <input class="input" type="text" id="street_name" name="street_name" | |||
| value="<?= e((string) ($old['street_name'] ?? '')) ?>"> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="latitude">Latitude</label> | |||
| <input class="input <?= isset($errors['latitude']) ? 'input-error' : '' ?>" | |||
| type="text" id="latitude" name="latitude" | |||
| value="<?= e((string) ($old['latitude'] ?? '')) ?>" | |||
| placeholder="e.g. 40.7128"> | |||
| <?php if (isset($errors['latitude'])): ?> | |||
| <span class="field-error"><?= e($errors['latitude'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="longitude">Longitude</label> | |||
| <input class="input <?= isset($errors['longitude']) ? 'input-error' : '' ?>" | |||
| type="text" id="longitude" name="longitude" | |||
| value="<?= e((string) ($old['longitude'] ?? '')) ?>" | |||
| placeholder="e.g. -74.0060"> | |||
| <?php if (isset($errors['longitude'])): ?> | |||
| <span class="field-error"><?= e($errors['longitude'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label class="checkbox-label"> | |||
| <input type="checkbox" name="is_business" value="1" | |||
| <?= !empty($old['is_business']) ? 'checked' : '' ?>> | |||
| This is a business | |||
| </label> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label class="checkbox-label"> | |||
| <input type="checkbox" name="do_not_call" value="1" id="dnc_check" | |||
| <?= !empty($old['do_not_call']) ? 'checked' : '' ?> | |||
| onchange="document.getElementById('dnc_fields').style.display=this.checked?'grid':'none'"> | |||
| Do Not Call | |||
| </label> | |||
| </div> | |||
| <div id="dnc_fields" class="form-grid" style="grid-column:1/-1;display:<?= !empty($old['do_not_call']) ? 'grid' : 'none' ?>"> | |||
| <div class="field"> | |||
| <label for="do_not_call_date">DNC Date</label> | |||
| <input class="input" type="date" id="do_not_call_date" name="do_not_call_date" | |||
| value="<?= e((string) ($old['do_not_call_date'] ?? '')) ?>"> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="do_not_call_notes">Public Notes</label> | |||
| <textarea class="input" id="do_not_call_notes" name="do_not_call_notes" | |||
| rows="2"><?= e((string) ($old['do_not_call_notes'] ?? '')) ?></textarea> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="do_not_call_private_notes">Private Notes</label> | |||
| <textarea class="input" id="do_not_call_private_notes" name="do_not_call_private_notes" | |||
| rows="2"><?= e((string) ($old['do_not_call_private_notes'] ?? '')) ?></textarea> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="form-actions" style="margin-top:1.5rem"> | |||
| <button class="button button-primary" type="submit">Create Household</button> | |||
| <a class="button button-secondary" href="/households">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,109 @@ | |||
| <div class="page-header"> | |||
| <h1>Edit Household</h1> | |||
| <a class="button button-secondary button-sm" href="/households/<?= e($household['id']) ?>">← Back</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <?php if (!empty($errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/households/<?= e($household['id']) ?>" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-grid"> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="territory_id">Territory <span style="color:#c0392b">*</span></label> | |||
| <select class="input <?= isset($errors['territory_id']) ? 'input-error' : '' ?>" | |||
| id="territory_id" name="territory_id" required> | |||
| <option value="">Select a territory…</option> | |||
| <?php foreach ($territories as $t): ?> | |||
| <option value="<?= e($t['id']) ?>" | |||
| <?= (string) $household['territory_id'] === (string) $t['id'] ? 'selected' : '' ?>> | |||
| <?= e($t['name']) ?> | |||
| </option> | |||
| <?php endforeach; ?> | |||
| </select> | |||
| <?php if (isset($errors['territory_id'])): ?> | |||
| <span class="field-error"><?= e($errors['territory_id'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="address">Address <span style="color:#c0392b">*</span></label> | |||
| <input class="input <?= isset($errors['address']) ? 'input-error' : '' ?>" | |||
| type="text" id="address" name="address" | |||
| value="<?= e((string) ($household['address'] ?? '')) ?>" required autofocus> | |||
| <?php if (isset($errors['address'])): ?> | |||
| <span class="field-error"><?= e($errors['address'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="street_number">Street Number</label> | |||
| <input class="input" type="number" id="street_number" name="street_number" | |||
| value="<?= e((string) ($household['street_number'] ?? '')) ?>"> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="street_name">Street Name</label> | |||
| <input class="input" type="text" id="street_name" name="street_name" | |||
| value="<?= e((string) ($household['street_name'] ?? '')) ?>"> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="latitude">Latitude</label> | |||
| <input class="input" type="text" id="latitude" name="latitude" | |||
| value="<?= e((string) ($household['latitude'] ?? '')) ?>"> | |||
| </div> | |||
| <div class="field"> | |||
| <label for="longitude">Longitude</label> | |||
| <input class="input" type="text" id="longitude" name="longitude" | |||
| value="<?= e((string) ($household['longitude'] ?? '')) ?>"> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label class="checkbox-label"> | |||
| <input type="checkbox" name="is_business" value="1" | |||
| <?= $household['is_business'] ? 'checked' : '' ?>> | |||
| This is a business | |||
| </label> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label class="checkbox-label"> | |||
| <input type="checkbox" name="do_not_call" value="1" id="dnc_check" | |||
| <?= $household['do_not_call'] ? 'checked' : '' ?> | |||
| onchange="document.getElementById('dnc_fields').style.display=this.checked?'grid':'none'"> | |||
| Do Not Call | |||
| </label> | |||
| </div> | |||
| <div id="dnc_fields" class="form-grid" style="grid-column:1/-1;display:<?= $household['do_not_call'] ? 'grid' : 'none' ?>"> | |||
| <div class="field"> | |||
| <label for="do_not_call_date">DNC Date</label> | |||
| <input class="input" type="date" id="do_not_call_date" name="do_not_call_date" | |||
| value="<?= e((string) ($household['do_not_call_date'] ?? '')) ?>"> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="do_not_call_notes">Public Notes</label> | |||
| <textarea class="input" id="do_not_call_notes" name="do_not_call_notes" | |||
| rows="2"><?= e((string) ($household['do_not_call_notes'] ?? '')) ?></textarea> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="do_not_call_private_notes">Private Notes</label> | |||
| <textarea class="input" id="do_not_call_private_notes" name="do_not_call_private_notes" | |||
| rows="2"><?= e((string) ($household['do_not_call_private_notes'] ?? '')) ?></textarea> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="form-actions" style="margin-top:1.5rem"> | |||
| <button class="button button-primary" type="submit">Save Changes</button> | |||
| <a class="button button-secondary" href="/households/<?= e($household['id']) ?>">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,124 @@ | |||
| <div class="page-header"> | |||
| <h1>Households</h1> | |||
| <a class="button button-primary button-sm" href="/households/new">+ New Household</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <form class="filter-bar" method="GET" action="/households"> | |||
| <div class="field" style="flex:2;min-width:200px"> | |||
| <label for="search">Search</label> | |||
| <input class="input" type="search" id="search" name="search" | |||
| value="<?= e($search) ?>" placeholder="Address or street name…"> | |||
| </div> | |||
| <div class="field" style="flex:1;min-width:160px"> | |||
| <label for="territory_id">Territory</label> | |||
| <select class="input" id="territory_id" name="territory_id"> | |||
| <option value="">All territories</option> | |||
| <?php foreach ($territories as $t): ?> | |||
| <option value="<?= e($t['id']) ?>" | |||
| <?= $territoryId === (string) $t['id'] ? 'selected' : '' ?>> | |||
| <?= e($t['name']) ?> | |||
| </option> | |||
| <?php endforeach; ?> | |||
| </select> | |||
| </div> | |||
| <div class="field" style="flex:1;min-width:140px"> | |||
| <label for="do_not_call">Do Not Call</label> | |||
| <select class="input" id="do_not_call" name="do_not_call"> | |||
| <option value="">All</option> | |||
| <option value="1" <?= $doNotCall === '1' ? 'selected' : '' ?>>DNC only</option> | |||
| <option value="0" <?= $doNotCall === '0' ? 'selected' : '' ?>>Non-DNC only</option> | |||
| </select> | |||
| </div> | |||
| <div class="form-actions" style="align-self:flex-end"> | |||
| <button class="button button-primary button-sm" type="submit">Filter</button> | |||
| <?php if ($search !== '' || $territoryId !== '' || $doNotCall !== ''): ?> | |||
| <a class="button button-secondary button-sm" href="/households">Clear</a> | |||
| <?php endif; ?> | |||
| </div> | |||
| </form> | |||
| <?php if (empty($households)): ?> | |||
| <div class="empty-state"> | |||
| <p>No households found.</p> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="table-responsive"> | |||
| <table class="data-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>Address</th> | |||
| <th>Street</th> | |||
| <th>Territory</th> | |||
| <th>Type</th> | |||
| <th>DNC</th> | |||
| <th>Actions</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <?php foreach ($households as $h): ?> | |||
| <tr> | |||
| <td> | |||
| <a href="/households/<?= e($h['id']) ?>"> | |||
| <?= e($h['address']) ?> | |||
| </a> | |||
| </td> | |||
| <td class="text-secondary"><?= e((string) ($h['street_name'] ?? '')) ?></td> | |||
| <td> | |||
| <a href="/territories/<?= e($h['territory_id']) ?>"> | |||
| <?= e($h['territory_name']) ?> | |||
| </a> | |||
| </td> | |||
| <td> | |||
| <?php if ($h['is_business']): ?> | |||
| <span class="badge badge-warning">Business</span> | |||
| <?php endif; ?> | |||
| </td> | |||
| <td> | |||
| <?php if ($h['do_not_call']): ?> | |||
| <span class="badge badge-danger">DNC</span> | |||
| <?php if ($h['do_not_call_date']): ?> | |||
| <span class="text-secondary" style="font-size:0.8rem"> | |||
| <?= e($h['do_not_call_date']) ?> | |||
| </span> | |||
| <?php endif; ?> | |||
| <?php endif; ?> | |||
| </td> | |||
| <td class="table-actions"> | |||
| <a class="button button-secondary button-sm" | |||
| href="/households/<?= e($h['id']) ?>">View</a> | |||
| <a class="button button-secondary button-sm" | |||
| href="/households/<?= e($h['id']) ?>/edit">Edit</a> | |||
| <form method="POST" action="/households/<?= e($h['id']) ?>/delete" | |||
| class="inline-form" | |||
| onsubmit="return confirm('Delete this household?')"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-danger button-sm" type="submit">Delete</button> | |||
| </form> | |||
| </td> | |||
| </tr> | |||
| <?php endforeach; ?> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <?php if ($pagination->hasPages()): ?> | |||
| <div class="pagination"> | |||
| <?php if ($pagination->previousPage()): ?> | |||
| <a class="page-link" href="<?= e(paginate_url($pagination->previousPage())) ?>">‹ Prev</a> | |||
| <?php endif; ?> | |||
| <?php foreach ($pagination->pageRange() as $p): ?> | |||
| <a class="page-link <?= $p === $pagination->page ? 'is-active' : '' ?>" | |||
| href="<?= e(paginate_url($p)) ?>"><?= $p ?></a> | |||
| <?php endforeach; ?> | |||
| <?php if ($pagination->nextPage()): ?> | |||
| <a class="page-link" href="<?= e(paginate_url($pagination->nextPage())) ?>">Next ›</a> | |||
| <?php endif; ?> | |||
| <span class="pagination-meta"><?= number_format($pagination->total) ?> total</span> | |||
| </div> | |||
| <?php endif; ?> | |||
| <?php endif; ?> | |||
| </div> | |||
| @@ -0,0 +1,154 @@ | |||
| <div class="page-header"> | |||
| <h1><?= e($household['address']) ?></h1> | |||
| <div class="page-header-actions"> | |||
| <a class="button button-secondary button-sm" | |||
| href="/households/<?= e($household['id']) ?>/edit">Edit</a> | |||
| <form method="POST" action="/households/<?= e($household['id']) ?>/delete" | |||
| class="inline-form" | |||
| onsubmit="return confirm('Delete this household?')"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-danger button-sm" type="submit">Delete</button> | |||
| </form> | |||
| <a class="button button-secondary button-sm" href="/households">← Back</a> | |||
| </div> | |||
| </div> | |||
| <div class="content-stack"> | |||
| <div class="section-panel"> | |||
| <div class="panel-header"><h2>Details</h2></div> | |||
| <dl class="detail-list"> | |||
| <dt>Territory</dt> | |||
| <dd> | |||
| <a href="/territories/<?= e($household['territory_id']) ?>"> | |||
| <?= e($household['territory_name']) ?> | |||
| </a> | |||
| </dd> | |||
| <?php if ($household['street_number'] || $household['street_name']): ?> | |||
| <dt>Street</dt> | |||
| <dd> | |||
| <?= e((string) ($household['street_number'] ?? '')) ?> | |||
| <?= e((string) ($household['street_name'] ?? '')) ?> | |||
| </dd> | |||
| <?php endif; ?> | |||
| <dt>Type</dt> | |||
| <dd> | |||
| <?php if ($household['is_business']): ?> | |||
| <span class="badge badge-warning">Business</span> | |||
| <?php else: ?> | |||
| Residential | |||
| <?php endif; ?> | |||
| </dd> | |||
| <dt>Do Not Call</dt> | |||
| <dd> | |||
| <?php if ($household['do_not_call']): ?> | |||
| <span class="badge badge-danger">Yes</span> | |||
| <?php if ($household['do_not_call_date']): ?> | |||
| <span class="text-secondary">(since <?= e($household['do_not_call_date']) ?>)</span> | |||
| <?php endif; ?> | |||
| <?php if ($household['do_not_call_notes']): ?> | |||
| <p style="margin:0.5rem 0 0"><?= e($household['do_not_call_notes']) ?></p> | |||
| <?php endif; ?> | |||
| <?php else: ?> | |||
| No | |||
| <?php endif; ?> | |||
| </dd> | |||
| <?php if ($household['latitude'] && $household['longitude']): ?> | |||
| <dt>Coordinates</dt> | |||
| <dd><?= e((string) $household['latitude']) ?>, <?= e((string) $household['longitude']) ?></dd> | |||
| <?php endif; ?> | |||
| </dl> | |||
| </div> | |||
| <?php if ($household['latitude'] && $household['longitude'] && $maps['maptiler_api_key'] !== ''): ?> | |||
| <div class="section-panel"> | |||
| <div class="panel-header"><h2>Map</h2></div> | |||
| <link rel="stylesheet" href="<?= e($maps['maptiler_sdk_css']) ?>"> | |||
| <div id="household-map" style="height:380px;border-radius:1rem;overflow:hidden"></div> | |||
| <script src="<?= e($maps['maptiler_sdk_js']) ?>"></script> | |||
| <script> | |||
| maptilersdk.config.apiKey = <?= json_encode($maps['maptiler_api_key']) ?>; | |||
| const map = new maptilersdk.Map({ | |||
| container: 'household-map', | |||
| style: <?= json_encode($maps['maptiler_style']) ?>, | |||
| center: [<?= (float) $household['longitude'] ?>, <?= (float) $household['latitude'] ?>], | |||
| zoom: 15 | |||
| }); | |||
| new maptilersdk.Marker() | |||
| .setLngLat([<?= (float) $household['longitude'] ?>, <?= (float) $household['latitude'] ?>]) | |||
| .addTo(map); | |||
| </script> | |||
| </div> | |||
| <?php endif; ?> | |||
| <div class="section-panel"> | |||
| <div class="panel-header" style="display:flex;justify-content:space-between;align-items:center"> | |||
| <h2 style="margin:0">Householder Names (<?= count($names) ?>)</h2> | |||
| <a class="button button-primary button-sm" | |||
| href="/householder-names/new?household_id=<?= e($household['id']) ?>">+ Add Name</a> | |||
| </div> | |||
| <?php if (empty($names)): ?> | |||
| <div class="empty-state"> | |||
| <p>No names recorded for this household.</p> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="table-responsive"> | |||
| <table class="data-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>Name</th> | |||
| <th>Added</th> | |||
| <th>Letter Returned</th> | |||
| <th>Actions</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <?php foreach ($names as $n): ?> | |||
| <tr> | |||
| <td><?= e($n['name']) ?></td> | |||
| <td class="text-secondary"> | |||
| <?= e((string) ($n['created_at'] ?? '')) ?> | |||
| </td> | |||
| <td> | |||
| <?php if ($n['letter_returned']): ?> | |||
| <span class="badge badge-success">Returned</span> | |||
| <?php if ($n['return_date']): ?> | |||
| <span class="text-secondary" style="font-size:0.8rem"> | |||
| <?= e($n['return_date']) ?> | |||
| </span> | |||
| <?php endif; ?> | |||
| <?php else: ?> | |||
| <span class="text-secondary">—</span> | |||
| <?php endif; ?> | |||
| </td> | |||
| <td class="table-actions"> | |||
| <form method="POST" | |||
| action="/householder-names/<?= e($n['id']) ?>/mark-returned" | |||
| class="inline-form"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-secondary button-sm" type="submit"> | |||
| <?= $n['letter_returned'] ? 'Unmark' : 'Mark Returned' ?> | |||
| </button> | |||
| </form> | |||
| <a class="button button-secondary button-sm" | |||
| href="/householder-names/<?= e($n['id']) ?>/edit">Edit</a> | |||
| <form method="POST" | |||
| action="/householder-names/<?= e($n['id']) ?>/delete" | |||
| class="inline-form" | |||
| onsubmit="return confirm('Delete this name?')"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-danger button-sm" type="submit">Delete</button> | |||
| </form> | |||
| </td> | |||
| </tr> | |||
| <?php endforeach; ?> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <?php endif; ?> | |||
| </div> | |||
| </div> | |||
| @@ -3,10 +3,19 @@ | |||
| declare(strict_types=1); | |||
| require __DIR__ . '/../partials/header.php'; | |||
| $flashSuccess = flash_get('success'); | |||
| $flashError = flash_get('error'); | |||
| ?> | |||
| <main class="page-content"> | |||
| <div class="container"> | |||
| <?php if ($flashSuccess): ?> | |||
| <div class="alert alert-success" role="alert"><?= e($flashSuccess) ?></div> | |||
| <?php endif; ?> | |||
| <?php if ($flashError): ?> | |||
| <div class="alert alert-error" role="alert"><?= e($flashError) ?></div> | |||
| <?php endif; ?> | |||
| <?= $content ?> | |||
| </div> | |||
| </main> | |||
| @@ -5,8 +5,11 @@ declare(strict_types=1); | |||
| use Cartalyst\Sentinel\Native\Facades\Sentinel; | |||
| $navigationItems = [ | |||
| ['label' => 'Home', 'href' => '/'], | |||
| ['label' => 'Example JSON', 'href' => '/users/123'], | |||
| ['label' => 'Home', 'href' => '/'], | |||
| ['label' => 'Territories', 'href' => '/territories'], | |||
| ['label' => 'Households', 'href' => '/households'], | |||
| ['label' => 'Householder Names', 'href' => '/householder-names'], | |||
| ['label' => 'Export', 'href' => '/export'], | |||
| ]; | |||
| $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | |||
| @@ -0,0 +1,44 @@ | |||
| <div class="page-header"> | |||
| <h1>New Territory</h1> | |||
| <a class="button button-secondary button-sm" href="/territories">← Back</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <?php if (!empty($errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/territories" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-grid"> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="name">Name <span style="color:#c0392b">*</span></label> | |||
| <input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>" | |||
| type="text" id="name" name="name" | |||
| value="<?= e((string) ($old['name'] ?? '')) ?>" required autofocus> | |||
| <?php if (isset($errors['name'])): ?> | |||
| <span class="field-error"><?= e($errors['name'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="description">Description</label> | |||
| <textarea class="input" id="description" name="description" | |||
| rows="3"><?= e((string) ($old['description'] ?? '')) ?></textarea> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="coordinates">Coordinates</label> | |||
| <textarea class="input" id="coordinates" name="coordinates" | |||
| rows="3" | |||
| placeholder="Optional map boundary coordinates"><?= e((string) ($old['coordinates'] ?? '')) ?></textarea> | |||
| </div> | |||
| </div> | |||
| <div class="form-actions" style="margin-top:1.5rem"> | |||
| <button class="button button-primary" type="submit">Create Territory</button> | |||
| <a class="button button-secondary" href="/territories">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,43 @@ | |||
| <div class="page-header"> | |||
| <h1>Edit Territory</h1> | |||
| <a class="button button-secondary button-sm" href="/territories/<?= e($territory['id']) ?>">← Back</a> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <?php if (!empty($errors['_token'])): ?> | |||
| <div class="alert alert-error"><?= e($errors['_token'][0]) ?></div> | |||
| <?php endif; ?> | |||
| <form method="POST" action="/territories/<?= e($territory['id']) ?>" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-grid"> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="name">Name <span style="color:#c0392b">*</span></label> | |||
| <input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>" | |||
| type="text" id="name" name="name" | |||
| value="<?= e((string) ($territory['name'] ?? '')) ?>" required autofocus> | |||
| <?php if (isset($errors['name'])): ?> | |||
| <span class="field-error"><?= e($errors['name'][0]) ?></span> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="description">Description</label> | |||
| <textarea class="input" id="description" name="description" | |||
| rows="3"><?= e((string) ($territory['description'] ?? '')) ?></textarea> | |||
| </div> | |||
| <div class="field" style="grid-column:1/-1"> | |||
| <label for="coordinates">Coordinates</label> | |||
| <textarea class="input" id="coordinates" name="coordinates" | |||
| rows="3"><?= e((string) ($territory['coordinates'] ?? '')) ?></textarea> | |||
| </div> | |||
| </div> | |||
| <div class="form-actions" style="margin-top:1.5rem"> | |||
| <button class="button button-primary" type="submit">Save Changes</button> | |||
| <a class="button button-secondary" href="/territories/<?= e($territory['id']) ?>">Cancel</a> | |||
| </div> | |||
| </form> | |||
| </div> | |||
| @@ -0,0 +1,91 @@ | |||
| <div class="page-header"> | |||
| <h1>Territories</h1> | |||
| <div class="page-header-actions"> | |||
| <a class="button button-primary button-sm" href="/territories/new">+ New Territory</a> | |||
| <a class="button button-secondary button-sm" href="/export">Export</a> | |||
| </div> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <form class="filter-bar" method="GET" action="/territories"> | |||
| <div class="field" style="flex:1;min-width:220px"> | |||
| <label for="search">Search</label> | |||
| <input class="input" type="search" id="search" name="search" | |||
| value="<?= e($search) ?>" placeholder="Name or description…"> | |||
| </div> | |||
| <div class="form-actions" style="align-self:flex-end"> | |||
| <button class="button button-primary button-sm" type="submit">Filter</button> | |||
| <?php if ($search !== ''): ?> | |||
| <a class="button button-secondary button-sm" href="/territories">Clear</a> | |||
| <?php endif; ?> | |||
| </div> | |||
| </form> | |||
| <?php if (empty($territories)): ?> | |||
| <div class="empty-state"> | |||
| <p>No territories found<?= $search !== '' ? ' matching "' . e($search) . '"' : '' ?>.</p> | |||
| <?php if ($search === ''): ?> | |||
| <p><a href="/territories/new">Create the first territory</a>.</p> | |||
| <?php endif; ?> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="table-responsive"> | |||
| <table class="data-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>Name</th> | |||
| <th>Description</th> | |||
| <th>Households</th> | |||
| <th>Actions</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <?php foreach ($territories as $territory): ?> | |||
| <tr> | |||
| <td> | |||
| <a href="/territories/<?= e($territory['id']) ?>"> | |||
| <?= e($territory['name']) ?> | |||
| </a> | |||
| </td> | |||
| <td class="text-secondary"><?= e((string) ($territory['description'] ?? '')) ?></td> | |||
| <td><?= (int) ($counts[(int) $territory['id']] ?? 0) ?></td> | |||
| <td class="table-actions"> | |||
| <a class="button button-secondary button-sm" | |||
| href="/territories/<?= e($territory['id']) ?>">View</a> | |||
| <a class="button button-secondary button-sm" | |||
| href="/territories/<?= e($territory['id']) ?>/edit">Edit</a> | |||
| <form method="POST" action="/territories/<?= e($territory['id']) ?>/delete" | |||
| class="inline-form" | |||
| onsubmit="return confirm('Delete territory \'<?= e(addslashes($territory['name'])) ?>\'?')"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-danger button-sm" type="submit">Delete</button> | |||
| </form> | |||
| </td> | |||
| </tr> | |||
| <?php endforeach; ?> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <?php if ($pagination->hasPages()): ?> | |||
| <div class="pagination"> | |||
| <?php if ($pagination->previousPage()): ?> | |||
| <a class="page-link" href="<?= e(paginate_url($pagination->previousPage())) ?>">‹ Prev</a> | |||
| <?php endif; ?> | |||
| <?php foreach ($pagination->pageRange() as $p): ?> | |||
| <a class="page-link <?= $p === $pagination->page ? 'is-active' : '' ?>" | |||
| href="<?= e(paginate_url($p)) ?>"><?= $p ?></a> | |||
| <?php endforeach; ?> | |||
| <?php if ($pagination->nextPage()): ?> | |||
| <a class="page-link" href="<?= e(paginate_url($pagination->nextPage())) ?>">Next ›</a> | |||
| <?php endif; ?> | |||
| <span class="pagination-meta"> | |||
| <?= number_format($pagination->total) ?> total | |||
| </span> | |||
| </div> | |||
| <?php endif; ?> | |||
| <?php endif; ?> | |||
| </div> | |||
| @@ -0,0 +1,95 @@ | |||
| <div class="page-header"> | |||
| <h1><?= e($territory['name']) ?></h1> | |||
| <div class="page-header-actions"> | |||
| <a class="button button-secondary button-sm" href="/territories/<?= e($territory['id']) ?>/edit">Edit</a> | |||
| <form method="POST" action="/territories/<?= e($territory['id']) ?>/delete" | |||
| class="inline-form" | |||
| onsubmit="return confirm('Delete this territory?')"> | |||
| <?= csrf_field() ?> | |||
| <button class="button button-danger button-sm" type="submit">Delete</button> | |||
| </form> | |||
| <a class="button button-secondary button-sm" href="/territories">← Back</a> | |||
| </div> | |||
| </div> | |||
| <?php if ($territory['description']): ?> | |||
| <div class="section-panel" style="margin-bottom:1.25rem"> | |||
| <p style="margin:0"><?= e($territory['description']) ?></p> | |||
| </div> | |||
| <?php endif; ?> | |||
| <div class="content-stack"> | |||
| <div class="section-panel"> | |||
| <div class="panel-header"> | |||
| <h2>Streets</h2> | |||
| </div> | |||
| <?php if (empty($streets)): ?> | |||
| <div class="empty-state"> | |||
| <p>No streets recorded yet. <a href="/households/new?territory_id=<?= e($territory['id']) ?>">Add a household</a> to this territory.</p> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="street-chips"> | |||
| <?php foreach ($streets as $street): ?> | |||
| <span class="badge badge-success"><?= e($street) ?></span> | |||
| <?php endforeach; ?> | |||
| </div> | |||
| <?php endif; ?> | |||
| </div> | |||
| <div class="section-panel"> | |||
| <div class="panel-header" style="display:flex;justify-content:space-between;align-items:center"> | |||
| <h2 style="margin:0">Households (<?= count($households) ?>)</h2> | |||
| <a class="button button-primary button-sm" | |||
| href="/households/new?territory_id=<?= e($territory['id']) ?>">+ Add Household</a> | |||
| </div> | |||
| <?php if (empty($households)): ?> | |||
| <div class="empty-state"> | |||
| <p>No households in this territory yet.</p> | |||
| </div> | |||
| <?php else: ?> | |||
| <div class="table-responsive"> | |||
| <table class="data-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>#</th> | |||
| <th>Address</th> | |||
| <th>Street</th> | |||
| <th>Business</th> | |||
| <th>DNC</th> | |||
| <th>Actions</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <?php foreach ($households as $h): ?> | |||
| <tr> | |||
| <td class="text-secondary"><?= e((string) ($h['street_number'] ?? '')) ?></td> | |||
| <td> | |||
| <a href="/households/<?= e($h['id']) ?>"><?= e($h['address']) ?></a> | |||
| </td> | |||
| <td class="text-secondary"><?= e((string) ($h['street_name'] ?? '')) ?></td> | |||
| <td> | |||
| <?php if ($h['is_business']): ?> | |||
| <span class="badge badge-warning">Business</span> | |||
| <?php endif; ?> | |||
| </td> | |||
| <td> | |||
| <?php if ($h['do_not_call']): ?> | |||
| <span class="badge badge-danger">DNC</span> | |||
| <?php endif; ?> | |||
| </td> | |||
| <td class="table-actions"> | |||
| <a class="button button-secondary button-sm" | |||
| href="/households/<?= e($h['id']) ?>">View</a> | |||
| <a class="button button-secondary button-sm" | |||
| href="/households/<?= e($h['id']) ?>/edit">Edit</a> | |||
| </td> | |||
| </tr> | |||
| <?php endforeach; ?> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| <?php endif; ?> | |||
| </div> | |||
| </div> | |||
| @@ -24,6 +24,8 @@ | |||
| "illuminate/database": "^10.0", | |||
| "illuminate/events": "^10.0", | |||
| "symfony/http-foundation": "^6.0", | |||
| "phpmailer/phpmailer": "^7.1" | |||
| "phpmailer/phpmailer": "^7.1", | |||
| "phpoffice/phpspreadsheet": "^2.0", | |||
| "dompdf/dompdf": "^3.0" | |||
| } | |||
| } | |||
| @@ -4,7 +4,7 @@ | |||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||
| "This file is @generated automatically" | |||
| ], | |||
| "content-hash": "37ea658ac1ecd261eb123806529f89e3", | |||
| "content-hash": "cb8272e0d540d99369560b569f30567d", | |||
| "packages": [ | |||
| { | |||
| "name": "brick/math", | |||
| @@ -277,6 +277,85 @@ | |||
| }, | |||
| "time": "2023-02-22T21:51:13+00:00" | |||
| }, | |||
| { | |||
| "name": "composer/pcre", | |||
| "version": "3.3.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/composer/pcre.git", | |||
| "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", | |||
| "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.4 || ^8.0" | |||
| }, | |||
| "conflict": { | |||
| "phpstan/phpstan": "<1.11.10" | |||
| }, | |||
| "require-dev": { | |||
| "phpstan/phpstan": "^1.12 || ^2", | |||
| "phpstan/phpstan-strict-rules": "^1 || ^2", | |||
| "phpunit/phpunit": "^8 || ^9" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "phpstan": { | |||
| "includes": [ | |||
| "extension.neon" | |||
| ] | |||
| }, | |||
| "branch-alias": { | |||
| "dev-main": "3.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Composer\\Pcre\\": "src" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Jordi Boggiano", | |||
| "email": "j.boggiano@seld.be", | |||
| "homepage": "http://seld.be" | |||
| } | |||
| ], | |||
| "description": "PCRE wrapping library that offers type-safe preg_* replacements.", | |||
| "keywords": [ | |||
| "PCRE", | |||
| "preg", | |||
| "regex", | |||
| "regular expression" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/composer/pcre/issues", | |||
| "source": "https://github.com/composer/pcre/tree/3.3.2" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://packagist.com", | |||
| "type": "custom" | |||
| }, | |||
| { | |||
| "url": "https://github.com/composer", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://tidelift.com/funding/github/packagist/composer/composer", | |||
| "type": "tidelift" | |||
| } | |||
| ], | |||
| "time": "2024-11-12T16:29:46+00:00" | |||
| }, | |||
| { | |||
| "name": "doctrine/inflector", | |||
| "version": "2.1.0", | |||
| @@ -367,6 +446,161 @@ | |||
| ], | |||
| "time": "2025-08-10T19:31:58+00:00" | |||
| }, | |||
| { | |||
| "name": "dompdf/dompdf", | |||
| "version": "v3.1.5", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/dompdf/dompdf.git", | |||
| "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", | |||
| "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "dompdf/php-font-lib": "^1.0.0", | |||
| "dompdf/php-svg-lib": "^1.0.0", | |||
| "ext-dom": "*", | |||
| "ext-mbstring": "*", | |||
| "masterminds/html5": "^2.0", | |||
| "php": "^7.1 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "ext-gd": "*", | |||
| "ext-json": "*", | |||
| "ext-zip": "*", | |||
| "mockery/mockery": "^1.3", | |||
| "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", | |||
| "squizlabs/php_codesniffer": "^3.5", | |||
| "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" | |||
| }, | |||
| "suggest": { | |||
| "ext-gd": "Needed to process images", | |||
| "ext-gmagick": "Improves image processing performance", | |||
| "ext-imagick": "Improves image processing performance", | |||
| "ext-zlib": "Needed for pdf stream compression" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Dompdf\\": "src/" | |||
| }, | |||
| "classmap": [ | |||
| "lib/" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "LGPL-2.1" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "The Dompdf Community", | |||
| "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" | |||
| } | |||
| ], | |||
| "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", | |||
| "homepage": "https://github.com/dompdf/dompdf", | |||
| "support": { | |||
| "issues": "https://github.com/dompdf/dompdf/issues", | |||
| "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" | |||
| }, | |||
| "time": "2026-03-03T13:54:37+00:00" | |||
| }, | |||
| { | |||
| "name": "dompdf/php-font-lib", | |||
| "version": "1.0.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/dompdf/php-font-lib.git", | |||
| "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", | |||
| "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-mbstring": "*", | |||
| "php": "^7.1 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "FontLib\\": "src/FontLib" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "LGPL-2.1-or-later" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "The FontLib Community", | |||
| "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" | |||
| } | |||
| ], | |||
| "description": "A library to read, parse, export and make subsets of different types of font files.", | |||
| "homepage": "https://github.com/dompdf/php-font-lib", | |||
| "support": { | |||
| "issues": "https://github.com/dompdf/php-font-lib/issues", | |||
| "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" | |||
| }, | |||
| "time": "2026-01-20T14:10:26+00:00" | |||
| }, | |||
| { | |||
| "name": "dompdf/php-svg-lib", | |||
| "version": "1.0.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/dompdf/php-svg-lib.git", | |||
| "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", | |||
| "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-mbstring": "*", | |||
| "php": "^7.1 || ^8.0", | |||
| "sabberworm/php-css-parser": "^8.4 || ^9.0" | |||
| }, | |||
| "require-dev": { | |||
| "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Svg\\": "src/Svg" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "LGPL-3.0-or-later" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "The SvgLib Community", | |||
| "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" | |||
| } | |||
| ], | |||
| "description": "A library to read, parse and export to PDF SVG files.", | |||
| "homepage": "https://github.com/dompdf/php-svg-lib", | |||
| "support": { | |||
| "issues": "https://github.com/dompdf/php-svg-lib/issues", | |||
| "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" | |||
| }, | |||
| "time": "2026-01-02T16:01:13+00:00" | |||
| }, | |||
| { | |||
| "name": "illuminate/bus", | |||
| "version": "v10.49.0", | |||
| @@ -913,6 +1147,258 @@ | |||
| }, | |||
| "time": "2025-09-08T19:05:53+00:00" | |||
| }, | |||
| { | |||
| "name": "maennchen/zipstream-php", | |||
| "version": "3.2.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/maennchen/ZipStream-PHP.git", | |||
| "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", | |||
| "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-mbstring": "*", | |||
| "ext-zlib": "*", | |||
| "php-64bit": "^8.3" | |||
| }, | |||
| "require-dev": { | |||
| "brianium/paratest": "^7.7", | |||
| "ext-zip": "*", | |||
| "friendsofphp/php-cs-fixer": "^3.86", | |||
| "guzzlehttp/guzzle": "^7.5", | |||
| "mikey179/vfsstream": "^1.6", | |||
| "php-coveralls/php-coveralls": "^2.5", | |||
| "phpunit/phpunit": "^12.0", | |||
| "vimeo/psalm": "^6.0" | |||
| }, | |||
| "suggest": { | |||
| "guzzlehttp/psr7": "^2.4", | |||
| "psr/http-message": "^2.0" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "ZipStream\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Paul Duncan", | |||
| "email": "pabs@pablotron.org" | |||
| }, | |||
| { | |||
| "name": "Jonatan Männchen", | |||
| "email": "jonatan@maennchen.ch" | |||
| }, | |||
| { | |||
| "name": "Jesse Donat", | |||
| "email": "donatj@gmail.com" | |||
| }, | |||
| { | |||
| "name": "András Kolesár", | |||
| "email": "kolesar@kolesar.hu" | |||
| } | |||
| ], | |||
| "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", | |||
| "keywords": [ | |||
| "stream", | |||
| "zip" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/maennchen/ZipStream-PHP/issues", | |||
| "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://github.com/maennchen", | |||
| "type": "github" | |||
| } | |||
| ], | |||
| "time": "2026-04-11T18:38:28+00:00" | |||
| }, | |||
| { | |||
| "name": "markbaker/complex", | |||
| "version": "3.0.2", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/MarkBaker/PHPComplex.git", | |||
| "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", | |||
| "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.2 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "dealerdirect/phpcodesniffer-composer-installer": "dev-master", | |||
| "phpcompatibility/php-compatibility": "^9.3", | |||
| "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", | |||
| "squizlabs/php_codesniffer": "^3.7" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Complex\\": "classes/src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Mark Baker", | |||
| "email": "mark@lange.demon.co.uk" | |||
| } | |||
| ], | |||
| "description": "PHP Class for working with complex numbers", | |||
| "homepage": "https://github.com/MarkBaker/PHPComplex", | |||
| "keywords": [ | |||
| "complex", | |||
| "mathematics" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/MarkBaker/PHPComplex/issues", | |||
| "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" | |||
| }, | |||
| "time": "2022-12-06T16:21:08+00:00" | |||
| }, | |||
| { | |||
| "name": "markbaker/matrix", | |||
| "version": "3.0.1", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/MarkBaker/PHPMatrix.git", | |||
| "reference": "728434227fe21be27ff6d86621a1b13107a2562c" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", | |||
| "reference": "728434227fe21be27ff6d86621a1b13107a2562c", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^7.1 || ^8.0" | |||
| }, | |||
| "require-dev": { | |||
| "dealerdirect/phpcodesniffer-composer-installer": "dev-master", | |||
| "phpcompatibility/php-compatibility": "^9.3", | |||
| "phpdocumentor/phpdocumentor": "2.*", | |||
| "phploc/phploc": "^4.0", | |||
| "phpmd/phpmd": "2.*", | |||
| "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", | |||
| "sebastian/phpcpd": "^4.0", | |||
| "squizlabs/php_codesniffer": "^3.7" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Matrix\\": "classes/src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Mark Baker", | |||
| "email": "mark@demon-angel.eu" | |||
| } | |||
| ], | |||
| "description": "PHP Class for working with matrices", | |||
| "homepage": "https://github.com/MarkBaker/PHPMatrix", | |||
| "keywords": [ | |||
| "mathematics", | |||
| "matrix", | |||
| "vector" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/MarkBaker/PHPMatrix/issues", | |||
| "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" | |||
| }, | |||
| "time": "2022-12-02T22:17:43+00:00" | |||
| }, | |||
| { | |||
| "name": "masterminds/html5", | |||
| "version": "2.10.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/Masterminds/html5-php.git", | |||
| "reference": "fcf91eb64359852f00d921887b219479b4f21251" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", | |||
| "reference": "fcf91eb64359852f00d921887b219479b4f21251", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-dom": "*", | |||
| "php": ">=5.3.0" | |||
| }, | |||
| "require-dev": { | |||
| "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-master": "2.7-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "psr-4": { | |||
| "Masterminds\\": "src" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Matt Butcher", | |||
| "email": "technosophos@gmail.com" | |||
| }, | |||
| { | |||
| "name": "Matt Farina", | |||
| "email": "matt@mattfarina.com" | |||
| }, | |||
| { | |||
| "name": "Asmir Mustafic", | |||
| "email": "goetas@gmail.com" | |||
| } | |||
| ], | |||
| "description": "An HTML5 parser and serializer.", | |||
| "homepage": "http://masterminds.github.io/html5-php", | |||
| "keywords": [ | |||
| "HTML5", | |||
| "dom", | |||
| "html", | |||
| "parser", | |||
| "querypath", | |||
| "serializer", | |||
| "xml" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/Masterminds/html5-php/issues", | |||
| "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" | |||
| }, | |||
| "time": "2025-07-25T09:04:22+00:00" | |||
| }, | |||
| { | |||
| "name": "nesbot/carbon", | |||
| "version": "2.73.0", | |||
| @@ -1102,6 +1588,112 @@ | |||
| ], | |||
| "time": "2026-05-18T08:06:14+00:00" | |||
| }, | |||
| { | |||
| "name": "phpoffice/phpspreadsheet", | |||
| "version": "2.4.5", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", | |||
| "reference": "ec7815be350e03df90f3e2ace92653fa6cb4327c" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/ec7815be350e03df90f3e2ace92653fa6cb4327c", | |||
| "reference": "ec7815be350e03df90f3e2ace92653fa6cb4327c", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "composer/pcre": "^1 || ^2 || ^3", | |||
| "ext-ctype": "*", | |||
| "ext-dom": "*", | |||
| "ext-fileinfo": "*", | |||
| "ext-gd": "*", | |||
| "ext-iconv": "*", | |||
| "ext-libxml": "*", | |||
| "ext-mbstring": "*", | |||
| "ext-simplexml": "*", | |||
| "ext-xml": "*", | |||
| "ext-xmlreader": "*", | |||
| "ext-xmlwriter": "*", | |||
| "ext-zip": "*", | |||
| "ext-zlib": "*", | |||
| "maennchen/zipstream-php": "^2.1 || ^3.0", | |||
| "markbaker/complex": "^3.0", | |||
| "markbaker/matrix": "^3.0", | |||
| "php": ">=8.1.0 <8.6.0", | |||
| "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" | |||
| }, | |||
| "require-dev": { | |||
| "dealerdirect/phpcodesniffer-composer-installer": "dev-main", | |||
| "dompdf/dompdf": "^2.0 || ^3.0", | |||
| "friendsofphp/php-cs-fixer": "^3.2", | |||
| "mitoteam/jpgraph": "^10.5", | |||
| "mpdf/mpdf": "^8.1.1", | |||
| "phpcompatibility/php-compatibility": "^9.3", | |||
| "phpstan/phpstan": "^1.1", | |||
| "phpstan/phpstan-phpunit": "^1.0", | |||
| "phpunit/phpunit": "^9.6 || ^10.5", | |||
| "squizlabs/php_codesniffer": "^3.7", | |||
| "tecnickcom/tcpdf": "^6.5" | |||
| }, | |||
| "suggest": { | |||
| "dompdf/dompdf": "Option for rendering PDF with PDF Writer", | |||
| "ext-intl": "PHP Internationalization Functions, required for NumberFormatter Wizard", | |||
| "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", | |||
| "mpdf/mpdf": "Option for rendering PDF with PDF Writer", | |||
| "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "psr-4": { | |||
| "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Maarten Balliauw", | |||
| "homepage": "https://blog.maartenballiauw.be" | |||
| }, | |||
| { | |||
| "name": "Mark Baker", | |||
| "homepage": "https://markbakeruk.net" | |||
| }, | |||
| { | |||
| "name": "Franck Lefevre", | |||
| "homepage": "https://rootslabs.net" | |||
| }, | |||
| { | |||
| "name": "Erik Tilt" | |||
| }, | |||
| { | |||
| "name": "Adrien Crivelli" | |||
| }, | |||
| { | |||
| "name": "Owen Leibman" | |||
| } | |||
| ], | |||
| "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", | |||
| "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", | |||
| "keywords": [ | |||
| "OpenXML", | |||
| "excel", | |||
| "gnumeric", | |||
| "ods", | |||
| "php", | |||
| "spreadsheet", | |||
| "xls", | |||
| "xlsx" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", | |||
| "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.5" | |||
| }, | |||
| "time": "2026-04-19T05:48:49+00:00" | |||
| }, | |||
| { | |||
| "name": "psr/clock", | |||
| "version": "1.0.0", | |||
| @@ -1254,6 +1846,86 @@ | |||
| }, | |||
| "time": "2021-10-29T13:26:27+00:00" | |||
| }, | |||
| { | |||
| "name": "sabberworm/php-css-parser", | |||
| "version": "v9.3.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", | |||
| "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", | |||
| "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "ext-iconv": "*", | |||
| "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", | |||
| "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" | |||
| }, | |||
| "require-dev": { | |||
| "php-parallel-lint/php-parallel-lint": "1.4.0", | |||
| "phpstan/extension-installer": "1.4.3", | |||
| "phpstan/phpstan": "1.12.32 || 2.1.32", | |||
| "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", | |||
| "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", | |||
| "phpunit/phpunit": "8.5.52", | |||
| "rawr/phpunit-data-provider": "3.3.1", | |||
| "rector/rector": "1.2.10 || 2.2.8", | |||
| "rector/type-perfect": "1.0.0 || 2.1.0", | |||
| "squizlabs/php_codesniffer": "4.0.1", | |||
| "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" | |||
| }, | |||
| "suggest": { | |||
| "ext-mbstring": "for parsing UTF-8 CSS" | |||
| }, | |||
| "type": "library", | |||
| "extra": { | |||
| "branch-alias": { | |||
| "dev-main": "9.4.x-dev" | |||
| } | |||
| }, | |||
| "autoload": { | |||
| "files": [ | |||
| "src/Rule/Rule.php", | |||
| "src/RuleSet/RuleContainer.php" | |||
| ], | |||
| "psr-4": { | |||
| "Sabberworm\\CSS\\": "src/" | |||
| } | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "authors": [ | |||
| { | |||
| "name": "Raphael Schweikert" | |||
| }, | |||
| { | |||
| "name": "Oliver Klee", | |||
| "email": "github@oliverklee.de" | |||
| }, | |||
| { | |||
| "name": "Jake Hotson", | |||
| "email": "jake.github@qzdesign.co.uk" | |||
| } | |||
| ], | |||
| "description": "Parser for CSS Files written in PHP", | |||
| "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", | |||
| "keywords": [ | |||
| "css", | |||
| "parser", | |||
| "stylesheet" | |||
| ], | |||
| "support": { | |||
| "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", | |||
| "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" | |||
| }, | |||
| "time": "2026-03-03T17:31:43+00:00" | |||
| }, | |||
| { | |||
| "name": "symfony/deprecation-contracts", | |||
| "version": "v3.7.0", | |||
| @@ -1836,6 +2508,149 @@ | |||
| ], | |||
| "time": "2026-01-05T13:30:16+00:00" | |||
| }, | |||
| { | |||
| "name": "thecodingmachine/safe", | |||
| "version": "v3.4.0", | |||
| "source": { | |||
| "type": "git", | |||
| "url": "https://github.com/thecodingmachine/safe.git", | |||
| "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" | |||
| }, | |||
| "dist": { | |||
| "type": "zip", | |||
| "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", | |||
| "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", | |||
| "shasum": "" | |||
| }, | |||
| "require": { | |||
| "php": "^8.1" | |||
| }, | |||
| "require-dev": { | |||
| "php-parallel-lint/php-parallel-lint": "^1.4", | |||
| "phpstan/phpstan": "^2", | |||
| "phpunit/phpunit": "^10", | |||
| "squizlabs/php_codesniffer": "^3.2" | |||
| }, | |||
| "type": "library", | |||
| "autoload": { | |||
| "files": [ | |||
| "lib/special_cases.php", | |||
| "generated/apache.php", | |||
| "generated/apcu.php", | |||
| "generated/array.php", | |||
| "generated/bzip2.php", | |||
| "generated/calendar.php", | |||
| "generated/classobj.php", | |||
| "generated/com.php", | |||
| "generated/cubrid.php", | |||
| "generated/curl.php", | |||
| "generated/datetime.php", | |||
| "generated/dir.php", | |||
| "generated/eio.php", | |||
| "generated/errorfunc.php", | |||
| "generated/exec.php", | |||
| "generated/fileinfo.php", | |||
| "generated/filesystem.php", | |||
| "generated/filter.php", | |||
| "generated/fpm.php", | |||
| "generated/ftp.php", | |||
| "generated/funchand.php", | |||
| "generated/gettext.php", | |||
| "generated/gmp.php", | |||
| "generated/gnupg.php", | |||
| "generated/hash.php", | |||
| "generated/ibase.php", | |||
| "generated/ibmDb2.php", | |||
| "generated/iconv.php", | |||
| "generated/image.php", | |||
| "generated/imap.php", | |||
| "generated/info.php", | |||
| "generated/inotify.php", | |||
| "generated/json.php", | |||
| "generated/ldap.php", | |||
| "generated/libxml.php", | |||
| "generated/lzf.php", | |||
| "generated/mailparse.php", | |||
| "generated/mbstring.php", | |||
| "generated/misc.php", | |||
| "generated/mysql.php", | |||
| "generated/mysqli.php", | |||
| "generated/network.php", | |||
| "generated/oci8.php", | |||
| "generated/opcache.php", | |||
| "generated/openssl.php", | |||
| "generated/outcontrol.php", | |||
| "generated/pcntl.php", | |||
| "generated/pcre.php", | |||
| "generated/pgsql.php", | |||
| "generated/posix.php", | |||
| "generated/ps.php", | |||
| "generated/pspell.php", | |||
| "generated/readline.php", | |||
| "generated/rnp.php", | |||
| "generated/rpminfo.php", | |||
| "generated/rrd.php", | |||
| "generated/sem.php", | |||
| "generated/session.php", | |||
| "generated/shmop.php", | |||
| "generated/sockets.php", | |||
| "generated/sodium.php", | |||
| "generated/solr.php", | |||
| "generated/spl.php", | |||
| "generated/sqlsrv.php", | |||
| "generated/ssdeep.php", | |||
| "generated/ssh2.php", | |||
| "generated/stream.php", | |||
| "generated/strings.php", | |||
| "generated/swoole.php", | |||
| "generated/uodbc.php", | |||
| "generated/uopz.php", | |||
| "generated/url.php", | |||
| "generated/var.php", | |||
| "generated/xdiff.php", | |||
| "generated/xml.php", | |||
| "generated/xmlrpc.php", | |||
| "generated/yaml.php", | |||
| "generated/yaz.php", | |||
| "generated/zip.php", | |||
| "generated/zlib.php" | |||
| ], | |||
| "classmap": [ | |||
| "lib/DateTime.php", | |||
| "lib/DateTimeImmutable.php", | |||
| "lib/Exceptions/", | |||
| "generated/Exceptions/" | |||
| ] | |||
| }, | |||
| "notification-url": "https://packagist.org/downloads/", | |||
| "license": [ | |||
| "MIT" | |||
| ], | |||
| "description": "PHP core functions that throw exceptions instead of returning FALSE on error", | |||
| "support": { | |||
| "issues": "https://github.com/thecodingmachine/safe/issues", | |||
| "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" | |||
| }, | |||
| "funding": [ | |||
| { | |||
| "url": "https://github.com/OskarStark", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/shish", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/silasjoisten", | |||
| "type": "github" | |||
| }, | |||
| { | |||
| "url": "https://github.com/staabm", | |||
| "type": "github" | |||
| } | |||
| ], | |||
| "time": "2026-02-04T18:08:13+00:00" | |||
| }, | |||
| { | |||
| "name": "voku/portable-ascii", | |||
| "version": "2.1.1", | |||
| @@ -0,0 +1,11 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| return [ | |||
| 'provider' => $_ENV['MAP_PROVIDER'] ?? 'maptiler', | |||
| 'maptiler_api_key' => $_ENV['MAPTILER_API_KEY'] ?? '', | |||
| 'maptiler_style' => $_ENV['MAPTILER_STYLE'] ?? 'streets-v2', | |||
| 'maptiler_sdk_js' => 'https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.umd.min.js', | |||
| 'maptiler_sdk_css' => 'https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css', | |||
| ]; | |||
| @@ -4,6 +4,8 @@ declare(strict_types=1); | |||
| namespace Core; | |||
| use Cartalyst\Sentinel\Native\Facades\Sentinel; | |||
| abstract class Controller | |||
| { | |||
| protected function view(string $view, array $data = []): Response | |||
| @@ -32,4 +34,21 @@ abstract class Controller | |||
| throw new \Exception('This action requires POST.'); | |||
| } | |||
| } | |||
| protected function requireAuth(): ?Response | |||
| { | |||
| if (!Sentinel::check()) { | |||
| return $this->redirect('/login'); | |||
| } | |||
| return null; | |||
| } | |||
| protected function fileResponse(string $content, string $filename, string $mimeType = 'application/octet-stream'): Response | |||
| { | |||
| return new Response($content, 200, [ | |||
| 'Content-Type' => $mimeType, | |||
| 'Content-Disposition' => 'attachment; filename="' . $filename . '"', | |||
| 'Content-Length' => (string) strlen($content), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace Core; | |||
| class Pagination | |||
| { | |||
| public readonly int $totalPages; | |||
| public readonly int $offset; | |||
| public function __construct( | |||
| public readonly int $total, | |||
| public readonly int $page, | |||
| public readonly int $perPage | |||
| ) { | |||
| $this->totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 1; | |||
| $this->offset = ($page - 1) * $perPage; | |||
| } | |||
| public function hasPages(): bool | |||
| { | |||
| return $this->totalPages > 1; | |||
| } | |||
| public function previousPage(): ?int | |||
| { | |||
| return $this->page > 1 ? $this->page - 1 : null; | |||
| } | |||
| public function nextPage(): ?int | |||
| { | |||
| return $this->page < $this->totalPages ? $this->page + 1 : null; | |||
| } | |||
| /** @return list<int> */ | |||
| public function pageRange(): array | |||
| { | |||
| $start = max(1, $this->page - 2); | |||
| $end = min($this->totalPages, $this->page + 2); | |||
| return range($start, $end); | |||
| } | |||
| } | |||
| @@ -35,6 +35,15 @@ class Validator | |||
| return $this; | |||
| } | |||
| public function optionalNumeric(string $field, mixed $value, string $message = ''): self | |||
| { | |||
| if ($value !== null && trim((string) $value) !== '' && !is_numeric($value)) { | |||
| $this->errors[$field][] = $message ?: "{$field} must be a number."; | |||
| } | |||
| return $this; | |||
| } | |||
| public function passes(): bool | |||
| { | |||
| return empty($this->errors); | |||
| @@ -134,3 +134,33 @@ function verify_csrf_token(?string $token): bool | |||
| return is_string($sessionToken) && hash_equals($sessionToken, $token); | |||
| } | |||
| function flash(string $key, string $message): void | |||
| { | |||
| ensureSessionStarted(); | |||
| $_SESSION['_flash'][$key] = $message; | |||
| } | |||
| function flash_get(string $key): ?string | |||
| { | |||
| ensureSessionStarted(); | |||
| $value = $_SESSION['_flash'][$key] ?? null; | |||
| unset($_SESSION['_flash'][$key]); | |||
| return is_string($value) ? $value : null; | |||
| } | |||
| function paginate_url(int $page, array $merge = []): string | |||
| { | |||
| $params = array_merge($_GET, $merge, ['page' => $page]); | |||
| $params = array_filter($params, fn($v) => $v !== '' && $v !== null); | |||
| return '?' . http_build_query($params); | |||
| } | |||
| function maps_config(): array | |||
| { | |||
| static $config = null; | |||
| if ($config === null) { | |||
| $config = require __DIR__ . '/../config/maps.php'; | |||
| } | |||
| return $config; | |||
| } | |||
| @@ -0,0 +1,354 @@ | |||
| <# | |||
| .SYNOPSIS | |||
| Migrates territory data from database\myAccessFile.accdb to database\app.sqlite. | |||
| .DESCRIPTION | |||
| Reads the Territories, Households, and HouseholderNames tables from the Access | |||
| database and inserts them into the matching SQLite tables. | |||
| Prerequisites: | |||
| - 64-bit Microsoft Access Database Engine (ACE OLEDB 12.0): | |||
| https://www.microsoft.com/en-us/download/details.aspx?id=54920 | |||
| If you have 32-bit Office installed, run from 32-bit PowerShell instead: | |||
| %SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe | |||
| - sqlite3.exe on PATH, in the project root, or in database\ . | |||
| Download: https://www.sqlite.org/download.html (sqlite-tools-win-x64-*.zip) | |||
| The script will attempt to download it automatically if not found. | |||
| .PARAMETER AccessFile | |||
| Path to the .accdb file. Relative paths resolve from the project root. | |||
| Default: database\myAccessFile.accdb | |||
| .PARAMETER SQLiteFile | |||
| Path to the SQLite database. Relative paths resolve from the project root. | |||
| Default: database\app.sqlite | |||
| .PARAMETER DryRun | |||
| Print Access row counts without writing any data to SQLite. | |||
| .PARAMETER Sqlite3Path | |||
| Explicit path to sqlite3.exe (overrides automatic search). | |||
| .EXAMPLE | |||
| .\database\Migrate-AccessToSQLite.ps1 | |||
| .\database\Migrate-AccessToSQLite.ps1 -DryRun | |||
| .\database\Migrate-AccessToSQLite.ps1 -Sqlite3Path C:\sqlite\sqlite3.exe | |||
| #> | |||
| [CmdletBinding()] | |||
| param( | |||
| [string]$AccessFile = "database\myAccessFile.accdb", | |||
| [string]$SQLiteFile = "database\app.sqlite", | |||
| [switch]$DryRun, | |||
| [string]$Sqlite3Path = "" | |||
| ) | |||
| Set-StrictMode -Version Latest | |||
| $ErrorActionPreference = 'Stop' | |||
| # ── Resolve project root ─────────────────────────────────────────────────────── | |||
| $projectRoot = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path } | |||
| if ($projectRoot -match '[/\\]database$') { | |||
| $projectRoot = Split-Path $projectRoot -Parent | |||
| } | |||
| function Resolve-ProjectPath([string]$p) { | |||
| if ([System.IO.Path]::IsPathRooted($p)) { return $p } | |||
| return Join-Path $projectRoot $p | |||
| } | |||
| $accessPath = Resolve-ProjectPath $AccessFile | |||
| $sqlitePath = Resolve-ProjectPath $SQLiteFile | |||
| if (-not (Test-Path $accessPath)) { | |||
| Write-Error "Access file not found: $accessPath" | |||
| exit 1 | |||
| } | |||
| if (-not (Test-Path $sqlitePath)) { | |||
| Write-Error "SQLite file not found: $sqlitePath`nRun the PHP migrations first to create the schema." | |||
| exit 1 | |||
| } | |||
| Write-Host "Access file : $accessPath" | |||
| Write-Host "SQLite file : $sqlitePath" | |||
| # ── Locate sqlite3.exe ───────────────────────────────────────────────────────── | |||
| function Find-Sqlite3([string]$hint) { | |||
| $candidates = @( | |||
| $hint, | |||
| (Get-Command sqlite3.exe -ErrorAction SilentlyContinue | | |||
| Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue), | |||
| (Join-Path $projectRoot "sqlite3.exe"), | |||
| (Join-Path $projectRoot "database\sqlite3.exe"), | |||
| "C:\sqlite\sqlite3.exe", | |||
| "C:\tools\sqlite3.exe", | |||
| "C:\ProgramData\chocolatey\bin\sqlite3.exe" | |||
| ) | Where-Object { $_ -and (Test-Path $_ -ErrorAction SilentlyContinue) } | |||
| return $candidates | Select-Object -First 1 | |||
| } | |||
| $sqlite3 = Find-Sqlite3 $Sqlite3Path | |||
| if (-not $sqlite3 -and -not $DryRun) { | |||
| Write-Host "" | |||
| Write-Host "sqlite3.exe not found — attempting automatic download ..." | |||
| $destExe = Join-Path $projectRoot "sqlite3.exe" | |||
| try { | |||
| $dlPage = Invoke-WebRequest -Uri "https://www.sqlite.org/download.html" -UseBasicParsing | |||
| $zipName = ($dlPage.Content | | |||
| Select-String -Pattern 'sqlite-tools-win-x64-[\d]+\.zip' -AllMatches | |||
| ).Matches[0].Value | |||
| if (-not $zipName) { throw "Could not parse download filename from sqlite.org" } | |||
| $zipUrl = "https://www.sqlite.org/$zipName" | |||
| $zipPath = Join-Path $env:TEMP $zipName | |||
| Write-Host " Downloading $zipUrl ..." | |||
| Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing | |||
| $extractDir = Join-Path $env:TEMP "sqlite_extract_$(Get-Random)" | |||
| Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force | |||
| $extracted = Get-ChildItem -Path $extractDir -Filter sqlite3.exe -Recurse | | |||
| Select-Object -First 1 | |||
| if ($extracted) { | |||
| Copy-Item $extracted.FullName $destExe -Force | |||
| $sqlite3 = $destExe | |||
| Write-Host " sqlite3.exe saved to: $destExe" | |||
| } else { | |||
| throw "sqlite3.exe not found inside downloaded archive" | |||
| } | |||
| } catch { | |||
| Write-Host "" | |||
| Write-Host " Automatic download failed: $_" | |||
| Write-Host "" | |||
| Write-Host " Please download sqlite3.exe manually:" | |||
| Write-Host " 1. Visit https://www.sqlite.org/download.html" | |||
| Write-Host " 2. Download 'sqlite-tools-win-x64-*.zip'" | |||
| Write-Host " 3. Extract sqlite3.exe to: $projectRoot" | |||
| Write-Host " Then re-run this script." | |||
| exit 1 | |||
| } | |||
| } | |||
| if ($sqlite3) { | |||
| Write-Host "sqlite3.exe : $sqlite3" | |||
| } | |||
| # ── Open Access database via OleDb ──────────────────────────────────────────── | |||
| Add-Type -AssemblyName System.Data | |||
| $script:oleConn = [System.Data.OleDb.OleDbConnection]::new( | |||
| "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=$accessPath;Mode=Read;" | |||
| ) | |||
| try { | |||
| $script:oleConn.Open() | |||
| } catch { | |||
| Write-Error @" | |||
| Cannot open Access database. Error: $($_.Exception.Message) | |||
| Ensure the 64-bit Microsoft Access Database Engine is installed: | |||
| https://www.microsoft.com/en-us/download/details.aspx?id=54920 | |||
| If you have 32-bit Office, run this script from 32-bit PowerShell: | |||
| %SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe .\database\Migrate-AccessToSQLite.ps1 | |||
| "@ | |||
| exit 1 | |||
| } | |||
| # Reads all rows from an Access table and returns a List[hashtable]. | |||
| # Using a DataReader avoids DataTable/pipeline-unwrap quirks in PowerShell. | |||
| function Read-AccessTable([string]$TableName) { | |||
| $rows = [System.Collections.Generic.List[hashtable]]::new() | |||
| $cmd = [System.Data.OleDb.OleDbCommand]::new("SELECT * FROM [$TableName]", $script:oleConn) | |||
| $rdr = $cmd.ExecuteReader() | |||
| $n = $rdr.FieldCount | |||
| $cols = [string[]]::new($n) | |||
| for ($i = 0; $i -lt $n; $i++) { $cols[$i] = $rdr.GetName($i) } | |||
| while ($rdr.Read()) { | |||
| $row = @{} | |||
| for ($i = 0; $i -lt $n; $i++) { | |||
| $v = $rdr.GetValue($i) | |||
| $row[$cols[$i]] = if ($v -is [System.DBNull]) { $null } else { $v } | |||
| } | |||
| $rows.Add($row) | |||
| } | |||
| $rdr.Dispose() | |||
| $cmd.Dispose() | |||
| return $rows | |||
| } | |||
| # ── SQL value helpers ───────────────────────────────────────────────────────── | |||
| function Esc-Str([object]$v) { | |||
| if ($null -eq $v) { return 'NULL' } | |||
| $s = [string]$v | |||
| if ($s -eq '') { return 'NULL' } | |||
| return "'" + $s.Replace("'", "''") + "'" | |||
| } | |||
| # For NOT NULL VARCHAR columns: fall back to empty string rather than NULL. | |||
| function EscStrNN([object]$v) { | |||
| if ($null -eq $v) { return "''" } | |||
| return "'" + ([string]$v).Replace("'", "''") + "'" | |||
| } | |||
| function Esc-Int([object]$v) { | |||
| if ($null -eq $v) { return 'NULL' } | |||
| $i = 0 | |||
| if ([int]::TryParse([string]$v, [ref]$i)) { return [string]$i } | |||
| return 'NULL' | |||
| } | |||
| function Esc-Float([object]$v) { | |||
| if ($null -eq $v) { return 'NULL' } | |||
| $f = 0.0 | |||
| $style = [System.Globalization.NumberStyles]::Float | |||
| $culture = [System.Globalization.CultureInfo]::InvariantCulture | |||
| if ([double]::TryParse([string]$v, $style, $culture, [ref]$f)) { | |||
| return $f.ToString($culture) | |||
| } | |||
| return 'NULL' | |||
| } | |||
| function Esc-Bool([object]$v) { | |||
| if ($null -eq $v) { return '0' } | |||
| $s = ([string]$v).ToLower().Trim() | |||
| if ($s -in @('true', '1', 'yes', '-1', 'on')) { return '1' } | |||
| return '0' | |||
| } | |||
| function Esc-Date([object]$v, [string]$fmt = 'yyyy-MM-dd') { | |||
| if ($null -eq $v) { return 'NULL' } | |||
| if ($v -is [datetime]) { return "'" + ([datetime]$v).ToString($fmt) + "'" } | |||
| $dt = [datetime]::MinValue | |||
| if ([datetime]::TryParse([string]$v, [ref]$dt)) { | |||
| return "'" + $dt.ToString($fmt) + "'" | |||
| } | |||
| return 'NULL' | |||
| } | |||
| function Esc-DateTime([object]$v) { return Esc-Date $v 'yyyy-MM-dd HH:mm:ss' } | |||
| # ── Read all three tables ───────────────────────────────────────────────────── | |||
| Write-Host "" | |||
| Write-Host "[1/3] Reading Territories ..." | |||
| $tTerritories = Read-AccessTable 'Territories' | |||
| Write-Host " $($tTerritories.Count) row(s)" | |||
| Write-Host "[2/3] Reading Households ..." | |||
| $tHouseholds = Read-AccessTable 'Households' | |||
| Write-Host " $($tHouseholds.Count) row(s)" | |||
| Write-Host "[3/3] Reading HouseholderNames ..." | |||
| $tHouseholderNames = Read-AccessTable 'HouseholderNames' | |||
| Write-Host " $($tHouseholderNames.Count) row(s)" | |||
| $script:oleConn.Close() | |||
| if ($DryRun) { | |||
| Write-Host "" | |||
| Write-Host "-- DRY RUN: no data written --" | |||
| Write-Host (" {0,-25} {1}" -f "territories", $tTerritories.Count) | |||
| Write-Host (" {0,-25} {1}" -f "households", $tHouseholds.Count) | |||
| Write-Host (" {0,-25} {1}" -f "householder_names", $tHouseholderNames.Count) | |||
| exit 0 | |||
| } | |||
| # ── Build SQL ───────────────────────────────────────────────────────────────── | |||
| $now = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' | |||
| $sql = [System.Text.StringBuilder]::new() | |||
| [void]$sql.AppendLine("PRAGMA foreign_keys = OFF;") | |||
| [void]$sql.AppendLine("PRAGMA journal_mode = WAL;") | |||
| [void]$sql.AppendLine("BEGIN TRANSACTION;") | |||
| [void]$sql.AppendLine("") | |||
| [void]$sql.AppendLine("DELETE FROM householder_names;") | |||
| [void]$sql.AppendLine("DELETE FROM households;") | |||
| [void]$sql.AppendLine("DELETE FROM territories;") | |||
| [void]$sql.AppendLine("") | |||
| # Territories | |||
| foreach ($row in $tTerritories) { | |||
| $id = [int]$row['Id'] | |||
| $name = EscStrNN $row['Name'] | |||
| $desc = Esc-Str $row['Description'] | |||
| $coord = Esc-Str $row['Coordinates'] | |||
| [void]$sql.AppendLine( | |||
| "INSERT INTO territories (id, name, description, coordinates, created_at, updated_at) " + | |||
| "VALUES ($id, $name, $desc, $coord, '$now', '$now');" | |||
| ) | |||
| } | |||
| [void]$sql.AppendLine("") | |||
| # Households | |||
| foreach ($row in $tHouseholds) { | |||
| $id = [int]$row['Id'] | |||
| $terrId = [int]$row['TerritoryId'] | |||
| $addr = EscStrNN $row['Address'] | |||
| $sNum = Esc-Int $row['StreetNumber'] | |||
| $sName = Esc-Str $row['StreetName'] | |||
| $lat = Esc-Float $row['Latitude'] | |||
| $lon = Esc-Float $row['Longitude'] | |||
| $isBiz = Esc-Bool $row['IsBusiness'] | |||
| $dnc = Esc-Bool $row['DoNotCall'] | |||
| $dncDate = Esc-Date $row['DoNotCallDate'] | |||
| $dncNotes = Esc-Str $row['DoNotCallNotes'] | |||
| $dncPriv = Esc-Str $row['DoNotCallPrivateNotes'] | |||
| [void]$sql.AppendLine( | |||
| "INSERT INTO households (id, territory_id, address, street_number, street_name, " + | |||
| "latitude, longitude, is_business, do_not_call, do_not_call_date, " + | |||
| "do_not_call_notes, do_not_call_private_notes, created_at, updated_at) " + | |||
| "VALUES ($id, $terrId, $addr, $sNum, $sName, $lat, $lon, $isBiz, $dnc, " + | |||
| "$dncDate, $dncNotes, $dncPriv, '$now', '$now');" | |||
| ) | |||
| } | |||
| [void]$sql.AppendLine("") | |||
| # HouseholderNames | |||
| foreach ($row in $tHouseholderNames) { | |||
| $id = [int]$row['Id'] | |||
| $hhId = [int]$row['HouseholdId'] | |||
| $name = EscStrNN $row['Name'] | |||
| $letRet = Esc-Bool $row['LetterReturned'] | |||
| $retDate = Esc-DateTime $row['ReturnDate'] | |||
| $creAt = Esc-DateTime $row['Created'] | |||
| if ($creAt -eq 'NULL') { $creAt = "'$now'" } | |||
| [void]$sql.AppendLine( | |||
| "INSERT INTO householder_names (id, household_id, name, letter_returned, " + | |||
| "return_date, created_at, updated_at) " + | |||
| "VALUES ($id, $hhId, $name, $letRet, $retDate, $creAt, '$now');" | |||
| ) | |||
| } | |||
| [void]$sql.AppendLine("") | |||
| [void]$sql.AppendLine("COMMIT;") | |||
| [void]$sql.AppendLine("PRAGMA foreign_keys = ON;") | |||
| # ── Execute SQL via sqlite3 stdin ───────────────────────────────────────────── | |||
| Write-Host "" | |||
| Write-Host "Writing to SQLite ..." | |||
| $output = $sql.ToString() | & $sqlite3 $sqlitePath 2>&1 | |||
| if ($LASTEXITCODE -ne 0) { | |||
| Write-Host "sqlite3 output:" | |||
| $output | ForEach-Object { Write-Host " $_" } | |||
| Write-Error "sqlite3 exited with code $LASTEXITCODE" | |||
| exit 1 | |||
| } | |||
| # ── Verify ──────────────────────────────────────────────────────────────────── | |||
| Write-Host "" | |||
| Write-Host "Migration complete. SQLite row counts:" | |||
| $countSql = @( | |||
| "SELECT 'territories', COUNT(*) FROM territories;" | |||
| "SELECT 'households', COUNT(*) FROM households;" | |||
| "SELECT 'householder_names', COUNT(*) FROM householder_names;" | |||
| ) -join "`n" | |||
| $counts = ($countSql | & $sqlite3 $sqlitePath) 2>&1 | |||
| foreach ($line in $counts) { | |||
| $parts = [string]$line -split '\|' | |||
| Write-Host (" {0,-25} {1}" -f $parts[0].Trim(), $parts[1].Trim()) | |||
| } | |||
| @@ -0,0 +1,358 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| /** | |||
| * Migrates territory data from database/myAccessFile.accdb into database/app.sqlite. | |||
| * | |||
| * Platform support | |||
| * Windows — PDO ODBC via the Microsoft Access Database Engine driver. | |||
| * Requires: pdo_odbc extension + Microsoft Access Database Engine | |||
| * Linux/macOS — mdbtools (`mdb-export` CLI). | |||
| * Requires: sudo apt install mdbtools / brew install mdbtools | |||
| * | |||
| * Usage: | |||
| * php database/migrate_access_to_sqlite.php # auto-detect driver | |||
| * php database/migrate_access_to_sqlite.php --dry-run # show counts, no writes | |||
| * php database/migrate_access_to_sqlite.php --driver=odbc | |||
| * php database/migrate_access_to_sqlite.php --driver=mdbtools | |||
| */ | |||
| // ── CLI args ─────────────────────────────────────────────────────────────── | |||
| $args = $argv ?? []; | |||
| $dryRun = in_array('--dry-run', $args, true); | |||
| $driverFlag = null; | |||
| foreach ($args as $arg) { | |||
| if (str_starts_with($arg, '--driver=')) { | |||
| $driverFlag = substr($arg, strlen('--driver=')); | |||
| } | |||
| } | |||
| // ── Paths ────────────────────────────────────────────────────────────────── | |||
| $accessPath = realpath(__DIR__ . '/myAccessFile.accdb'); | |||
| $sqlitePath = realpath(__DIR__ . '/app.sqlite'); | |||
| if (!$accessPath) { | |||
| fwrite(STDERR, "ERROR: myAccessFile.accdb not found in " . __DIR__ . "\n"); | |||
| exit(1); | |||
| } | |||
| if (!$sqlitePath) { | |||
| fwrite(STDERR, "ERROR: app.sqlite not found — run the migrations first.\n"); | |||
| exit(1); | |||
| } | |||
| // ── Access reader interface ──────────────────────────────────────────────── | |||
| interface AccessReader | |||
| { | |||
| /** Total number of rows in the given Access table. */ | |||
| public function count(string $table): int; | |||
| /** Yield each row as an associative array (string keys, string|null values). */ | |||
| public function rows(string $table): iterable; | |||
| } | |||
| // ── ODBC reader (Windows) ────────────────────────────────────────────────── | |||
| final class OdbcAccessReader implements AccessReader | |||
| { | |||
| private PDO $pdo; | |||
| public function __construct(string $path) | |||
| { | |||
| $dsn = "odbc:Driver={Microsoft Access Driver (*.mdb, *.accdb)};Dbq={$path};"; | |||
| try { | |||
| $this->pdo = new PDO($dsn, '', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); | |||
| } catch (PDOException $e) { | |||
| fwrite(STDERR, "ERROR: Cannot open Access database via ODBC.\n{$e->getMessage()}\n"); | |||
| fwrite(STDERR, "Ensure pdo_odbc is enabled and the Microsoft Access Database Engine is installed.\n"); | |||
| exit(1); | |||
| } | |||
| } | |||
| public function count(string $table): int | |||
| { | |||
| return (int) $this->pdo->query("SELECT COUNT(*) FROM [{$table}]")->fetchColumn(); | |||
| } | |||
| public function rows(string $table): iterable | |||
| { | |||
| yield from $this->pdo->query("SELECT * FROM [{$table}]"); | |||
| } | |||
| } | |||
| // ── mdbtools reader (Linux / macOS) ─────────────────────────────────────── | |||
| final class MdbToolsAccessReader implements AccessReader | |||
| { | |||
| public function __construct(private readonly string $path) | |||
| { | |||
| if (!$this->which('mdb-export')) { | |||
| fwrite(STDERR, "ERROR: mdb-export not found.\n"); | |||
| fwrite(STDERR, "Install mdbtools: sudo apt install mdbtools (Debian/Ubuntu)\n"); | |||
| fwrite(STDERR, " brew install mdbtools (macOS)\n"); | |||
| exit(1); | |||
| } | |||
| } | |||
| public function count(string $table): int | |||
| { | |||
| // Row count = line count of mdb-export output minus the header line. | |||
| $cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table) . ' | wc -l'; | |||
| $output = shell_exec($cmd); | |||
| return max(0, (int) trim((string) $output) - 1); | |||
| } | |||
| public function rows(string $table): iterable | |||
| { | |||
| $cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table); | |||
| $handle = popen($cmd, 'r'); | |||
| if ($handle === false) { | |||
| throw new RuntimeException("Failed to run mdb-export for table '{$table}'."); | |||
| } | |||
| $headers = null; | |||
| while (!feof($handle)) { | |||
| $line = fgets($handle); | |||
| if ($line === false) { | |||
| break; | |||
| } | |||
| $line = rtrim($line, "\r\n"); | |||
| if ($line === '') { | |||
| continue; | |||
| } | |||
| $cols = str_getcsv($line); | |||
| if ($headers === null) { | |||
| $headers = $cols; | |||
| continue; | |||
| } | |||
| // Pad short rows (trailing empty columns may be omitted by mdbtools). | |||
| while (count($cols) < count($headers)) { | |||
| $cols[] = ''; | |||
| } | |||
| yield array_combine($headers, $cols); | |||
| } | |||
| pclose($handle); | |||
| } | |||
| private function which(string $bin): bool | |||
| { | |||
| return !empty(shell_exec('which ' . escapeshellarg($bin) . ' 2>/dev/null')); | |||
| } | |||
| } | |||
| // ── Select driver ────────────────────────────────────────────────────────── | |||
| $driver = $driverFlag ?? (PHP_OS_FAMILY === 'Windows' ? 'odbc' : 'mdbtools'); | |||
| echo "Access file: {$accessPath}\n"; | |||
| echo "Driver: {$driver}\n"; | |||
| $reader = match ($driver) { | |||
| 'odbc' => new OdbcAccessReader($accessPath), | |||
| 'mdbtools' => new MdbToolsAccessReader($accessPath), | |||
| default => throw new InvalidArgumentException("Unknown driver '{$driver}'. Use 'odbc' or 'mdbtools'."), | |||
| }; | |||
| // ── SQLite connection ────────────────────────────────────────────────────── | |||
| echo "SQLite file: {$sqlitePath}\n"; | |||
| $sqlite = new PDO("sqlite:{$sqlitePath}", null, null, [ | |||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||
| ]); | |||
| $sqlite->exec('PRAGMA foreign_keys = OFF'); | |||
| $sqlite->exec('PRAGMA journal_mode = WAL'); | |||
| $sqlite->exec('PRAGMA synchronous = NORMAL'); | |||
| $sqlite->exec('PRAGMA cache_size = -16000'); | |||
| if ($dryRun) { | |||
| echo "\n-- DRY RUN: no data will be written --\n"; | |||
| } | |||
| $now = date('Y-m-d H:i:s'); | |||
| // ── Normalisation helpers ────────────────────────────────────────────────── | |||
| function normaliseString(mixed $v): ?string | |||
| { | |||
| return ($v === null || $v === '') ? null : (string) $v; | |||
| } | |||
| function normaliseInt(mixed $v): ?int | |||
| { | |||
| return ($v === null || $v === '') ? null : (int) $v; | |||
| } | |||
| function normaliseFloat(mixed $v): ?float | |||
| { | |||
| return ($v === null || $v === '') ? null : (float) $v; | |||
| } | |||
| function normaliseDate(mixed $v, string $format = 'Y-m-d'): ?string | |||
| { | |||
| if ($v === null || $v === '') { | |||
| return null; | |||
| } | |||
| $ts = strtotime((string) $v); | |||
| return $ts !== false ? date($format, $ts) : null; | |||
| } | |||
| function normaliseDateTime(mixed $v): ?string | |||
| { | |||
| return normaliseDate($v, 'Y-m-d H:i:s'); | |||
| } | |||
| // ── Migration runner ─────────────────────────────────────────────────────── | |||
| function migrateTable( | |||
| AccessReader $src, | |||
| PDO $dst, | |||
| string $srcTable, | |||
| string $dstTable, | |||
| string $insertSql, | |||
| callable $mapRow, | |||
| bool $dryRun, | |||
| int $batchSize = 500 | |||
| ): void { | |||
| $total = $src->count($srcTable); | |||
| echo " {$srcTable} → {$dstTable}: {$total} rows"; | |||
| if ($dryRun) { | |||
| echo " (skipped — dry run)\n"; | |||
| return; | |||
| } | |||
| echo "\n"; | |||
| $dst->exec("DELETE FROM [{$dstTable}]"); | |||
| $stmt = $dst->prepare($insertSql); | |||
| $dst->beginTransaction(); | |||
| $n = 0; | |||
| foreach ($src->rows($srcTable) as $row) { | |||
| $stmt->execute($mapRow($row)); | |||
| $n++; | |||
| if ($n % $batchSize === 0) { | |||
| $dst->commit(); | |||
| $dst->beginTransaction(); | |||
| printf(" %d / %d (%.0f%%)\n", $n, $total, $total > 0 ? ($n / $total) * 100 : 0); | |||
| } | |||
| } | |||
| if ($dst->inTransaction()) { | |||
| $dst->commit(); | |||
| } | |||
| printf(" Done: %d rows inserted.\n", $n); | |||
| } | |||
| // ── 1. Territories ───────────────────────────────────────────────────────── | |||
| echo "\n[1/3] Territories\n"; | |||
| migrateTable( | |||
| $reader, | |||
| $sqlite, | |||
| 'Territories', | |||
| 'territories', | |||
| 'INSERT INTO territories (id, name, description, coordinates, created_at, updated_at) | |||
| VALUES (:id, :name, :description, :coordinates, :created_at, :updated_at)', | |||
| function (array $row) use ($now): array { | |||
| return [ | |||
| ':id' => (int) $row['Id'], | |||
| ':name' => normaliseString($row['Name']), | |||
| ':description' => normaliseString($row['Description']), | |||
| ':coordinates' => normaliseString($row['Coordinates']), | |||
| ':created_at' => $now, | |||
| ':updated_at' => $now, | |||
| ]; | |||
| }, | |||
| $dryRun | |||
| ); | |||
| // ── 2. Households ────────────────────────────────────────────────────────── | |||
| echo "\n[2/3] Households\n"; | |||
| migrateTable( | |||
| $reader, | |||
| $sqlite, | |||
| 'Households', | |||
| 'households', | |||
| 'INSERT INTO households | |||
| (id, territory_id, address, street_number, street_name, | |||
| latitude, longitude, is_business, do_not_call, | |||
| do_not_call_date, do_not_call_notes, do_not_call_private_notes, | |||
| created_at, updated_at) | |||
| VALUES | |||
| (:id, :territory_id, :address, :street_number, :street_name, | |||
| :latitude, :longitude, :is_business, :do_not_call, | |||
| :do_not_call_date, :do_not_call_notes, :do_not_call_private_notes, | |||
| :created_at, :updated_at)', | |||
| function (array $row) use ($now): array { | |||
| return [ | |||
| ':id' => (int) $row['Id'], | |||
| ':territory_id' => (int) $row['TerritoryId'], | |||
| ':address' => normaliseString($row['Address']), | |||
| ':street_number' => normaliseInt($row['StreetNumber']), | |||
| ':street_name' => normaliseString($row['StreetName']), | |||
| ':latitude' => normaliseFloat($row['Latitude']), | |||
| ':longitude' => normaliseFloat($row['Longitude']), | |||
| ':is_business' => (int) ($row['IsBusiness'] ?? 0), | |||
| ':do_not_call' => (int) ($row['DoNotCall'] ?? 0), | |||
| ':do_not_call_date' => normaliseDate($row['DoNotCallDate']), | |||
| ':do_not_call_notes' => normaliseString($row['DoNotCallNotes']), | |||
| ':do_not_call_private_notes' => normaliseString($row['DoNotCallPrivateNotes']), | |||
| ':created_at' => $now, | |||
| ':updated_at' => $now, | |||
| ]; | |||
| }, | |||
| $dryRun | |||
| ); | |||
| // ── 3. HouseholderNames ──────────────────────────────────────────────────── | |||
| echo "\n[3/3] HouseholderNames\n"; | |||
| migrateTable( | |||
| $reader, | |||
| $sqlite, | |||
| 'HouseholderNames', | |||
| 'householder_names', | |||
| 'INSERT INTO householder_names | |||
| (id, household_id, name, letter_returned, return_date, created_at, updated_at) | |||
| VALUES | |||
| (:id, :household_id, :name, :letter_returned, :return_date, :created_at, :updated_at)', | |||
| function (array $row) use ($now): array { | |||
| return [ | |||
| ':id' => (int) $row['Id'], | |||
| ':household_id' => (int) $row['HouseholdId'], | |||
| ':name' => normaliseString($row['Name']), | |||
| ':letter_returned' => (int) ($row['LetterReturned'] ?? 0), | |||
| ':return_date' => normaliseDateTime($row['ReturnDate']), | |||
| ':created_at' => normaliseDateTime($row['Created']) ?? $now, | |||
| ':updated_at' => $now, | |||
| ]; | |||
| }, | |||
| $dryRun | |||
| ); | |||
| // ── Finalise ─────────────────────────────────────────────────────────────── | |||
| $sqlite->exec('PRAGMA foreign_keys = ON'); | |||
| echo "\n"; | |||
| if ($dryRun) { | |||
| echo "Dry run complete — no data was written.\n"; | |||
| } else { | |||
| echo "Migration complete. SQLite row counts:\n"; | |||
| foreach (['territories', 'households', 'householder_names'] as $t) { | |||
| $n = $sqlite->query("SELECT COUNT(*) FROM [{$t}]")->fetchColumn(); | |||
| printf(" %-25s %d\n", $t, $n); | |||
| } | |||
| } | |||
| @@ -0,0 +1,63 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| use Core\Database; | |||
| use Core\Migration; | |||
| return new class extends Migration | |||
| { | |||
| public function up(Database $database): void | |||
| { | |||
| $database->execute(' | |||
| CREATE TABLE IF NOT EXISTS territories ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| name VARCHAR(255) NOT NULL, | |||
| description TEXT, | |||
| coordinates TEXT, | |||
| created_at DATETIME, | |||
| updated_at DATETIME | |||
| ) | |||
| '); | |||
| $database->execute(' | |||
| CREATE TABLE IF NOT EXISTS households ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| territory_id INTEGER NOT NULL, | |||
| address VARCHAR(255) NOT NULL, | |||
| street_number INTEGER, | |||
| street_name VARCHAR(255), | |||
| latitude REAL, | |||
| longitude REAL, | |||
| is_business INTEGER NOT NULL DEFAULT 0, | |||
| do_not_call INTEGER NOT NULL DEFAULT 0, | |||
| do_not_call_date DATE, | |||
| do_not_call_notes TEXT, | |||
| do_not_call_private_notes TEXT, | |||
| created_at DATETIME, | |||
| updated_at DATETIME, | |||
| FOREIGN KEY (territory_id) REFERENCES territories(id) | |||
| ) | |||
| '); | |||
| $database->execute(' | |||
| CREATE TABLE IF NOT EXISTS householder_names ( | |||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| household_id INTEGER NOT NULL, | |||
| name VARCHAR(255) NOT NULL, | |||
| letter_returned INTEGER NOT NULL DEFAULT 0, | |||
| return_date DATETIME, | |||
| created_at DATETIME, | |||
| updated_at DATETIME, | |||
| FOREIGN KEY (household_id) REFERENCES households(id) | |||
| ) | |||
| '); | |||
| } | |||
| public function down(Database $database): void | |||
| { | |||
| foreach (['householder_names', 'households', 'territories'] as $table) { | |||
| $database->execute("DROP TABLE IF EXISTS {$table}"); | |||
| } | |||
| } | |||
| }; | |||
| @@ -560,3 +560,303 @@ code { | |||
| background: rgba(29, 122, 109, 0.12); | |||
| transform: translateY(-1px); | |||
| } | |||
| /* ── Page header ──────────────────────────────────────── */ | |||
| .page-header { | |||
| display: flex; | |||
| align-items: flex-start; | |||
| justify-content: space-between; | |||
| gap: 1rem; | |||
| margin-bottom: 1.5rem; | |||
| flex-wrap: wrap; | |||
| } | |||
| .page-header h1 { | |||
| margin: 0; | |||
| font-size: clamp(1.6rem, 4vw, 2.4rem); | |||
| letter-spacing: -0.03em; | |||
| line-height: 1.1; | |||
| } | |||
| .page-header-actions { | |||
| display: flex; | |||
| gap: 0.6rem; | |||
| flex-wrap: wrap; | |||
| align-items: center; | |||
| } | |||
| .button-sm { | |||
| padding: 0.5rem 0.85rem; | |||
| font-size: 0.875rem; | |||
| } | |||
| .button-danger { | |||
| background: rgba(239, 124, 77, 0.14); | |||
| color: #8f3518; | |||
| } | |||
| .button-danger:hover, | |||
| .button-danger:focus-visible { | |||
| background: rgba(239, 124, 77, 0.28); | |||
| } | |||
| /* ── Alerts (flash) ───────────────────────────────────── */ | |||
| .alert { | |||
| margin-bottom: 1.25rem; | |||
| } | |||
| /* ── Badges ───────────────────────────────────────────── */ | |||
| .badge { | |||
| display: inline-block; | |||
| padding: 0.2rem 0.55rem; | |||
| border-radius: 999px; | |||
| font-size: 0.72rem; | |||
| font-weight: 700; | |||
| letter-spacing: 0.06em; | |||
| text-transform: uppercase; | |||
| vertical-align: middle; | |||
| } | |||
| .badge-danger { | |||
| background: rgba(239, 124, 77, 0.18); | |||
| color: #8f3518; | |||
| } | |||
| .badge-warning { | |||
| background: rgba(239, 200, 77, 0.22); | |||
| color: #6b4a00; | |||
| } | |||
| .badge-success { | |||
| background: var(--accent-soft); | |||
| color: var(--accent-strong); | |||
| } | |||
| /* ── Filter bar ───────────────────────────────────────── */ | |||
| .filter-bar { | |||
| display: flex; | |||
| gap: 0.75rem; | |||
| flex-wrap: wrap; | |||
| align-items: flex-end; | |||
| margin-bottom: 1.5rem; | |||
| } | |||
| .filter-bar .field { | |||
| min-width: 160px; | |||
| } | |||
| /* ── Data table ───────────────────────────────────────── */ | |||
| .table-responsive { | |||
| overflow-x: auto; | |||
| border-radius: 1rem; | |||
| } | |||
| .data-table { | |||
| width: 100%; | |||
| border-collapse: collapse; | |||
| font-size: 0.95rem; | |||
| } | |||
| .data-table th { | |||
| text-align: left; | |||
| padding: 0.7rem 1rem; | |||
| font-weight: 700; | |||
| font-size: 0.75rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| color: var(--text-secondary); | |||
| border-bottom: 2px solid var(--surface-border); | |||
| white-space: nowrap; | |||
| } | |||
| .data-table td { | |||
| padding: 0.75rem 1rem; | |||
| border-bottom: 1px solid var(--surface-border); | |||
| vertical-align: middle; | |||
| } | |||
| .data-table tbody tr:last-child td { | |||
| border-bottom: none; | |||
| } | |||
| .data-table tbody tr:hover td { | |||
| background: rgba(29, 122, 109, 0.035); | |||
| } | |||
| .data-table a { | |||
| color: var(--accent); | |||
| font-weight: 600; | |||
| text-decoration: none; | |||
| } | |||
| .data-table a:hover { | |||
| color: var(--accent-strong); | |||
| text-decoration: underline; | |||
| } | |||
| .table-actions { | |||
| display: flex; | |||
| gap: 0.4rem; | |||
| flex-wrap: nowrap; | |||
| } | |||
| /* ── Inline form (for delete buttons in tables) ───────── */ | |||
| .inline-form { | |||
| display: inline; | |||
| margin: 0; | |||
| padding: 0; | |||
| } | |||
| /* ── Pagination ───────────────────────────────────────── */ | |||
| .pagination { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 0.3rem; | |||
| flex-wrap: wrap; | |||
| margin-top: 1.5rem; | |||
| padding-top: 1rem; | |||
| border-top: 1px solid var(--surface-border); | |||
| } | |||
| .page-link { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| min-width: 2.2rem; | |||
| height: 2.2rem; | |||
| padding: 0 0.6rem; | |||
| border-radius: 999px; | |||
| text-decoration: none; | |||
| font-weight: 600; | |||
| font-size: 0.88rem; | |||
| color: var(--text-secondary); | |||
| transition: background-color 140ms, color 140ms; | |||
| } | |||
| .page-link:hover { | |||
| background: rgba(29, 122, 109, 0.1); | |||
| color: var(--accent-strong); | |||
| } | |||
| .page-link.is-active { | |||
| background: var(--accent); | |||
| color: #fff; | |||
| pointer-events: none; | |||
| } | |||
| .pagination-meta { | |||
| margin-left: 0.5rem; | |||
| font-size: 0.82rem; | |||
| color: var(--text-secondary); | |||
| } | |||
| /* ── Detail list (show pages) ─────────────────────────── */ | |||
| .detail-list { | |||
| display: grid; | |||
| grid-template-columns: 10rem 1fr; | |||
| gap: 0.6rem 1.25rem; | |||
| margin: 0; | |||
| } | |||
| .detail-list dt { | |||
| font-weight: 700; | |||
| font-size: 0.82rem; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.08em; | |||
| color: var(--text-secondary); | |||
| padding-top: 0.1rem; | |||
| } | |||
| .detail-list dd { | |||
| margin: 0; | |||
| } | |||
| /* ── Checkbox label ───────────────────────────────────── */ | |||
| .checkbox-label { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 0.5rem; | |||
| font-weight: 600; | |||
| cursor: pointer; | |||
| } | |||
| .checkbox-label input[type="checkbox"] { | |||
| width: 1rem; | |||
| height: 1rem; | |||
| accent-color: var(--accent); | |||
| cursor: pointer; | |||
| } | |||
| /* ── Street chips ─────────────────────────────────────── */ | |||
| .street-chips { | |||
| display: flex; | |||
| flex-wrap: wrap; | |||
| gap: 0.5rem; | |||
| } | |||
| /* ── Export checklist ─────────────────────────────────── */ | |||
| .territory-checklist { | |||
| display: grid; | |||
| gap: 0.6rem; | |||
| margin-bottom: 0.5rem; | |||
| } | |||
| .territory-item { | |||
| padding: 0.6rem 0.75rem; | |||
| border: 1px solid var(--surface-border); | |||
| border-radius: 0.75rem; | |||
| background: var(--surface-strong); | |||
| font-weight: 400; | |||
| } | |||
| .territory-item:has(input:checked) { | |||
| border-color: var(--accent); | |||
| background: var(--accent-soft); | |||
| } | |||
| /* ── Utility ──────────────────────────────────────────── */ | |||
| .text-secondary { | |||
| color: var(--text-secondary); | |||
| } | |||
| select.input { | |||
| appearance: auto; | |||
| cursor: pointer; | |||
| } | |||
| textarea.input { | |||
| resize: vertical; | |||
| min-height: 80px; | |||
| } | |||
| @media (max-width: 860px) { | |||
| .detail-list { | |||
| grid-template-columns: 1fr; | |||
| gap: 0.2rem; | |||
| } | |||
| .detail-list dt { | |||
| margin-top: 0.75rem; | |||
| } | |||
| .detail-list dt:first-child { | |||
| margin-top: 0; | |||
| } | |||
| .page-header { | |||
| flex-direction: column; | |||
| align-items: flex-start; | |||
| } | |||
| } | |||
| @@ -2,12 +2,50 @@ | |||
| declare(strict_types=1); | |||
| use App\Controllers\HomeController; | |||
| use App\Controllers\AuthController; | |||
| use App\Controllers\ExportController; | |||
| use App\Controllers\HomeController; | |||
| use App\Controllers\HouseholdController; | |||
| use App\Controllers\HouseholderNameController; | |||
| use App\Controllers\TerritoryController; | |||
| // Home | |||
| $router->get('/', [HomeController::class, 'index']); | |||
| $router->get('/users/{id}', [HomeController::class, 'user']); | |||
| // Auth | |||
| $router->get('/login', [AuthController::class, 'showLogin']); | |||
| $router->post('/login', [AuthController::class, 'login']); | |||
| $router->post('/logout', [AuthController::class, 'logout']); | |||
| // Territories — specific routes before parameterized | |||
| $router->get('/territories', [TerritoryController::class, 'index']); | |||
| $router->get('/territories/new', [TerritoryController::class, 'create']); | |||
| $router->post('/territories', [TerritoryController::class, 'store']); | |||
| $router->get('/territories/{id}', [TerritoryController::class, 'show']); | |||
| $router->get('/territories/{id}/edit', [TerritoryController::class, 'edit']); | |||
| $router->post('/territories/{id}', [TerritoryController::class, 'update']); | |||
| $router->post('/territories/{id}/delete', [TerritoryController::class, 'delete']); | |||
| // Households | |||
| $router->get('/households', [HouseholdController::class, 'index']); | |||
| $router->get('/households/new', [HouseholdController::class, 'create']); | |||
| $router->post('/households', [HouseholdController::class, 'store']); | |||
| $router->get('/households/{id}', [HouseholdController::class, 'show']); | |||
| $router->get('/households/{id}/edit', [HouseholdController::class, 'edit']); | |||
| $router->post('/households/{id}', [HouseholdController::class, 'update']); | |||
| $router->post('/households/{id}/delete', [HouseholdController::class, 'delete']); | |||
| // Householder Names | |||
| $router->get('/householder-names', [HouseholderNameController::class, 'index']); | |||
| $router->get('/householder-names/new', [HouseholderNameController::class, 'create']); | |||
| $router->post('/householder-names', [HouseholderNameController::class, 'store']); | |||
| $router->get('/householder-names/{id}', [HouseholderNameController::class, 'show']); | |||
| $router->get('/householder-names/{id}/edit', [HouseholderNameController::class, 'edit']); | |||
| $router->post('/householder-names/{id}', [HouseholderNameController::class, 'update']); | |||
| $router->post('/householder-names/{id}/delete', [HouseholderNameController::class, 'delete']); | |||
| $router->post('/householder-names/{id}/mark-returned', [HouseholderNameController::class, 'markReturned']); | |||
| // Export | |||
| $router->get('/export', [ExportController::class, 'generate']); | |||
| $router->post('/export', [ExportController::class, 'download']); | |||
Powered by TurnKey Linux.