diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 20fffeb..8b093e9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 ' *)" ] } } diff --git a/app/Controllers/ExportController.php b/app/Controllers/ExportController.php new file mode 100644 index 0000000..5d5d435 --- /dev/null +++ b/app/Controllers/ExportController.php @@ -0,0 +1,74 @@ +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'); + } +} diff --git a/app/Controllers/HouseholdController.php b/app/Controllers/HouseholdController.php new file mode 100644 index 0000000..75b3641 --- /dev/null +++ b/app/Controllers/HouseholdController.php @@ -0,0 +1,266 @@ +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 */ + 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') ?? '')), + ]; + } +} diff --git a/app/Controllers/HouseholderNameController.php b/app/Controllers/HouseholderNameController.php new file mode 100644 index 0000000..0e386ab --- /dev/null +++ b/app/Controllers/HouseholderNameController.php @@ -0,0 +1,253 @@ +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']); + } +} diff --git a/app/Controllers/TerritoryController.php b/app/Controllers/TerritoryController.php new file mode 100644 index 0000000..0b021c0 --- /dev/null +++ b/app/Controllers/TerritoryController.php @@ -0,0 +1,214 @@ +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'); + } +} diff --git a/app/Repositories/HouseholdRepository.php b/app/Repositories/HouseholdRepository.php new file mode 100644 index 0000000..30ae800 --- /dev/null +++ b/app/Repositories/HouseholdRepository.php @@ -0,0 +1,113 @@ +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> */ + 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> */ + 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> */ + 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} */ + 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]; + } +} diff --git a/app/Repositories/HouseholderNameRepository.php b/app/Repositories/HouseholderNameRepository.php new file mode 100644 index 0000000..494d9dd --- /dev/null +++ b/app/Repositories/HouseholderNameRepository.php @@ -0,0 +1,102 @@ +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> */ + 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> */ + 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} */ + 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]; + } +} diff --git a/app/Repositories/TerritoryRepository.php b/app/Repositories/TerritoryRepository.php new file mode 100644 index 0000000..4491fb2 --- /dev/null +++ b/app/Repositories/TerritoryRepository.php @@ -0,0 +1,90 @@ +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> */ + 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> */ + 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> */ + 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 */ + 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'); + } +} diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php new file mode 100644 index 0000000..4e4c881 --- /dev/null +++ b/app/Services/ExportService.php @@ -0,0 +1,273 @@ +> $territories + * @param list> $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> $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> $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> $households */ + private function buildPdfHtml(array $territory, array $households): string + { + $title = htmlspecialchars($territory['name'], ENT_QUOTES, 'UTF-8'); + $byStreet = $this->groupByStreet($households); + + $html = ' + + '; + + $html .= "

Territory: {$title}

"; + + if (empty($byStreet)) { + $html .= '

No households found.

'; + } + + foreach ($byStreet as $streetName => $streetHouseholds) { + $escapedStreet = htmlspecialchars((string) $streetName, ENT_QUOTES, 'UTF-8'); + [$even, $odd] = $this->splitEvenOdd($streetHouseholds); + + $html .= "

{$escapedStreet}

"; + $html .= ' + + + + '; + + $max = max(count($even), count($odd), 1); + $evenVals = array_values($even); + $oddVals = array_values($odd); + + for ($i = 0; $i < $max; $i++) { + $html .= ''; + if (isset($evenVals[$i])) { + $h = $evenVals[$i]; + $addr = htmlspecialchars($h['address'] ?? '', ENT_QUOTES, 'UTF-8'); + $dnc = $h['do_not_call'] ? 'DNC' : ''; + $html .= ""; + } else { + $html .= ''; + } + $html .= ''; + if (isset($oddVals[$i])) { + $h = $oddVals[$i]; + $addr = htmlspecialchars($h['address'] ?? '', ENT_QUOTES, 'UTF-8'); + $dnc = $h['do_not_call'] ? 'DNC' : ''; + $html .= ""; + } else { + $html .= ''; + } + $html .= ''; + } + + $html .= '
#Address (Even)DNC#Address (Odd)DNC
{$h['street_number']}{$addr}{$dnc}{$h['street_number']}{$addr}{$dnc}
'; + } + + $html .= ''; + return $html; + } + + /** + * @param list> $households + * @return array>> + */ + 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> $households + * @return array{list>, list>} + */ + 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'; + } +} diff --git a/app/Views/export/generate.php b/app/Views/export/generate.php new file mode 100644 index 0000000..cd96139 --- /dev/null +++ b/app/Views/export/generate.php @@ -0,0 +1,54 @@ + + +
+
+

Select Territories to Export

+

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.

+
+ + +
+

No territories found. Create a territory first.

+
+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ +
+
+ +
diff --git a/app/Views/householder-names/create.php b/app/Views/householder-names/create.php new file mode 100644 index 0000000..d84a822 --- /dev/null +++ b/app/Views/householder-names/create.php @@ -0,0 +1,64 @@ + + +
+ +
+ + +
+ + +
+
+ + + + + +
+ +
+ + + + + +
+ +
+ +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
diff --git a/app/Views/householder-names/edit.php b/app/Views/householder-names/edit.php new file mode 100644 index 0000000..a96bb30 --- /dev/null +++ b/app/Views/householder-names/edit.php @@ -0,0 +1,65 @@ + + +
+ +
+ + +
+ + +
+
+ + + + + +
+ +
+ + + + + +
+ +
+ +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
diff --git a/app/Views/householder-names/index.php b/app/Views/householder-names/index.php new file mode 100644 index 0000000..d3bb7a7 --- /dev/null +++ b/app/Views/householder-names/index.php @@ -0,0 +1,97 @@ + + +
+
+
+ + +
+
+ + + Clear + +
+
+ + +
+

No names found.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
NameHouseholdTerritoryLetter ReturnedAddedActions
+ + + + + + + + + + Returned + + + + + + + View + Edit +
+ + +
+
+
+ + hasPages()): ?> + + + +
diff --git a/app/Views/householder-names/show.php b/app/Views/householder-names/show.php new file mode 100644 index 0000000..358343a --- /dev/null +++ b/app/Views/householder-names/show.php @@ -0,0 +1,59 @@ + + +
+
+
Household
+
+ + + +
+ +
Territory
+
+ + + +
+ +
Letter Returned
+
+ + Yes + + () + + + No + +
+ +
Added
+
+
+ +
+
+ + +
+
+
diff --git a/app/Views/households/create.php b/app/Views/households/create.php new file mode 100644 index 0000000..1c929b2 --- /dev/null +++ b/app/Views/households/create.php @@ -0,0 +1,123 @@ + + +
+ +
+ + +
+ + +
+
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + Cancel +
+
+
diff --git a/app/Views/households/edit.php b/app/Views/households/edit.php new file mode 100644 index 0000000..a3a2329 --- /dev/null +++ b/app/Views/households/edit.php @@ -0,0 +1,109 @@ + + +
+ +
+ + +
+ + +
+
+ + + + + +
+ +
+ + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + Cancel +
+
+
diff --git a/app/Views/households/index.php b/app/Views/households/index.php new file mode 100644 index 0000000..c79f96d --- /dev/null +++ b/app/Views/households/index.php @@ -0,0 +1,124 @@ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Clear + +
+
+ + +
+

No households found.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
AddressStreetTerritoryTypeDNCActions
+ + + + + + + + + + Business + + + + DNC + + + + + + + + View + Edit +
+ + +
+
+
+ + hasPages()): ?> + + + +
diff --git a/app/Views/households/show.php b/app/Views/households/show.php new file mode 100644 index 0000000..5b603e5 --- /dev/null +++ b/app/Views/households/show.php @@ -0,0 +1,154 @@ + + +
+
+

Details

+
+
Territory
+
+ + + +
+ + +
Street
+
+ + +
+ + +
Type
+
+ + Business + + Residential + +
+ +
Do Not Call
+
+ + Yes + + (since ) + + +

+ + + No + +
+ + +
Coordinates
+
,
+ +
+
+ + +
+

Map

+ +
+ + +
+ + +
+
+

Householder Names ()

+ + Add Name +
+ + +
+

No names recorded for this household.

+
+ +
+ + + + + + + + + + + + + + + + + + + +
NameAddedLetter ReturnedActions
+ + + + Returned + + + + + + + + + +
+ + +
+ Edit +
+ + +
+
+
+ +
+
diff --git a/app/Views/layouts/app.php b/app/Views/layouts/app.php index 5796585..30eed3b 100644 --- a/app/Views/layouts/app.php +++ b/app/Views/layouts/app.php @@ -3,10 +3,19 @@ declare(strict_types=1); require __DIR__ . '/../partials/header.php'; + +$flashSuccess = flash_get('success'); +$flashError = flash_get('error'); ?>
+ + + + + +
diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php index 698c75a..8d57860 100644 --- a/app/Views/partials/header.php +++ b/app/Views/partials/header.php @@ -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); diff --git a/app/Views/territories/create.php b/app/Views/territories/create.php new file mode 100644 index 0000000..5e1542e --- /dev/null +++ b/app/Views/territories/create.php @@ -0,0 +1,44 @@ + + +
+ +
+ + +
+ + +
+
+ + + + + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
diff --git a/app/Views/territories/edit.php b/app/Views/territories/edit.php new file mode 100644 index 0000000..8875584 --- /dev/null +++ b/app/Views/territories/edit.php @@ -0,0 +1,43 @@ + + +
+ +
+ + +
+ + +
+
+ + + + + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
diff --git a/app/Views/territories/index.php b/app/Views/territories/index.php new file mode 100644 index 0000000..a689fab --- /dev/null +++ b/app/Views/territories/index.php @@ -0,0 +1,91 @@ + + +
+
+
+ + +
+
+ + + Clear + +
+
+ + +
+

No territories found.

+ +

Create the first territory.

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
NameDescriptionHouseholdsActions
+ + + + + View + Edit +
+ + +
+
+
+ + hasPages()): ?> + + + +
diff --git a/app/Views/territories/show.php b/app/Views/territories/show.php new file mode 100644 index 0000000..51aef62 --- /dev/null +++ b/app/Views/territories/show.php @@ -0,0 +1,95 @@ + + + +
+

+
+ + +
+
+
+

Streets

+
+ + +
+

No streets recorded yet. Add a household to this territory.

+
+ +
+ + + +
+ +
+ +
+
+

Households ()

+ + Add Household +
+ + +
+

No households in this territory yet.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
#AddressStreetBusinessDNCActions
+ + + + Business + + + + DNC + + + View + Edit +
+
+ +
+
diff --git a/composer.json b/composer.json index 7500f23..7c07735 100644 --- a/composer.json +++ b/composer.json @@ -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" } } diff --git a/composer.lock b/composer.lock index 4bcb0f2..b2fbbc4 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/maps.php b/config/maps.php new file mode 100644 index 0000000..db76cbf --- /dev/null +++ b/config/maps.php @@ -0,0 +1,11 @@ + $_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', +]; diff --git a/core/Controller.php b/core/Controller.php index a73032e..bba7e82 100644 --- a/core/Controller.php +++ b/core/Controller.php @@ -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), + ]); + } } diff --git a/core/Pagination.php b/core/Pagination.php new file mode 100644 index 0000000..b429963 --- /dev/null +++ b/core/Pagination.php @@ -0,0 +1,44 @@ +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 */ + public function pageRange(): array + { + $start = max(1, $this->page - 2); + $end = min($this->totalPages, $this->page + 2); + + return range($start, $end); + } +} diff --git a/core/Validator.php b/core/Validator.php index 3e6f340..64573d0 100644 --- a/core/Validator.php +++ b/core/Validator.php @@ -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); diff --git a/core/helpers.php b/core/helpers.php index b00f086..786a1f5 100644 --- a/core/helpers.php +++ b/core/helpers.php @@ -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; +} diff --git a/database/Migrate-AccessToSQLite.ps1 b/database/Migrate-AccessToSQLite.ps1 new file mode 100644 index 0000000..bd713f6 --- /dev/null +++ b/database/Migrate-AccessToSQLite.ps1 @@ -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()) +} diff --git a/database/migrate_access_to_sqlite.php b/database/migrate_access_to_sqlite.php new file mode 100644 index 0000000..cc2f7e5 --- /dev/null +++ b/database/migrate_access_to_sqlite.php @@ -0,0 +1,358 @@ +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); + } +} diff --git a/database/migrations/20260531_000002_create_territory_tables.php b/database/migrations/20260531_000002_create_territory_tables.php new file mode 100644 index 0000000..157d0ec --- /dev/null +++ b/database/migrations/20260531_000002_create_territory_tables.php @@ -0,0 +1,63 @@ +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}"); + } + } +}; diff --git a/database/myAccessFile.accdb b/database/myAccessFile.accdb new file mode 100644 index 0000000..3176930 Binary files /dev/null and b/database/myAccessFile.accdb differ diff --git a/public/css/site.css b/public/css/site.css index 0809d55..811e06d 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -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; + } +} diff --git a/routes/web.php b/routes/web.php index e11f652..179d676 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']);