소스 검색

sql migrate

ASP_MATCH
부모
커밋
f4901b4f9f
37개의 변경된 파일4581개의 추가작업 그리고 6개의 파일을 삭제
  1. +14
    -1
      .claude/settings.local.json
  2. +74
    -0
      app/Controllers/ExportController.php
  3. +266
    -0
      app/Controllers/HouseholdController.php
  4. +253
    -0
      app/Controllers/HouseholderNameController.php
  5. +214
    -0
      app/Controllers/TerritoryController.php
  6. +113
    -0
      app/Repositories/HouseholdRepository.php
  7. +102
    -0
      app/Repositories/HouseholderNameRepository.php
  8. +90
    -0
      app/Repositories/TerritoryRepository.php
  9. +273
    -0
      app/Services/ExportService.php
  10. +54
    -0
      app/Views/export/generate.php
  11. +64
    -0
      app/Views/householder-names/create.php
  12. +65
    -0
      app/Views/householder-names/edit.php
  13. +97
    -0
      app/Views/householder-names/index.php
  14. +59
    -0
      app/Views/householder-names/show.php
  15. +123
    -0
      app/Views/households/create.php
  16. +109
    -0
      app/Views/households/edit.php
  17. +124
    -0
      app/Views/households/index.php
  18. +154
    -0
      app/Views/households/show.php
  19. +9
    -0
      app/Views/layouts/app.php
  20. +5
    -2
      app/Views/partials/header.php
  21. +44
    -0
      app/Views/territories/create.php
  22. +43
    -0
      app/Views/territories/edit.php
  23. +91
    -0
      app/Views/territories/index.php
  24. +95
    -0
      app/Views/territories/show.php
  25. +3
    -1
      composer.json
  26. +816
    -1
      composer.lock
  27. +11
    -0
      config/maps.php
  28. +19
    -0
      core/Controller.php
  29. +44
    -0
      core/Pagination.php
  30. +9
    -0
      core/Validator.php
  31. +30
    -0
      core/helpers.php
  32. +354
    -0
      database/Migrate-AccessToSQLite.ps1
  33. +358
    -0
      database/migrate_access_to_sqlite.php
  34. +63
    -0
      database/migrations/20260531_000002_create_territory_tables.php
  35. BIN
      database/myAccessFile.accdb
  36. +300
    -0
      public/css/site.css
  37. +39
    -1
      routes/web.php

+ 14
- 1
.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 ' *)"
]
}
}

+ 74
- 0
app/Controllers/ExportController.php 파일 보기

@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\HouseholdRepository;
use App\Repositories\TerritoryRepository;
use App\Services\ExportService;
use Core\Controller;
use Core\Request;
use Core\Response;

class ExportController extends Controller
{
public function generate(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$territories = (new TerritoryRepository(database()))->allOrdered();

return $this->view('export.generate', [
'pageTitle' => 'Export Territories',
'territories' => $territories,
]);
}

public function download(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();

if (!verify_csrf_token($request->input('_token'))) {
flash('error', 'Invalid request.');
return $this->redirect('/export');
}

$selectedIds = $request->input('territory_ids');

if (!is_array($selectedIds) || empty($selectedIds)) {
flash('error', 'Please select at least one territory to export.');
return $this->redirect('/export');
}

$ids = array_map('intval', $selectedIds);
$ids = array_filter($ids, fn(int $id) => $id > 0);

if (empty($ids)) {
flash('error', 'Please select at least one territory to export.');
return $this->redirect('/export');
}

$territoryRepo = new TerritoryRepository(database());
$territories = array_filter(
array_map(fn(int $id) => $territoryRepo->find($id), $ids)
);

if (empty($territories)) {
flash('error', 'Selected territories not found.');
return $this->redirect('/export');
}

$households = (new HouseholdRepository(database()))->findAllByTerritories($ids);
$zip = (new ExportService())->buildZip(array_values($territories), $households);
$filename = 'territory_export_' . date('Ymd_His') . '.zip';

return $this->fileResponse($zip, $filename, 'application/zip');
}
}

+ 266
- 0
app/Controllers/HouseholdController.php 파일 보기

@@ -0,0 +1,266 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\HouseholdRepository;
use App\Repositories\HouseholderNameRepository;
use App\Repositories\TerritoryRepository;
use Core\Controller;
use Core\Pagination;
use Core\Request;
use Core\Response;
use Core\Validator;

class HouseholdController extends Controller
{
public function index(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$search = (string) ($request->input('search') ?? '');
$territoryId = (string) ($request->input('territory_id') ?? '');
$doNotCall = (string) ($request->input('do_not_call') ?? '');
$page = max(1, (int) ($request->input('page') ?? 1));
$perPage = 20;

$repo = new HouseholdRepository(database());
$total = $repo->countAll($search, $territoryId, $doNotCall);
$pagination = new Pagination($total, $page, $perPage);
$households = $repo->findPaged($page, $perPage, $search, $territoryId, $doNotCall);
$territories = (new TerritoryRepository(database()))->allOrdered();

return $this->view('households.index', [
'pageTitle' => 'Households',
'households' => $households,
'territories' => $territories,
'search' => $search,
'territoryId' => $territoryId,
'doNotCall' => $doNotCall,
'pagination' => $pagination,
]);
}

public function show(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$household = (new HouseholdRepository(database()))->findWithTerritory((int) $id);

if (!$household) {
return Response::notFound('Household not found.');
}

$names = (new HouseholderNameRepository(database()))->findAllByHousehold((int) $id);
$maps = maps_config();

return $this->view('households.show', [
'pageTitle' => e($household['address']),
'household' => $household,
'names' => $names,
'maps' => $maps,
]);
}

public function create(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$territories = (new TerritoryRepository(database()))->allOrdered();

return $this->view('households.create', [
'pageTitle' => 'New Household',
'territories' => $territories,
'defaultTerritoryId' => (string) ($request->input('territory_id') ?? ''),
'errors' => [],
'old' => [],
]);
}

public function store(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$territories = (new TerritoryRepository(database()))->allOrdered();

if (!verify_csrf_token($request->input('_token'))) {
return $this->view('households.create', [
'pageTitle' => 'New Household',
'territories' => $territories,
'defaultTerritoryId' => '',
'errors' => ['_token' => ['Invalid request. Please try again.']],
'old' => $request->all(),
]);
}

$fields = $this->extractFields($request);

$validator = (new Validator())
->required('territory_id', $fields['territory_id'], 'Territory is required.')
->required('address', $fields['address'], 'Address is required.')
->maxLength('address', $fields['address'], 255)
->optionalNumeric('street_number', $fields['street_number'])
->optionalNumeric('latitude', $fields['latitude'], 'Latitude must be a number.')
->optionalNumeric('longitude', $fields['longitude'], 'Longitude must be a number.');

if ($validator->fails()) {
return $this->view('households.create', [
'pageTitle' => 'New Household',
'territories' => $territories,
'defaultTerritoryId' => $fields['territory_id'],
'errors' => $validator->errors(),
'old' => $request->all(),
]);
}

$now = date('Y-m-d H:i:s');
database()->execute(
'INSERT INTO households
(territory_id, address, street_number, street_name, latitude, longitude,
is_business, do_not_call, do_not_call_date, do_not_call_notes,
do_not_call_private_notes, created_at, updated_at)
VALUES
(:territory_id, :address, :street_number, :street_name, :latitude, :longitude,
:is_business, :do_not_call, :do_not_call_date, :do_not_call_notes,
:do_not_call_private_notes, :now, :now)',
array_merge($fields, ['now' => $now])
);

flash('success', 'Household created.');
return $this->redirect('/households');
}

public function edit(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$household = (new HouseholdRepository(database()))->find((int) $id);

if (!$household) {
return Response::notFound('Household not found.');
}

$territories = (new TerritoryRepository(database()))->allOrdered();

return $this->view('households.edit', [
'pageTitle' => 'Edit Household',
'household' => $household,
'territories' => $territories,
'errors' => [],
]);
}

public function update(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$household = (new HouseholdRepository(database()))->find((int) $id);

if (!$household) {
return Response::notFound('Household not found.');
}

if (!verify_csrf_token($request->input('_token'))) {
flash('error', 'Invalid request.');
return $this->redirect('/households/' . $id . '/edit');
}

$fields = $this->extractFields($request);
$validator = (new Validator())
->required('territory_id', $fields['territory_id'], 'Territory is required.')
->required('address', $fields['address'], 'Address is required.')
->maxLength('address', $fields['address'], 255)
->optionalNumeric('street_number', $fields['street_number'])
->optionalNumeric('latitude', $fields['latitude'], 'Latitude must be a number.')
->optionalNumeric('longitude', $fields['longitude'], 'Longitude must be a number.');

$territories = (new TerritoryRepository(database()))->allOrdered();

if ($validator->fails()) {
return $this->view('households.edit', [
'pageTitle' => 'Edit Household',
'household' => array_merge($household, $fields),
'territories' => $territories,
'errors' => $validator->errors(),
]);
}

database()->execute(
'UPDATE households SET
territory_id = :territory_id, address = :address,
street_number = :street_number, street_name = :street_name,
latitude = :latitude, longitude = :longitude,
is_business = :is_business, do_not_call = :do_not_call,
do_not_call_date = :do_not_call_date, do_not_call_notes = :do_not_call_notes,
do_not_call_private_notes = :do_not_call_private_notes,
updated_at = :now
WHERE id = :id',
array_merge($fields, ['now' => date('Y-m-d H:i:s'), 'id' => $id])
);

flash('success', 'Household updated.');
return $this->redirect('/households/' . $id);
}

public function delete(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$household = (new HouseholdRepository(database()))->find((int) $id);

if (!$household) {
return Response::notFound('Household not found.');
}

if (!verify_csrf_token($request->input('_token'))) {
flash('error', 'Invalid request.');
return $this->redirect('/households');
}

database()->execute('DELETE FROM householder_names WHERE household_id = :id', ['id' => $id]);
database()->execute('DELETE FROM households WHERE id = :id', ['id' => $id]);

flash('success', 'Household deleted.');
return $this->redirect('/households');
}

/** @return array<string,mixed> */
private function extractFields(Request $request): array
{
$doNotCall = (bool) $request->input('do_not_call');

return [
'territory_id' => (string) ($request->input('territory_id') ?? ''),
'address' => trim((string) ($request->input('address') ?? '')),
'street_number' => $request->input('street_number') !== '' ? $request->input('street_number') : null,
'street_name' => trim((string) ($request->input('street_name') ?? '')),
'latitude' => $request->input('latitude') !== '' ? $request->input('latitude') : null,
'longitude' => $request->input('longitude') !== '' ? $request->input('longitude') : null,
'is_business' => (int) (bool) $request->input('is_business'),
'do_not_call' => (int) $doNotCall,
'do_not_call_date' => $doNotCall ? (trim((string) ($request->input('do_not_call_date') ?? '')) ?: null) : null,
'do_not_call_notes' => trim((string) ($request->input('do_not_call_notes') ?? '')),
'do_not_call_private_notes' => trim((string) ($request->input('do_not_call_private_notes') ?? '')),
];
}
}

+ 253
- 0
app/Controllers/HouseholderNameController.php 파일 보기

@@ -0,0 +1,253 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\HouseholdRepository;
use App\Repositories\HouseholderNameRepository;
use Core\Controller;
use Core\Pagination;
use Core\Request;
use Core\Response;
use Core\Validator;

class HouseholderNameController extends Controller
{
public function index(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$search = (string) ($request->input('search') ?? '');
$householdId = (string) ($request->input('household_id') ?? '');
$page = max(1, (int) ($request->input('page') ?? 1));
$perPage = 20;

$repo = new HouseholderNameRepository(database());
$total = $repo->countAll($search, $householdId);
$pagination = new Pagination($total, $page, $perPage);
$names = $repo->findPaged($page, $perPage, $search, $householdId);

return $this->view('householder-names.index', [
'pageTitle' => 'Householder Names',
'names' => $names,
'search' => $search,
'householdId' => $householdId,
'pagination' => $pagination,
]);
}

public function show(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$name = (new HouseholderNameRepository(database()))->findWithHousehold((int) $id);

if (!$name) {
return Response::notFound('Householder name not found.');
}

return $this->view('householder-names.show', [
'pageTitle' => e($name['name']),
'name' => $name,
]);
}

public function create(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$households = (new HouseholdRepository(database()))->all();

return $this->view('householder-names.create', [
'pageTitle' => 'New Householder Name',
'households' => $households,
'defaultHouseholdId' => (string) ($request->input('household_id') ?? ''),
'errors' => [],
'old' => [],
]);
}

public function store(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$households = (new HouseholdRepository(database()))->all();

if (!verify_csrf_token($request->input('_token'))) {
return $this->view('householder-names.create', [
'pageTitle' => 'New Householder Name',
'households' => $households,
'defaultHouseholdId' => '',
'errors' => ['_token' => ['Invalid request. Please try again.']],
'old' => $request->all(),
]);
}

$householdId = (string) ($request->input('household_id') ?? '');
$name = trim((string) ($request->input('name') ?? ''));
$letterReturned = (int) (bool) $request->input('letter_returned');
$returnDate = trim((string) ($request->input('return_date') ?? '')) ?: null;

$validator = (new Validator())
->required('household_id', $householdId, 'Household is required.')
->required('name', $name, 'Name is required.')
->maxLength('name', $name, 255);

if ($validator->fails()) {
return $this->view('householder-names.create', [
'pageTitle' => 'New Householder Name',
'households' => $households,
'defaultHouseholdId' => $householdId,
'errors' => $validator->errors(),
'old' => $request->all(),
]);
}

$now = date('Y-m-d H:i:s');
database()->execute(
'INSERT INTO householder_names (household_id, name, letter_returned, return_date, created_at, updated_at)
VALUES (:household_id, :name, :letter_returned, :return_date, :now, :now)',
['household_id' => $householdId, 'name' => $name,
'letter_returned' => $letterReturned, 'return_date' => $returnDate, 'now' => $now]
);

flash('success', "Householder name \"{$name}\" created.");
return $this->redirect('/householder-names');
}

public function edit(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$name = (new HouseholderNameRepository(database()))->find((int) $id);

if (!$name) {
return Response::notFound('Householder name not found.');
}

$households = (new HouseholdRepository(database()))->all();

return $this->view('householder-names.edit', [
'pageTitle' => 'Edit Householder Name',
'name' => $name,
'households' => $households,
'errors' => [],
]);
}

public function update(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$repo = new HouseholderNameRepository(database());
$name = $repo->find((int) $id);

if (!$name) {
return Response::notFound('Householder name not found.');
}

if (!verify_csrf_token($request->input('_token'))) {
flash('error', 'Invalid request.');
return $this->redirect('/householder-names/' . $id . '/edit');
}

$householdId = (string) ($request->input('household_id') ?? '');
$nameVal = trim((string) ($request->input('name') ?? ''));
$letterReturned = (int) (bool) $request->input('letter_returned');
$returnDate = trim((string) ($request->input('return_date') ?? '')) ?: null;

$validator = (new Validator())
->required('household_id', $householdId, 'Household is required.')
->required('name', $nameVal, 'Name is required.')
->maxLength('name', $nameVal, 255);

$households = (new HouseholdRepository(database()))->all();

if ($validator->fails()) {
return $this->view('householder-names.edit', [
'pageTitle' => 'Edit Householder Name',
'name' => array_merge($name, ['name' => $nameVal, 'household_id' => $householdId]),
'households' => $households,
'errors' => $validator->errors(),
]);
}

database()->execute(
'UPDATE householder_names SET household_id = :household_id, name = :name,
letter_returned = :letter_returned, return_date = :return_date,
updated_at = :now WHERE id = :id',
['household_id' => $householdId, 'name' => $nameVal, 'letter_returned' => $letterReturned,
'return_date' => $returnDate, 'now' => date('Y-m-d H:i:s'), 'id' => $id]
);

flash('success', "Householder name \"{$nameVal}\" updated.");
return $this->redirect('/householder-names/' . $id);
}

public function delete(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$name = (new HouseholderNameRepository(database()))->find((int) $id);

if (!$name) {
return Response::notFound('Householder name not found.');
}

if (!verify_csrf_token($request->input('_token'))) {
flash('error', 'Invalid request.');
return $this->redirect('/householder-names');
}

$householdId = $name['household_id'];
database()->execute('DELETE FROM householder_names WHERE id = :id', ['id' => $id]);

flash('success', 'Householder name deleted.');
return $this->redirect('/households/' . $householdId);
}

public function markReturned(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$repo = new HouseholderNameRepository(database());
$name = $repo->find((int) $id);

if (!$name) {
return Response::notFound('Householder name not found.');
}

if (!verify_csrf_token($request->input('_token'))) {
flash('error', 'Invalid request.');
return $this->redirect('/households/' . $name['household_id']);
}

$repo->toggleLetterReturned((int) $id);

flash('success', 'Letter returned status updated.');
return $this->redirect('/households/' . $name['household_id']);
}
}

+ 214
- 0
app/Controllers/TerritoryController.php 파일 보기

@@ -0,0 +1,214 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\TerritoryRepository;
use Core\Controller;
use Core\Pagination;
use Core\Request;
use Core\Response;
use Core\Validator;

class TerritoryController extends Controller
{
public function index(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$search = (string) ($request->input('search') ?? '');
$page = max(1, (int) ($request->input('page') ?? 1));
$perPage = 20;
$repo = new TerritoryRepository(database());
$total = $repo->countAll($search);
$pagination = new Pagination($total, $page, $perPage);
$territories = $repo->findPaged($page, $perPage, $search);
$counts = $repo->householdCountsKeyed();

return $this->view('territories.index', [
'pageTitle' => 'Territories',
'territories' => $territories,
'counts' => $counts,
'search' => $search,
'pagination' => $pagination,
]);
}

public function show(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$repo = new TerritoryRepository(database());
$territory = $repo->find((int) $id);

if (!$territory) {
return Response::notFound('Territory not found.');
}

$streets = $repo->distinctStreets((int) $id);

$householdRepo = new \App\Repositories\HouseholdRepository(database());
$households = $householdRepo->findAllByTerritory((int) $id);

return $this->view('territories.show', [
'pageTitle' => e($territory['name']),
'territory' => $territory,
'streets' => $streets,
'households' => $households,
]);
}

public function create(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

return $this->view('territories.create', [
'pageTitle' => 'New Territory',
'errors' => [],
'old' => [],
]);
}

public function store(): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();

if (!verify_csrf_token($request->input('_token'))) {
return $this->view('territories.create', [
'pageTitle' => 'New Territory',
'errors' => ['_token' => ['Invalid request. Please try again.']],
'old' => $request->all(),
]);
}

$name = trim((string) ($request->input('name') ?? ''));
$description = trim((string) ($request->input('description') ?? ''));
$coordinates = trim((string) ($request->input('coordinates') ?? ''));

$validator = (new Validator())
->required('name', $name, 'Name is required.')
->maxLength('name', $name, 255);

if ($validator->fails()) {
return $this->view('territories.create', [
'pageTitle' => 'New Territory',
'errors' => $validator->errors(),
'old' => $request->all(),
]);
}

$now = date('Y-m-d H:i:s');
database()->execute(
'INSERT INTO territories (name, description, coordinates, created_at, updated_at)
VALUES (:name, :description, :coordinates, :now, :now)',
['name' => $name, 'description' => $description, 'coordinates' => $coordinates, 'now' => $now]
);

flash('success', "Territory \"{$name}\" created.");
return $this->redirect('/territories');
}

public function edit(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$territory = (new TerritoryRepository(database()))->find((int) $id);

if (!$territory) {
return Response::notFound('Territory not found.');
}

return $this->view('territories.edit', [
'pageTitle' => 'Edit Territory',
'territory' => $territory,
'errors' => [],
]);
}

public function update(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$repo = new TerritoryRepository(database());
$territory = $repo->find((int) $id);

if (!$territory) {
return Response::notFound('Territory not found.');
}

if (!verify_csrf_token($request->input('_token'))) {
return $this->view('territories.edit', [
'pageTitle' => 'Edit Territory',
'territory' => $territory,
'errors' => ['_token' => ['Invalid request. Please try again.']],
]);
}

$name = trim((string) ($request->input('name') ?? ''));
$description = trim((string) ($request->input('description') ?? ''));
$coordinates = trim((string) ($request->input('coordinates') ?? ''));

$validator = (new Validator())
->required('name', $name, 'Name is required.')
->maxLength('name', $name, 255);

if ($validator->fails()) {
return $this->view('territories.edit', [
'pageTitle' => 'Edit Territory',
'territory' => array_merge($territory, ['name' => $name, 'description' => $description, 'coordinates' => $coordinates]),
'errors' => $validator->errors(),
]);
}

database()->execute(
'UPDATE territories SET name = :name, description = :description, coordinates = :coordinates,
updated_at = :now WHERE id = :id',
['name' => $name, 'description' => $description, 'coordinates' => $coordinates,
'now' => date('Y-m-d H:i:s'), 'id' => $id]
);

flash('success', "Territory \"{$name}\" updated.");
return $this->redirect('/territories/' . $id);
}

public function delete(int|string $id): Response
{
if ($redirect = $this->requireAuth()) {
return $redirect;
}

$request = Request::capture();
$territory = (new TerritoryRepository(database()))->find((int) $id);

if (!$territory) {
return Response::notFound('Territory not found.');
}

if (!verify_csrf_token($request->input('_token'))) {
flash('error', 'Invalid request.');
return $this->redirect('/territories');
}

database()->execute('DELETE FROM territories WHERE id = :id', ['id' => $id]);

flash('success', 'Territory deleted.');
return $this->redirect('/territories');
}
}

+ 113
- 0
app/Repositories/HouseholdRepository.php 파일 보기

@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

class HouseholdRepository extends Repository
{
protected string $table = 'households';
protected string $primaryKey = 'id';

public function countAll(string $search = '', string $territoryId = '', string $doNotCall = ''): int
{
[$sql, $params] = $this->buildFilterQuery(
'SELECT COUNT(*) AS n FROM households h
JOIN territories t ON t.id = h.territory_id',
$search, $territoryId, $doNotCall
);

$row = $this->database->first($sql, $params);
return (int) ($row['n'] ?? 0);
}

/** @return list<array<string,mixed>> */
public function findPaged(int $page, int $perPage, string $search = '', string $territoryId = '', string $doNotCall = ''): array
{
$offset = ($page - 1) * $perPage;

[$where, $params] = $this->buildFilterQuery(
'SELECT h.*, t.name AS territory_name FROM households h
JOIN territories t ON t.id = h.territory_id',
$search, $territoryId, $doNotCall
);

return $this->database->query(
$where . ' ORDER BY t.name ASC, h.street_name ASC, h.street_number ASC
LIMIT :limit OFFSET :offset',
array_merge($params, ['limit' => $perPage, 'offset' => $offset])
);
}

/** @return list<array<string,mixed>> */
public function findAllByTerritory(int|string $territoryId): array
{
return $this->database->query(
'SELECT * FROM households WHERE territory_id = :id
ORDER BY street_name ASC, street_number ASC',
['id' => $territoryId]
);
}

/** @return list<array<string,mixed>> */
public function findAllByTerritories(array $territoryIds): array
{
if (empty($territoryIds)) {
return [];
}

$placeholders = implode(',', array_fill(0, count($territoryIds), '?'));

return $this->database->query(
"SELECT h.*, t.name AS territory_name
FROM households h
JOIN territories t ON t.id = h.territory_id
WHERE h.territory_id IN ({$placeholders})
ORDER BY t.name ASC, h.street_name ASC, h.street_number ASC",
array_values($territoryIds)
);
}

public function findWithTerritory(int|string $id): ?array
{
return $this->database->first(
'SELECT h.*, t.name AS territory_name
FROM households h
JOIN territories t ON t.id = h.territory_id
WHERE h.id = :id',
['id' => $id]
);
}

/** @return array{string, array<string,mixed>} */
private function buildFilterQuery(string $base, string $search, string $territoryId, string $doNotCall): array
{
$conditions = [];
$params = [];

if ($search !== '') {
$conditions[] = '(h.address LIKE :s OR h.street_name LIKE :s)';
$params['s'] = '%' . $search . '%';
}

if ($territoryId !== '') {
$conditions[] = 'h.territory_id = :tid';
$params['tid'] = $territoryId;
}

if ($doNotCall === '1') {
$conditions[] = 'h.do_not_call = 1';
} elseif ($doNotCall === '0') {
$conditions[] = 'h.do_not_call = 0';
}

$sql = $base;
if (!empty($conditions)) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}

return [$sql, $params];
}
}

+ 102
- 0
app/Repositories/HouseholderNameRepository.php 파일 보기

@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

class HouseholderNameRepository extends Repository
{
protected string $table = 'householder_names';
protected string $primaryKey = 'id';

public function countAll(string $search = '', string $householdId = ''): int
{
[$sql, $params] = $this->buildFilterQuery(
'SELECT COUNT(*) AS n FROM householder_names hn
JOIN households h ON h.id = hn.household_id',
$search, $householdId
);

$row = $this->database->first($sql, $params);
return (int) ($row['n'] ?? 0);
}

/** @return list<array<string,mixed>> */
public function findPaged(int $page, int $perPage, string $search = '', string $householdId = ''): array
{
$offset = ($page - 1) * $perPage;

[$where, $params] = $this->buildFilterQuery(
'SELECT hn.*, h.address AS household_address, t.name AS territory_name
FROM householder_names hn
JOIN households h ON h.id = hn.household_id
JOIN territories t ON t.id = h.territory_id',
$search, $householdId
);

return $this->database->query(
$where . ' ORDER BY h.address ASC, hn.name ASC LIMIT :limit OFFSET :offset',
array_merge($params, ['limit' => $perPage, 'offset' => $offset])
);
}

/** @return list<array<string,mixed>> */
public function findAllByHousehold(int|string $householdId): array
{
return $this->database->query(
'SELECT * FROM householder_names WHERE household_id = :id ORDER BY name ASC',
['id' => $householdId]
);
}

public function findWithHousehold(int|string $id): ?array
{
return $this->database->first(
'SELECT hn.*, h.address AS household_address, h.territory_id,
t.name AS territory_name
FROM householder_names hn
JOIN households h ON h.id = hn.household_id
JOIN territories t ON t.id = h.territory_id
WHERE hn.id = :id',
['id' => $id]
);
}

public function toggleLetterReturned(int|string $id): bool
{
return $this->database->execute(
'UPDATE householder_names
SET letter_returned = CASE WHEN letter_returned = 1 THEN 0 ELSE 1 END,
return_date = CASE WHEN letter_returned = 0 THEN datetime(\'now\') ELSE NULL END,
updated_at = datetime(\'now\')
WHERE id = :id',
['id' => $id]
);
}

/** @return array{string, array<string,mixed>} */
private function buildFilterQuery(string $base, string $search, string $householdId): array
{
$conditions = [];
$params = [];

if ($search !== '') {
$conditions[] = 'hn.name LIKE :s';
$params['s'] = '%' . $search . '%';
}

if ($householdId !== '') {
$conditions[] = 'hn.household_id = :hid';
$params['hid'] = $householdId;
}

$sql = $base;
if (!empty($conditions)) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}

return [$sql, $params];
}
}

+ 90
- 0
app/Repositories/TerritoryRepository.php 파일 보기

@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

class TerritoryRepository extends Repository
{
protected string $table = 'territories';
protected string $primaryKey = 'id';

public function countAll(string $search = ''): int
{
if ($search !== '') {
$row = $this->database->first(
'SELECT COUNT(*) AS n FROM territories WHERE name LIKE :s OR description LIKE :s',
['s' => '%' . $search . '%']
);
} else {
$row = $this->database->first('SELECT COUNT(*) AS n FROM territories');
}

return (int) ($row['n'] ?? 0);
}

/** @return list<array<string,mixed>> */
public function findPaged(int $page, int $perPage, string $search = ''): array
{
$offset = ($page - 1) * $perPage;

if ($search !== '') {
return $this->database->query(
'SELECT * FROM territories WHERE name LIKE :s OR description LIKE :s
ORDER BY name ASC LIMIT :limit OFFSET :offset',
['s' => '%' . $search . '%', 'limit' => $perPage, 'offset' => $offset]
);
}

return $this->database->query(
'SELECT * FROM territories ORDER BY name ASC LIMIT :limit OFFSET :offset',
['limit' => $perPage, 'offset' => $offset]
);
}

/** @return list<array<string,mixed>> */
public function allOrdered(): array
{
return $this->database->query('SELECT * FROM territories ORDER BY name ASC');
}

public function householdCount(int|string $territoryId): int
{
$row = $this->database->first(
'SELECT COUNT(*) AS n FROM households WHERE territory_id = :id',
['id' => $territoryId]
);

return (int) ($row['n'] ?? 0);
}

/** @return list<array<string,mixed>> */
public function householdCountsKeyed(): array
{
$rows = $this->database->query(
'SELECT territory_id, COUNT(*) AS n FROM households GROUP BY territory_id'
);

$counts = [];
foreach ($rows as $row) {
$counts[(int) $row['territory_id']] = (int) $row['n'];
}

return $counts;
}

/** @return list<string> */
public function distinctStreets(int|string $territoryId): array
{
$rows = $this->database->query(
'SELECT DISTINCT street_name FROM households
WHERE territory_id = :id AND street_name IS NOT NULL AND street_name != \'\'
ORDER BY street_name ASC',
['id' => $territoryId]
);

return array_column($rows, 'street_name');
}
}

+ 273
- 0
app/Services/ExportService.php 파일 보기

@@ -0,0 +1,273 @@
<?php

declare(strict_types=1);

namespace App\Services;

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Dompdf\Dompdf;
use Dompdf\Options;

class ExportService
{
/**
* Build a ZIP archive containing XLSX + PDF for each territory.
*
* @param list<array<string,mixed>> $territories
* @param list<array<string,mixed>> $allHouseholds All household rows (with territory_id)
* @return string Raw ZIP binary content
*/
public function buildZip(array $territories, array $allHouseholds): string
{
$byTerritory = [];
foreach ($allHouseholds as $h) {
$tid = (int) $h['territory_id'];
$byTerritory[$tid][] = $h;
}

$zipFile = tempnam(sys_get_temp_dir(), 'territory_export_');
$zip = new \ZipArchive();
$zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);

foreach ($territories as $territory) {
$tid = (int) $territory['id'];
$households = $byTerritory[$tid] ?? [];
$slug = $this->slug($territory['name']);

$xlsx = $this->buildXlsx($territory, $households);
$zip->addFromString("{$slug}.xlsx", $xlsx);

$pdf = $this->buildPdf($territory, $households);
$zip->addFromString("{$slug}.pdf", $pdf);
}

$zip->close();
$content = (string) file_get_contents($zipFile);
unlink($zipFile);

return $content;
}

/** @param list<array<string,mixed>> $households */
private function buildXlsx(array $territory, array $households): string
{
$spreadsheet = new Spreadsheet();
$spreadsheet->removeSheetByIndex(0);

$byStreet = $this->groupByStreet($households);

if (empty($byStreet)) {
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('No Data');
$sheet->setCellValue('A1', 'No households found for this territory.');
}

foreach ($byStreet as $streetName => $streetHouseholds) {
$sheet = $spreadsheet->createSheet();
$sheet->setTitle(substr((string) $streetName, 0, 31));

[$even, $odd] = $this->splitEvenOdd($streetHouseholds);

$headerStyle = [
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '1D7A6D']],
'borders' => ['bottom' => ['borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN]],
];

$evenHeaders = ['#', 'Address (Even)', 'Bus.', 'DNC', 'DNC Date'];
$oddHeaders = ['#', 'Address (Odd)', 'Bus.', 'DNC', 'DNC Date'];

foreach ($evenHeaders as $col => $header) {
$cell = $this->colLetter($col + 1) . '1';
$sheet->setCellValue($cell, $header);
$sheet->getStyle($cell)->applyFromArray($headerStyle);
}

$sheet->setCellValue('G1', '');

foreach ($oddHeaders as $col => $header) {
$cell = $this->colLetter($col + 8) . '1';
$sheet->setCellValue($cell, $header);
$sheet->getStyle($cell)->applyFromArray($headerStyle);
}

$row = 2;
foreach ($even as $h) {
$sheet->setCellValue("A{$row}", $h['street_number'] ?? '');
$sheet->setCellValue("B{$row}", $h['address'] ?? '');
$sheet->setCellValue("C{$row}", $h['is_business'] ? 'Yes' : '');
$sheet->setCellValue("D{$row}", $h['do_not_call'] ? 'Yes' : '');
$sheet->setCellValue("E{$row}", $h['do_not_call_date'] ?? '');
$row++;
}

$row = 2;
foreach ($odd as $h) {
$sheet->setCellValue("H{$row}", $h['street_number'] ?? '');
$sheet->setCellValue("I{$row}", $h['address'] ?? '');
$sheet->setCellValue("J{$row}", $h['is_business'] ? 'Yes' : '');
$sheet->setCellValue("K{$row}", $h['do_not_call'] ? 'Yes' : '');
$sheet->setCellValue("L{$row}", $h['do_not_call_date'] ?? '');
$row++;
}

foreach (range('A', 'L') as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
}

$writer = new Xlsx($spreadsheet);
$tmpFile = tempnam(sys_get_temp_dir(), 'territory_xlsx_');
$writer->save($tmpFile);
$content = (string) file_get_contents($tmpFile);
unlink($tmpFile);

return $content;
}

/** @param list<array<string,mixed>> $households */
private function buildPdf(array $territory, array $households): string
{
$options = new Options();
$options->set('defaultFont', 'DejaVu Sans');
$options->set('isRemoteEnabled', false);

$dompdf = new Dompdf($options);
$dompdf->loadHtml($this->buildPdfHtml($territory, $households));
$dompdf->setPaper('A4', 'landscape');
$dompdf->render();

return (string) $dompdf->output();
}

/** @param list<array<string,mixed>> $households */
private function buildPdfHtml(array $territory, array $households): string
{
$title = htmlspecialchars($territory['name'], ENT_QUOTES, 'UTF-8');
$byStreet = $this->groupByStreet($households);

$html = '<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
body { font-family: DejaVu Sans, sans-serif; font-size: 9pt; margin: 10mm; }
h1 { font-size: 14pt; margin-bottom: 4px; }
h2 { font-size: 11pt; margin: 12px 0 4px; border-bottom: 1px solid #555; padding-bottom: 2px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 8px; }
th { background: #1d7a6d; color: #fff; padding: 4px 6px; font-size: 8pt; text-align: left; }
td { padding: 3px 6px; border-bottom: 1px solid #ddd; font-size: 8pt; vertical-align: top; }
.dnc { color: #c0392b; font-weight: bold; }
.sep { width: 6px; }
</style>
</head><body>';

$html .= "<h1>Territory: {$title}</h1>";

if (empty($byStreet)) {
$html .= '<p>No households found.</p>';
}

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

$html .= "<h2>{$escapedStreet}</h2>";
$html .= '<table><tr>
<th>#</th><th>Address (Even)</th><th>DNC</th>
<th class="sep"></th>
<th>#</th><th>Address (Odd)</th><th>DNC</th>
</tr>';

$max = max(count($even), count($odd), 1);
$evenVals = array_values($even);
$oddVals = array_values($odd);

for ($i = 0; $i < $max; $i++) {
$html .= '<tr>';
if (isset($evenVals[$i])) {
$h = $evenVals[$i];
$addr = htmlspecialchars($h['address'] ?? '', ENT_QUOTES, 'UTF-8');
$dnc = $h['do_not_call'] ? '<span class="dnc">DNC</span>' : '';
$html .= "<td>{$h['street_number']}</td><td>{$addr}</td><td>{$dnc}</td>";
} else {
$html .= '<td></td><td></td><td></td>';
}
$html .= '<td class="sep"></td>';
if (isset($oddVals[$i])) {
$h = $oddVals[$i];
$addr = htmlspecialchars($h['address'] ?? '', ENT_QUOTES, 'UTF-8');
$dnc = $h['do_not_call'] ? '<span class="dnc">DNC</span>' : '';
$html .= "<td>{$h['street_number']}</td><td>{$addr}</td><td>{$dnc}</td>";
} else {
$html .= '<td></td><td></td><td></td>';
}
$html .= '</tr>';
}

$html .= '</table>';
}

$html .= '</body></html>';
return $html;
}

/**
* @param list<array<string,mixed>> $households
* @return array<string, list<array<string,mixed>>>
*/
private function groupByStreet(array $households): array
{
$byStreet = [];
foreach ($households as $h) {
$street = trim((string) ($h['street_name'] ?? ''));
if ($street === '') {
$street = 'Unknown Street';
}
$byStreet[$street][] = $h;
}

ksort($byStreet);
return $byStreet;
}

/**
* @param list<array<string,mixed>> $households
* @return array{list<array<string,mixed>>, list<array<string,mixed>>}
*/
private function splitEvenOdd(array $households): array
{
$even = [];
$odd = [];

foreach ($households as $h) {
$num = (int) ($h['street_number'] ?? 0);
if ($num % 2 === 0) {
$even[] = $h;
} else {
$odd[] = $h;
}
}

usort($even, fn($a, $b) => (int) ($a['street_number'] ?? 0) <=> (int) ($b['street_number'] ?? 0));
usort($odd, fn($a, $b) => (int) ($a['street_number'] ?? 0) <=> (int) ($b['street_number'] ?? 0));

return [$even, $odd];
}

private function colLetter(int $n): string
{
$letter = '';
while ($n > 0) {
$n--;
$letter = chr(65 + ($n % 26)) . $letter;
$n = (int) ($n / 26);
}
return $letter;
}

private function slug(string $name): string
{
$slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? $name);
return trim($slug, '_') ?: 'territory';
}
}

+ 54
- 0
app/Views/export/generate.php 파일 보기

@@ -0,0 +1,54 @@
<div class="page-header">
<h1>Export Territories</h1>
<a class="button button-secondary button-sm" href="/territories">← Territories</a>
</div>

<div class="section-panel">
<div class="panel-header">
<h2>Select Territories to Export</h2>
<p>Each selected territory will be exported as an XLSX workbook (one sheet per street, split by even/odd house numbers) and a PDF. All files are packaged in a ZIP archive.</p>
</div>

<?php if (empty($territories)): ?>
<div class="empty-state">
<p>No territories found. <a href="/territories/new">Create a territory</a> first.</p>
</div>
<?php else: ?>
<form method="POST" action="/export">
<?= csrf_field() ?>

<div style="margin-bottom:1rem">
<button type="button" class="button button-secondary button-sm"
onclick="document.querySelectorAll('.territory-check').forEach(cb => cb.checked = true)">
Select All
</button>
<button type="button" class="button button-secondary button-sm"
onclick="document.querySelectorAll('.territory-check').forEach(cb => cb.checked = false)">
Deselect All
</button>
</div>

<div class="territory-checklist">
<?php foreach ($territories as $t): ?>
<label class="checkbox-label territory-item">
<input type="checkbox" class="territory-check"
name="territory_ids[]"
value="<?= e($t['id']) ?>">
<?= e($t['name']) ?>
<?php if ($t['description']): ?>
<span class="text-secondary" style="font-size:0.85rem">
— <?= e((string) ($t['description'] ?? '')) ?>
</span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>

<div class="form-actions" style="margin-top:1.5rem">
<button class="button button-primary" type="submit">
Download ZIP
</button>
</div>
</form>
<?php endif; ?>
</div>

+ 64
- 0
app/Views/householder-names/create.php 파일 보기

@@ -0,0 +1,64 @@
<div class="page-header">
<h1>New Householder Name</h1>
<a class="button button-secondary button-sm" href="/householder-names">← Back</a>
</div>

<div class="section-panel">
<?php if (!empty($errors['_token'])): ?>
<div class="alert alert-error"><?= e($errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="POST" action="/householder-names" novalidate>
<?= csrf_field() ?>

<div class="form-grid">
<div class="field" style="grid-column:1/-1">
<label for="household_id">Household <span style="color:#c0392b">*</span></label>
<select class="input <?= isset($errors['household_id']) ? 'input-error' : '' ?>"
id="household_id" name="household_id" required>
<option value="">Select a household…</option>
<?php foreach ($households as $h): ?>
<option value="<?= e($h['id']) ?>"
<?= ((string) ($old['household_id'] ?? $defaultHouseholdId)) === (string) $h['id'] ? 'selected' : '' ?>>
<?= e($h['address']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['household_id'])): ?>
<span class="field-error"><?= e($errors['household_id'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label for="name">Name <span style="color:#c0392b">*</span></label>
<input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>"
type="text" id="name" name="name"
value="<?= e((string) ($old['name'] ?? '')) ?>" required autofocus>
<?php if (isset($errors['name'])): ?>
<span class="field-error"><?= e($errors['name'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label class="checkbox-label">
<input type="checkbox" name="letter_returned" value="1"
id="lr_check"
<?= !empty($old['letter_returned']) ? 'checked' : '' ?>
onchange="document.getElementById('lr_date_field').style.display=this.checked?'grid':'none'">
Letter Returned
</label>
</div>

<div id="lr_date_field" class="field" style="grid-column:1/-1;display:<?= !empty($old['letter_returned']) ? 'grid' : 'none' ?>">
<label for="return_date">Return Date</label>
<input class="input" type="datetime-local" id="return_date" name="return_date"
value="<?= e((string) ($old['return_date'] ?? '')) ?>">
</div>
</div>

<div class="form-actions" style="margin-top:1.5rem">
<button class="button button-primary" type="submit">Create</button>
<a class="button button-secondary" href="/householder-names">Cancel</a>
</div>
</form>
</div>

+ 65
- 0
app/Views/householder-names/edit.php 파일 보기

@@ -0,0 +1,65 @@
<div class="page-header">
<h1>Edit Householder Name</h1>
<a class="button button-secondary button-sm"
href="/householder-names/<?= e($name['id']) ?>">← Back</a>
</div>

<div class="section-panel">
<?php if (!empty($errors['_token'])): ?>
<div class="alert alert-error"><?= e($errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="POST" action="/householder-names/<?= e($name['id']) ?>" novalidate>
<?= csrf_field() ?>

<div class="form-grid">
<div class="field" style="grid-column:1/-1">
<label for="household_id">Household <span style="color:#c0392b">*</span></label>
<select class="input <?= isset($errors['household_id']) ? 'input-error' : '' ?>"
id="household_id" name="household_id" required>
<option value="">Select a household…</option>
<?php foreach ($households as $h): ?>
<option value="<?= e($h['id']) ?>"
<?= (string) $name['household_id'] === (string) $h['id'] ? 'selected' : '' ?>>
<?= e($h['address']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['household_id'])): ?>
<span class="field-error"><?= e($errors['household_id'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label for="name">Name <span style="color:#c0392b">*</span></label>
<input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>"
type="text" id="name" name="name"
value="<?= e((string) ($name['name'] ?? '')) ?>" required autofocus>
<?php if (isset($errors['name'])): ?>
<span class="field-error"><?= e($errors['name'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label class="checkbox-label">
<input type="checkbox" name="letter_returned" value="1"
id="lr_check"
<?= $name['letter_returned'] ? 'checked' : '' ?>
onchange="document.getElementById('lr_date_field').style.display=this.checked?'grid':'none'">
Letter Returned
</label>
</div>

<div id="lr_date_field" class="field" style="grid-column:1/-1;display:<?= $name['letter_returned'] ? 'grid' : 'none' ?>">
<label for="return_date">Return Date</label>
<input class="input" type="datetime-local" id="return_date" name="return_date"
value="<?= e((string) ($name['return_date'] ?? '')) ?>">
</div>
</div>

<div class="form-actions" style="margin-top:1.5rem">
<button class="button button-primary" type="submit">Save Changes</button>
<a class="button button-secondary" href="/householder-names/<?= e($name['id']) ?>">Cancel</a>
</div>
</form>
</div>

+ 97
- 0
app/Views/householder-names/index.php 파일 보기

@@ -0,0 +1,97 @@
<div class="page-header">
<h1>Householder Names</h1>
<a class="button button-primary button-sm" href="/householder-names/new">+ New Name</a>
</div>

<div class="section-panel">
<form class="filter-bar" method="GET" action="/householder-names">
<div class="field" style="flex:1;min-width:200px">
<label for="search">Search name</label>
<input class="input" type="search" id="search" name="search"
value="<?= e($search) ?>" placeholder="Name…">
</div>
<div class="form-actions" style="align-self:flex-end">
<button class="button button-primary button-sm" type="submit">Filter</button>
<?php if ($search !== ''): ?>
<a class="button button-secondary button-sm" href="/householder-names">Clear</a>
<?php endif; ?>
</div>
</form>

<?php if (empty($names)): ?>
<div class="empty-state">
<p>No names found.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Household</th>
<th>Territory</th>
<th>Letter Returned</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($names as $n): ?>
<tr>
<td>
<a href="/householder-names/<?= e($n['id']) ?>">
<?= e($n['name']) ?>
</a>
</td>
<td>
<a href="/households/<?= e($n['household_id']) ?>">
<?= e($n['household_address']) ?>
</a>
</td>
<td class="text-secondary"><?= e($n['territory_name']) ?></td>
<td>
<?php if ($n['letter_returned']): ?>
<span class="badge badge-success">Returned</span>
<?php else: ?>
<span class="text-secondary">—</span>
<?php endif; ?>
</td>
<td class="text-secondary" style="font-size:0.85rem">
<?= e((string) ($n['created_at'] ?? '')) ?>
</td>
<td class="table-actions">
<a class="button button-secondary button-sm"
href="/householder-names/<?= e($n['id']) ?>">View</a>
<a class="button button-secondary button-sm"
href="/householder-names/<?= e($n['id']) ?>/edit">Edit</a>
<form method="POST"
action="/householder-names/<?= e($n['id']) ?>/delete"
class="inline-form"
onsubmit="return confirm('Delete this name?')">
<?= csrf_field() ?>
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>

<?php if ($pagination->hasPages()): ?>
<div class="pagination">
<?php if ($pagination->previousPage()): ?>
<a class="page-link" href="<?= e(paginate_url($pagination->previousPage())) ?>">&lsaquo; Prev</a>
<?php endif; ?>
<?php foreach ($pagination->pageRange() as $p): ?>
<a class="page-link <?= $p === $pagination->page ? 'is-active' : '' ?>"
href="<?= e(paginate_url($p)) ?>"><?= $p ?></a>
<?php endforeach; ?>
<?php if ($pagination->nextPage()): ?>
<a class="page-link" href="<?= e(paginate_url($pagination->nextPage())) ?>">Next &rsaquo;</a>
<?php endif; ?>
<span class="pagination-meta"><?= number_format($pagination->total) ?> total</span>
</div>
<?php endif; ?>
<?php endif; ?>
</div>

+ 59
- 0
app/Views/householder-names/show.php 파일 보기

@@ -0,0 +1,59 @@
<div class="page-header">
<h1><?= e($name['name']) ?></h1>
<div class="page-header-actions">
<a class="button button-secondary button-sm"
href="/householder-names/<?= e($name['id']) ?>/edit">Edit</a>
<form method="POST" action="/householder-names/<?= e($name['id']) ?>/delete"
class="inline-form"
onsubmit="return confirm('Delete this name?')">
<?= csrf_field() ?>
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
<a class="button button-secondary button-sm"
href="/households/<?= e($name['household_id']) ?>">← Household</a>
</div>
</div>

<div class="section-panel">
<dl class="detail-list">
<dt>Household</dt>
<dd>
<a href="/households/<?= e($name['household_id']) ?>">
<?= e($name['household_address']) ?>
</a>
</dd>

<dt>Territory</dt>
<dd>
<a href="/territories/<?= e($name['territory_id']) ?>">
<?= e($name['territory_name']) ?>
</a>
</dd>

<dt>Letter Returned</dt>
<dd>
<?php if ($name['letter_returned']): ?>
<span class="badge badge-success">Yes</span>
<?php if ($name['return_date']): ?>
<span class="text-secondary">(<?= e($name['return_date']) ?>)</span>
<?php endif; ?>
<?php else: ?>
No
<?php endif; ?>
</dd>

<dt>Added</dt>
<dd class="text-secondary"><?= e((string) ($name['created_at'] ?? '')) ?></dd>
</dl>

<div class="form-actions" style="margin-top:1.25rem">
<form method="POST"
action="/householder-names/<?= e($name['id']) ?>/mark-returned"
class="inline-form">
<?= csrf_field() ?>
<button class="button button-secondary" type="submit">
<?= $name['letter_returned'] ? 'Unmark as Returned' : 'Mark Letter Returned' ?>
</button>
</form>
</div>
</div>

+ 123
- 0
app/Views/households/create.php 파일 보기

@@ -0,0 +1,123 @@
<div class="page-header">
<h1>New Household</h1>
<a class="button button-secondary button-sm" href="/households">← Back</a>
</div>

<div class="section-panel">
<?php if (!empty($errors['_token'])): ?>
<div class="alert alert-error"><?= e($errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="POST" action="/households" novalidate>
<?= csrf_field() ?>

<div class="form-grid">
<div class="field" style="grid-column:1/-1">
<label for="territory_id">Territory <span style="color:#c0392b">*</span></label>
<select class="input <?= isset($errors['territory_id']) ? 'input-error' : '' ?>"
id="territory_id" name="territory_id" required>
<option value="">Select a territory…</option>
<?php foreach ($territories as $t): ?>
<option value="<?= e($t['id']) ?>"
<?= ((string) ($old['territory_id'] ?? $defaultTerritoryId)) === (string) $t['id'] ? 'selected' : '' ?>>
<?= e($t['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['territory_id'])): ?>
<span class="field-error"><?= e($errors['territory_id'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label for="address">Address <span style="color:#c0392b">*</span></label>
<input class="input <?= isset($errors['address']) ? 'input-error' : '' ?>"
type="text" id="address" name="address"
value="<?= e((string) ($old['address'] ?? '')) ?>" required autofocus>
<?php if (isset($errors['address'])): ?>
<span class="field-error"><?= e($errors['address'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field">
<label for="street_number">Street Number</label>
<input class="input <?= isset($errors['street_number']) ? 'input-error' : '' ?>"
type="number" id="street_number" name="street_number"
value="<?= e((string) ($old['street_number'] ?? '')) ?>">
<?php if (isset($errors['street_number'])): ?>
<span class="field-error"><?= e($errors['street_number'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field">
<label for="street_name">Street Name</label>
<input class="input" type="text" id="street_name" name="street_name"
value="<?= e((string) ($old['street_name'] ?? '')) ?>">
</div>

<div class="field">
<label for="latitude">Latitude</label>
<input class="input <?= isset($errors['latitude']) ? 'input-error' : '' ?>"
type="text" id="latitude" name="latitude"
value="<?= e((string) ($old['latitude'] ?? '')) ?>"
placeholder="e.g. 40.7128">
<?php if (isset($errors['latitude'])): ?>
<span class="field-error"><?= e($errors['latitude'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field">
<label for="longitude">Longitude</label>
<input class="input <?= isset($errors['longitude']) ? 'input-error' : '' ?>"
type="text" id="longitude" name="longitude"
value="<?= e((string) ($old['longitude'] ?? '')) ?>"
placeholder="e.g. -74.0060">
<?php if (isset($errors['longitude'])): ?>
<span class="field-error"><?= e($errors['longitude'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label class="checkbox-label">
<input type="checkbox" name="is_business" value="1"
<?= !empty($old['is_business']) ? 'checked' : '' ?>>
This is a business
</label>
</div>

<div class="field" style="grid-column:1/-1">
<label class="checkbox-label">
<input type="checkbox" name="do_not_call" value="1" id="dnc_check"
<?= !empty($old['do_not_call']) ? 'checked' : '' ?>
onchange="document.getElementById('dnc_fields').style.display=this.checked?'grid':'none'">
Do Not Call
</label>
</div>

<div id="dnc_fields" class="form-grid" style="grid-column:1/-1;display:<?= !empty($old['do_not_call']) ? 'grid' : 'none' ?>">
<div class="field">
<label for="do_not_call_date">DNC Date</label>
<input class="input" type="date" id="do_not_call_date" name="do_not_call_date"
value="<?= e((string) ($old['do_not_call_date'] ?? '')) ?>">
</div>

<div class="field" style="grid-column:1/-1">
<label for="do_not_call_notes">Public Notes</label>
<textarea class="input" id="do_not_call_notes" name="do_not_call_notes"
rows="2"><?= e((string) ($old['do_not_call_notes'] ?? '')) ?></textarea>
</div>

<div class="field" style="grid-column:1/-1">
<label for="do_not_call_private_notes">Private Notes</label>
<textarea class="input" id="do_not_call_private_notes" name="do_not_call_private_notes"
rows="2"><?= e((string) ($old['do_not_call_private_notes'] ?? '')) ?></textarea>
</div>
</div>
</div>

<div class="form-actions" style="margin-top:1.5rem">
<button class="button button-primary" type="submit">Create Household</button>
<a class="button button-secondary" href="/households">Cancel</a>
</div>
</form>
</div>

+ 109
- 0
app/Views/households/edit.php 파일 보기

@@ -0,0 +1,109 @@
<div class="page-header">
<h1>Edit Household</h1>
<a class="button button-secondary button-sm" href="/households/<?= e($household['id']) ?>">← Back</a>
</div>

<div class="section-panel">
<?php if (!empty($errors['_token'])): ?>
<div class="alert alert-error"><?= e($errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="POST" action="/households/<?= e($household['id']) ?>" novalidate>
<?= csrf_field() ?>

<div class="form-grid">
<div class="field" style="grid-column:1/-1">
<label for="territory_id">Territory <span style="color:#c0392b">*</span></label>
<select class="input <?= isset($errors['territory_id']) ? 'input-error' : '' ?>"
id="territory_id" name="territory_id" required>
<option value="">Select a territory…</option>
<?php foreach ($territories as $t): ?>
<option value="<?= e($t['id']) ?>"
<?= (string) $household['territory_id'] === (string) $t['id'] ? 'selected' : '' ?>>
<?= e($t['name']) ?>
</option>
<?php endforeach; ?>
</select>
<?php if (isset($errors['territory_id'])): ?>
<span class="field-error"><?= e($errors['territory_id'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label for="address">Address <span style="color:#c0392b">*</span></label>
<input class="input <?= isset($errors['address']) ? 'input-error' : '' ?>"
type="text" id="address" name="address"
value="<?= e((string) ($household['address'] ?? '')) ?>" required autofocus>
<?php if (isset($errors['address'])): ?>
<span class="field-error"><?= e($errors['address'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field">
<label for="street_number">Street Number</label>
<input class="input" type="number" id="street_number" name="street_number"
value="<?= e((string) ($household['street_number'] ?? '')) ?>">
</div>

<div class="field">
<label for="street_name">Street Name</label>
<input class="input" type="text" id="street_name" name="street_name"
value="<?= e((string) ($household['street_name'] ?? '')) ?>">
</div>

<div class="field">
<label for="latitude">Latitude</label>
<input class="input" type="text" id="latitude" name="latitude"
value="<?= e((string) ($household['latitude'] ?? '')) ?>">
</div>

<div class="field">
<label for="longitude">Longitude</label>
<input class="input" type="text" id="longitude" name="longitude"
value="<?= e((string) ($household['longitude'] ?? '')) ?>">
</div>

<div class="field" style="grid-column:1/-1">
<label class="checkbox-label">
<input type="checkbox" name="is_business" value="1"
<?= $household['is_business'] ? 'checked' : '' ?>>
This is a business
</label>
</div>

<div class="field" style="grid-column:1/-1">
<label class="checkbox-label">
<input type="checkbox" name="do_not_call" value="1" id="dnc_check"
<?= $household['do_not_call'] ? 'checked' : '' ?>
onchange="document.getElementById('dnc_fields').style.display=this.checked?'grid':'none'">
Do Not Call
</label>
</div>

<div id="dnc_fields" class="form-grid" style="grid-column:1/-1;display:<?= $household['do_not_call'] ? 'grid' : 'none' ?>">
<div class="field">
<label for="do_not_call_date">DNC Date</label>
<input class="input" type="date" id="do_not_call_date" name="do_not_call_date"
value="<?= e((string) ($household['do_not_call_date'] ?? '')) ?>">
</div>

<div class="field" style="grid-column:1/-1">
<label for="do_not_call_notes">Public Notes</label>
<textarea class="input" id="do_not_call_notes" name="do_not_call_notes"
rows="2"><?= e((string) ($household['do_not_call_notes'] ?? '')) ?></textarea>
</div>

<div class="field" style="grid-column:1/-1">
<label for="do_not_call_private_notes">Private Notes</label>
<textarea class="input" id="do_not_call_private_notes" name="do_not_call_private_notes"
rows="2"><?= e((string) ($household['do_not_call_private_notes'] ?? '')) ?></textarea>
</div>
</div>
</div>

<div class="form-actions" style="margin-top:1.5rem">
<button class="button button-primary" type="submit">Save Changes</button>
<a class="button button-secondary" href="/households/<?= e($household['id']) ?>">Cancel</a>
</div>
</form>
</div>

+ 124
- 0
app/Views/households/index.php 파일 보기

@@ -0,0 +1,124 @@
<div class="page-header">
<h1>Households</h1>
<a class="button button-primary button-sm" href="/households/new">+ New Household</a>
</div>

<div class="section-panel">
<form class="filter-bar" method="GET" action="/households">
<div class="field" style="flex:2;min-width:200px">
<label for="search">Search</label>
<input class="input" type="search" id="search" name="search"
value="<?= e($search) ?>" placeholder="Address or street name…">
</div>

<div class="field" style="flex:1;min-width:160px">
<label for="territory_id">Territory</label>
<select class="input" id="territory_id" name="territory_id">
<option value="">All territories</option>
<?php foreach ($territories as $t): ?>
<option value="<?= e($t['id']) ?>"
<?= $territoryId === (string) $t['id'] ? 'selected' : '' ?>>
<?= e($t['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>

<div class="field" style="flex:1;min-width:140px">
<label for="do_not_call">Do Not Call</label>
<select class="input" id="do_not_call" name="do_not_call">
<option value="">All</option>
<option value="1" <?= $doNotCall === '1' ? 'selected' : '' ?>>DNC only</option>
<option value="0" <?= $doNotCall === '0' ? 'selected' : '' ?>>Non-DNC only</option>
</select>
</div>

<div class="form-actions" style="align-self:flex-end">
<button class="button button-primary button-sm" type="submit">Filter</button>
<?php if ($search !== '' || $territoryId !== '' || $doNotCall !== ''): ?>
<a class="button button-secondary button-sm" href="/households">Clear</a>
<?php endif; ?>
</div>
</form>

<?php if (empty($households)): ?>
<div class="empty-state">
<p>No households found.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Address</th>
<th>Street</th>
<th>Territory</th>
<th>Type</th>
<th>DNC</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($households as $h): ?>
<tr>
<td>
<a href="/households/<?= e($h['id']) ?>">
<?= e($h['address']) ?>
</a>
</td>
<td class="text-secondary"><?= e((string) ($h['street_name'] ?? '')) ?></td>
<td>
<a href="/territories/<?= e($h['territory_id']) ?>">
<?= e($h['territory_name']) ?>
</a>
</td>
<td>
<?php if ($h['is_business']): ?>
<span class="badge badge-warning">Business</span>
<?php endif; ?>
</td>
<td>
<?php if ($h['do_not_call']): ?>
<span class="badge badge-danger">DNC</span>
<?php if ($h['do_not_call_date']): ?>
<span class="text-secondary" style="font-size:0.8rem">
<?= e($h['do_not_call_date']) ?>
</span>
<?php endif; ?>
<?php endif; ?>
</td>
<td class="table-actions">
<a class="button button-secondary button-sm"
href="/households/<?= e($h['id']) ?>">View</a>
<a class="button button-secondary button-sm"
href="/households/<?= e($h['id']) ?>/edit">Edit</a>
<form method="POST" action="/households/<?= e($h['id']) ?>/delete"
class="inline-form"
onsubmit="return confirm('Delete this household?')">
<?= csrf_field() ?>
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>

<?php if ($pagination->hasPages()): ?>
<div class="pagination">
<?php if ($pagination->previousPage()): ?>
<a class="page-link" href="<?= e(paginate_url($pagination->previousPage())) ?>">&lsaquo; Prev</a>
<?php endif; ?>
<?php foreach ($pagination->pageRange() as $p): ?>
<a class="page-link <?= $p === $pagination->page ? 'is-active' : '' ?>"
href="<?= e(paginate_url($p)) ?>"><?= $p ?></a>
<?php endforeach; ?>
<?php if ($pagination->nextPage()): ?>
<a class="page-link" href="<?= e(paginate_url($pagination->nextPage())) ?>">Next &rsaquo;</a>
<?php endif; ?>
<span class="pagination-meta"><?= number_format($pagination->total) ?> total</span>
</div>
<?php endif; ?>
<?php endif; ?>
</div>

+ 154
- 0
app/Views/households/show.php 파일 보기

@@ -0,0 +1,154 @@
<div class="page-header">
<h1><?= e($household['address']) ?></h1>
<div class="page-header-actions">
<a class="button button-secondary button-sm"
href="/households/<?= e($household['id']) ?>/edit">Edit</a>
<form method="POST" action="/households/<?= e($household['id']) ?>/delete"
class="inline-form"
onsubmit="return confirm('Delete this household?')">
<?= csrf_field() ?>
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
<a class="button button-secondary button-sm" href="/households">← Back</a>
</div>
</div>

<div class="content-stack">
<div class="section-panel">
<div class="panel-header"><h2>Details</h2></div>
<dl class="detail-list">
<dt>Territory</dt>
<dd>
<a href="/territories/<?= e($household['territory_id']) ?>">
<?= e($household['territory_name']) ?>
</a>
</dd>

<?php if ($household['street_number'] || $household['street_name']): ?>
<dt>Street</dt>
<dd>
<?= e((string) ($household['street_number'] ?? '')) ?>
<?= e((string) ($household['street_name'] ?? '')) ?>
</dd>
<?php endif; ?>

<dt>Type</dt>
<dd>
<?php if ($household['is_business']): ?>
<span class="badge badge-warning">Business</span>
<?php else: ?>
Residential
<?php endif; ?>
</dd>

<dt>Do Not Call</dt>
<dd>
<?php if ($household['do_not_call']): ?>
<span class="badge badge-danger">Yes</span>
<?php if ($household['do_not_call_date']): ?>
<span class="text-secondary">(since <?= e($household['do_not_call_date']) ?>)</span>
<?php endif; ?>
<?php if ($household['do_not_call_notes']): ?>
<p style="margin:0.5rem 0 0"><?= e($household['do_not_call_notes']) ?></p>
<?php endif; ?>
<?php else: ?>
No
<?php endif; ?>
</dd>

<?php if ($household['latitude'] && $household['longitude']): ?>
<dt>Coordinates</dt>
<dd><?= e((string) $household['latitude']) ?>, <?= e((string) $household['longitude']) ?></dd>
<?php endif; ?>
</dl>
</div>

<?php if ($household['latitude'] && $household['longitude'] && $maps['maptiler_api_key'] !== ''): ?>
<div class="section-panel">
<div class="panel-header"><h2>Map</h2></div>
<link rel="stylesheet" href="<?= e($maps['maptiler_sdk_css']) ?>">
<div id="household-map" style="height:380px;border-radius:1rem;overflow:hidden"></div>
<script src="<?= e($maps['maptiler_sdk_js']) ?>"></script>
<script>
maptilersdk.config.apiKey = <?= json_encode($maps['maptiler_api_key']) ?>;
const map = new maptilersdk.Map({
container: 'household-map',
style: <?= json_encode($maps['maptiler_style']) ?>,
center: [<?= (float) $household['longitude'] ?>, <?= (float) $household['latitude'] ?>],
zoom: 15
});
new maptilersdk.Marker()
.setLngLat([<?= (float) $household['longitude'] ?>, <?= (float) $household['latitude'] ?>])
.addTo(map);
</script>
</div>
<?php endif; ?>

<div class="section-panel">
<div class="panel-header" style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0">Householder Names (<?= count($names) ?>)</h2>
<a class="button button-primary button-sm"
href="/householder-names/new?household_id=<?= e($household['id']) ?>">+ Add Name</a>
</div>

<?php if (empty($names)): ?>
<div class="empty-state">
<p>No names recorded for this household.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Added</th>
<th>Letter Returned</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($names as $n): ?>
<tr>
<td><?= e($n['name']) ?></td>
<td class="text-secondary">
<?= e((string) ($n['created_at'] ?? '')) ?>
</td>
<td>
<?php if ($n['letter_returned']): ?>
<span class="badge badge-success">Returned</span>
<?php if ($n['return_date']): ?>
<span class="text-secondary" style="font-size:0.8rem">
<?= e($n['return_date']) ?>
</span>
<?php endif; ?>
<?php else: ?>
<span class="text-secondary">—</span>
<?php endif; ?>
</td>
<td class="table-actions">
<form method="POST"
action="/householder-names/<?= e($n['id']) ?>/mark-returned"
class="inline-form">
<?= csrf_field() ?>
<button class="button button-secondary button-sm" type="submit">
<?= $n['letter_returned'] ? 'Unmark' : 'Mark Returned' ?>
</button>
</form>
<a class="button button-secondary button-sm"
href="/householder-names/<?= e($n['id']) ?>/edit">Edit</a>
<form method="POST"
action="/householder-names/<?= e($n['id']) ?>/delete"
class="inline-form"
onsubmit="return confirm('Delete this name?')">
<?= csrf_field() ?>
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>

+ 9
- 0
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');
?>

<main class="page-content">
<div class="container">
<?php if ($flashSuccess): ?>
<div class="alert alert-success" role="alert"><?= e($flashSuccess) ?></div>
<?php endif; ?>
<?php if ($flashError): ?>
<div class="alert alert-error" role="alert"><?= e($flashError) ?></div>
<?php endif; ?>
<?= $content ?>
</div>
</main>


+ 5
- 2
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);


+ 44
- 0
app/Views/territories/create.php 파일 보기

@@ -0,0 +1,44 @@
<div class="page-header">
<h1>New Territory</h1>
<a class="button button-secondary button-sm" href="/territories">← Back</a>
</div>

<div class="section-panel">
<?php if (!empty($errors['_token'])): ?>
<div class="alert alert-error"><?= e($errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="POST" action="/territories" novalidate>
<?= csrf_field() ?>

<div class="form-grid">
<div class="field" style="grid-column:1/-1">
<label for="name">Name <span style="color:#c0392b">*</span></label>
<input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>"
type="text" id="name" name="name"
value="<?= e((string) ($old['name'] ?? '')) ?>" required autofocus>
<?php if (isset($errors['name'])): ?>
<span class="field-error"><?= e($errors['name'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label for="description">Description</label>
<textarea class="input" id="description" name="description"
rows="3"><?= e((string) ($old['description'] ?? '')) ?></textarea>
</div>

<div class="field" style="grid-column:1/-1">
<label for="coordinates">Coordinates</label>
<textarea class="input" id="coordinates" name="coordinates"
rows="3"
placeholder="Optional map boundary coordinates"><?= e((string) ($old['coordinates'] ?? '')) ?></textarea>
</div>
</div>

<div class="form-actions" style="margin-top:1.5rem">
<button class="button button-primary" type="submit">Create Territory</button>
<a class="button button-secondary" href="/territories">Cancel</a>
</div>
</form>
</div>

+ 43
- 0
app/Views/territories/edit.php 파일 보기

@@ -0,0 +1,43 @@
<div class="page-header">
<h1>Edit Territory</h1>
<a class="button button-secondary button-sm" href="/territories/<?= e($territory['id']) ?>">← Back</a>
</div>

<div class="section-panel">
<?php if (!empty($errors['_token'])): ?>
<div class="alert alert-error"><?= e($errors['_token'][0]) ?></div>
<?php endif; ?>

<form method="POST" action="/territories/<?= e($territory['id']) ?>" novalidate>
<?= csrf_field() ?>

<div class="form-grid">
<div class="field" style="grid-column:1/-1">
<label for="name">Name <span style="color:#c0392b">*</span></label>
<input class="input <?= isset($errors['name']) ? 'input-error' : '' ?>"
type="text" id="name" name="name"
value="<?= e((string) ($territory['name'] ?? '')) ?>" required autofocus>
<?php if (isset($errors['name'])): ?>
<span class="field-error"><?= e($errors['name'][0]) ?></span>
<?php endif; ?>
</div>

<div class="field" style="grid-column:1/-1">
<label for="description">Description</label>
<textarea class="input" id="description" name="description"
rows="3"><?= e((string) ($territory['description'] ?? '')) ?></textarea>
</div>

<div class="field" style="grid-column:1/-1">
<label for="coordinates">Coordinates</label>
<textarea class="input" id="coordinates" name="coordinates"
rows="3"><?= e((string) ($territory['coordinates'] ?? '')) ?></textarea>
</div>
</div>

<div class="form-actions" style="margin-top:1.5rem">
<button class="button button-primary" type="submit">Save Changes</button>
<a class="button button-secondary" href="/territories/<?= e($territory['id']) ?>">Cancel</a>
</div>
</form>
</div>

+ 91
- 0
app/Views/territories/index.php 파일 보기

@@ -0,0 +1,91 @@
<div class="page-header">
<h1>Territories</h1>
<div class="page-header-actions">
<a class="button button-primary button-sm" href="/territories/new">+ New Territory</a>
<a class="button button-secondary button-sm" href="/export">Export</a>
</div>
</div>

<div class="section-panel">
<form class="filter-bar" method="GET" action="/territories">
<div class="field" style="flex:1;min-width:220px">
<label for="search">Search</label>
<input class="input" type="search" id="search" name="search"
value="<?= e($search) ?>" placeholder="Name or description…">
</div>
<div class="form-actions" style="align-self:flex-end">
<button class="button button-primary button-sm" type="submit">Filter</button>
<?php if ($search !== ''): ?>
<a class="button button-secondary button-sm" href="/territories">Clear</a>
<?php endif; ?>
</div>
</form>

<?php if (empty($territories)): ?>
<div class="empty-state">
<p>No territories found<?= $search !== '' ? ' matching "' . e($search) . '"' : '' ?>.</p>
<?php if ($search === ''): ?>
<p><a href="/territories/new">Create the first territory</a>.</p>
<?php endif; ?>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Households</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($territories as $territory): ?>
<tr>
<td>
<a href="/territories/<?= e($territory['id']) ?>">
<?= e($territory['name']) ?>
</a>
</td>
<td class="text-secondary"><?= e((string) ($territory['description'] ?? '')) ?></td>
<td><?= (int) ($counts[(int) $territory['id']] ?? 0) ?></td>
<td class="table-actions">
<a class="button button-secondary button-sm"
href="/territories/<?= e($territory['id']) ?>">View</a>
<a class="button button-secondary button-sm"
href="/territories/<?= e($territory['id']) ?>/edit">Edit</a>
<form method="POST" action="/territories/<?= e($territory['id']) ?>/delete"
class="inline-form"
onsubmit="return confirm('Delete territory \'<?= e(addslashes($territory['name'])) ?>\'?')">
<?= csrf_field() ?>
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>

<?php if ($pagination->hasPages()): ?>
<div class="pagination">
<?php if ($pagination->previousPage()): ?>
<a class="page-link" href="<?= e(paginate_url($pagination->previousPage())) ?>">&lsaquo; Prev</a>
<?php endif; ?>

<?php foreach ($pagination->pageRange() as $p): ?>
<a class="page-link <?= $p === $pagination->page ? 'is-active' : '' ?>"
href="<?= e(paginate_url($p)) ?>"><?= $p ?></a>
<?php endforeach; ?>

<?php if ($pagination->nextPage()): ?>
<a class="page-link" href="<?= e(paginate_url($pagination->nextPage())) ?>">Next &rsaquo;</a>
<?php endif; ?>

<span class="pagination-meta">
<?= number_format($pagination->total) ?> total
</span>
</div>
<?php endif; ?>
<?php endif; ?>
</div>

+ 95
- 0
app/Views/territories/show.php 파일 보기

@@ -0,0 +1,95 @@
<div class="page-header">
<h1><?= e($territory['name']) ?></h1>
<div class="page-header-actions">
<a class="button button-secondary button-sm" href="/territories/<?= e($territory['id']) ?>/edit">Edit</a>
<form method="POST" action="/territories/<?= e($territory['id']) ?>/delete"
class="inline-form"
onsubmit="return confirm('Delete this territory?')">
<?= csrf_field() ?>
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
<a class="button button-secondary button-sm" href="/territories">← Back</a>
</div>
</div>

<?php if ($territory['description']): ?>
<div class="section-panel" style="margin-bottom:1.25rem">
<p style="margin:0"><?= e($territory['description']) ?></p>
</div>
<?php endif; ?>

<div class="content-stack">
<div class="section-panel">
<div class="panel-header">
<h2>Streets</h2>
</div>

<?php if (empty($streets)): ?>
<div class="empty-state">
<p>No streets recorded yet. <a href="/households/new?territory_id=<?= e($territory['id']) ?>">Add a household</a> to this territory.</p>
</div>
<?php else: ?>
<div class="street-chips">
<?php foreach ($streets as $street): ?>
<span class="badge badge-success"><?= e($street) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>

<div class="section-panel">
<div class="panel-header" style="display:flex;justify-content:space-between;align-items:center">
<h2 style="margin:0">Households (<?= count($households) ?>)</h2>
<a class="button button-primary button-sm"
href="/households/new?territory_id=<?= e($territory['id']) ?>">+ Add Household</a>
</div>

<?php if (empty($households)): ?>
<div class="empty-state">
<p>No households in this territory yet.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>Address</th>
<th>Street</th>
<th>Business</th>
<th>DNC</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($households as $h): ?>
<tr>
<td class="text-secondary"><?= e((string) ($h['street_number'] ?? '')) ?></td>
<td>
<a href="/households/<?= e($h['id']) ?>"><?= e($h['address']) ?></a>
</td>
<td class="text-secondary"><?= e((string) ($h['street_name'] ?? '')) ?></td>
<td>
<?php if ($h['is_business']): ?>
<span class="badge badge-warning">Business</span>
<?php endif; ?>
</td>
<td>
<?php if ($h['do_not_call']): ?>
<span class="badge badge-danger">DNC</span>
<?php endif; ?>
</td>
<td class="table-actions">
<a class="button button-secondary button-sm"
href="/households/<?= e($h['id']) ?>">View</a>
<a class="button button-secondary button-sm"
href="/households/<?= e($h['id']) ?>/edit">Edit</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>

+ 3
- 1
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"
}
}

+ 816
- 1
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",


+ 11
- 0
config/maps.php 파일 보기

@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

return [
'provider' => $_ENV['MAP_PROVIDER'] ?? 'maptiler',
'maptiler_api_key' => $_ENV['MAPTILER_API_KEY'] ?? '',
'maptiler_style' => $_ENV['MAPTILER_STYLE'] ?? 'streets-v2',
'maptiler_sdk_js' => 'https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.umd.min.js',
'maptiler_sdk_css' => 'https://cdn.maptiler.com/maptiler-sdk-js/latest/maptiler-sdk.css',
];

+ 19
- 0
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),
]);
}
}

+ 44
- 0
core/Pagination.php 파일 보기

@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Core;

class Pagination
{
public readonly int $totalPages;
public readonly int $offset;

public function __construct(
public readonly int $total,
public readonly int $page,
public readonly int $perPage
) {
$this->totalPages = $perPage > 0 ? (int) ceil($total / $perPage) : 1;
$this->offset = ($page - 1) * $perPage;
}

public function hasPages(): bool
{
return $this->totalPages > 1;
}

public function previousPage(): ?int
{
return $this->page > 1 ? $this->page - 1 : null;
}

public function nextPage(): ?int
{
return $this->page < $this->totalPages ? $this->page + 1 : null;
}

/** @return list<int> */
public function pageRange(): array
{
$start = max(1, $this->page - 2);
$end = min($this->totalPages, $this->page + 2);

return range($start, $end);
}
}

+ 9
- 0
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);


+ 30
- 0
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;
}

+ 354
- 0
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())
}

+ 358
- 0
database/migrate_access_to_sqlite.php 파일 보기

@@ -0,0 +1,358 @@
<?php

declare(strict_types=1);

/**
* Migrates territory data from database/myAccessFile.accdb into database/app.sqlite.
*
* Platform support
* Windows — PDO ODBC via the Microsoft Access Database Engine driver.
* Requires: pdo_odbc extension + Microsoft Access Database Engine
* Linux/macOS — mdbtools (`mdb-export` CLI).
* Requires: sudo apt install mdbtools / brew install mdbtools
*
* Usage:
* php database/migrate_access_to_sqlite.php # auto-detect driver
* php database/migrate_access_to_sqlite.php --dry-run # show counts, no writes
* php database/migrate_access_to_sqlite.php --driver=odbc
* php database/migrate_access_to_sqlite.php --driver=mdbtools
*/

// ── CLI args ───────────────────────────────────────────────────────────────
$args = $argv ?? [];
$dryRun = in_array('--dry-run', $args, true);

$driverFlag = null;
foreach ($args as $arg) {
if (str_starts_with($arg, '--driver=')) {
$driverFlag = substr($arg, strlen('--driver='));
}
}

// ── Paths ──────────────────────────────────────────────────────────────────
$accessPath = realpath(__DIR__ . '/myAccessFile.accdb');
$sqlitePath = realpath(__DIR__ . '/app.sqlite');

if (!$accessPath) {
fwrite(STDERR, "ERROR: myAccessFile.accdb not found in " . __DIR__ . "\n");
exit(1);
}

if (!$sqlitePath) {
fwrite(STDERR, "ERROR: app.sqlite not found — run the migrations first.\n");
exit(1);
}

// ── Access reader interface ────────────────────────────────────────────────

interface AccessReader
{
/** Total number of rows in the given Access table. */
public function count(string $table): int;

/** Yield each row as an associative array (string keys, string|null values). */
public function rows(string $table): iterable;
}

// ── ODBC reader (Windows) ──────────────────────────────────────────────────

final class OdbcAccessReader implements AccessReader
{
private PDO $pdo;

public function __construct(string $path)
{
$dsn = "odbc:Driver={Microsoft Access Driver (*.mdb, *.accdb)};Dbq={$path};";
try {
$this->pdo = new PDO($dsn, '', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
} catch (PDOException $e) {
fwrite(STDERR, "ERROR: Cannot open Access database via ODBC.\n{$e->getMessage()}\n");
fwrite(STDERR, "Ensure pdo_odbc is enabled and the Microsoft Access Database Engine is installed.\n");
exit(1);
}
}

public function count(string $table): int
{
return (int) $this->pdo->query("SELECT COUNT(*) FROM [{$table}]")->fetchColumn();
}

public function rows(string $table): iterable
{
yield from $this->pdo->query("SELECT * FROM [{$table}]");
}
}

// ── mdbtools reader (Linux / macOS) ───────────────────────────────────────

final class MdbToolsAccessReader implements AccessReader
{
public function __construct(private readonly string $path)
{
if (!$this->which('mdb-export')) {
fwrite(STDERR, "ERROR: mdb-export not found.\n");
fwrite(STDERR, "Install mdbtools: sudo apt install mdbtools (Debian/Ubuntu)\n");
fwrite(STDERR, " brew install mdbtools (macOS)\n");
exit(1);
}
}

public function count(string $table): int
{
// Row count = line count of mdb-export output minus the header line.
$cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table) . ' | wc -l';
$output = shell_exec($cmd);
return max(0, (int) trim((string) $output) - 1);
}

public function rows(string $table): iterable
{
$cmd = 'mdb-export ' . escapeshellarg($this->path) . ' ' . escapeshellarg($table);
$handle = popen($cmd, 'r');

if ($handle === false) {
throw new RuntimeException("Failed to run mdb-export for table '{$table}'.");
}

$headers = null;

while (!feof($handle)) {
$line = fgets($handle);
if ($line === false) {
break;
}
$line = rtrim($line, "\r\n");
if ($line === '') {
continue;
}

$cols = str_getcsv($line);

if ($headers === null) {
$headers = $cols;
continue;
}

// Pad short rows (trailing empty columns may be omitted by mdbtools).
while (count($cols) < count($headers)) {
$cols[] = '';
}

yield array_combine($headers, $cols);
}

pclose($handle);
}

private function which(string $bin): bool
{
return !empty(shell_exec('which ' . escapeshellarg($bin) . ' 2>/dev/null'));
}
}

// ── Select driver ──────────────────────────────────────────────────────────

$driver = $driverFlag ?? (PHP_OS_FAMILY === 'Windows' ? 'odbc' : 'mdbtools');

echo "Access file: {$accessPath}\n";
echo "Driver: {$driver}\n";

$reader = match ($driver) {
'odbc' => new OdbcAccessReader($accessPath),
'mdbtools' => new MdbToolsAccessReader($accessPath),
default => throw new InvalidArgumentException("Unknown driver '{$driver}'. Use 'odbc' or 'mdbtools'."),
};

// ── SQLite connection ──────────────────────────────────────────────────────

echo "SQLite file: {$sqlitePath}\n";

$sqlite = new PDO("sqlite:{$sqlitePath}", null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$sqlite->exec('PRAGMA foreign_keys = OFF');
$sqlite->exec('PRAGMA journal_mode = WAL');
$sqlite->exec('PRAGMA synchronous = NORMAL');
$sqlite->exec('PRAGMA cache_size = -16000');

if ($dryRun) {
echo "\n-- DRY RUN: no data will be written --\n";
}

$now = date('Y-m-d H:i:s');

// ── Normalisation helpers ──────────────────────────────────────────────────

function normaliseString(mixed $v): ?string
{
return ($v === null || $v === '') ? null : (string) $v;
}

function normaliseInt(mixed $v): ?int
{
return ($v === null || $v === '') ? null : (int) $v;
}

function normaliseFloat(mixed $v): ?float
{
return ($v === null || $v === '') ? null : (float) $v;
}

function normaliseDate(mixed $v, string $format = 'Y-m-d'): ?string
{
if ($v === null || $v === '') {
return null;
}
$ts = strtotime((string) $v);
return $ts !== false ? date($format, $ts) : null;
}

function normaliseDateTime(mixed $v): ?string
{
return normaliseDate($v, 'Y-m-d H:i:s');
}

// ── Migration runner ───────────────────────────────────────────────────────

function migrateTable(
AccessReader $src,
PDO $dst,
string $srcTable,
string $dstTable,
string $insertSql,
callable $mapRow,
bool $dryRun,
int $batchSize = 500
): void {
$total = $src->count($srcTable);
echo " {$srcTable} → {$dstTable}: {$total} rows";

if ($dryRun) {
echo " (skipped — dry run)\n";
return;
}

echo "\n";
$dst->exec("DELETE FROM [{$dstTable}]");

$stmt = $dst->prepare($insertSql);
$dst->beginTransaction();
$n = 0;

foreach ($src->rows($srcTable) as $row) {
$stmt->execute($mapRow($row));
$n++;

if ($n % $batchSize === 0) {
$dst->commit();
$dst->beginTransaction();
printf(" %d / %d (%.0f%%)\n", $n, $total, $total > 0 ? ($n / $total) * 100 : 0);
}
}

if ($dst->inTransaction()) {
$dst->commit();
}

printf(" Done: %d rows inserted.\n", $n);
}

// ── 1. Territories ─────────────────────────────────────────────────────────
echo "\n[1/3] Territories\n";
migrateTable(
$reader,
$sqlite,
'Territories',
'territories',
'INSERT INTO territories (id, name, description, coordinates, created_at, updated_at)
VALUES (:id, :name, :description, :coordinates, :created_at, :updated_at)',
function (array $row) use ($now): array {
return [
':id' => (int) $row['Id'],
':name' => normaliseString($row['Name']),
':description' => normaliseString($row['Description']),
':coordinates' => normaliseString($row['Coordinates']),
':created_at' => $now,
':updated_at' => $now,
];
},
$dryRun
);

// ── 2. Households ──────────────────────────────────────────────────────────
echo "\n[2/3] Households\n";
migrateTable(
$reader,
$sqlite,
'Households',
'households',
'INSERT INTO households
(id, territory_id, address, street_number, street_name,
latitude, longitude, is_business, do_not_call,
do_not_call_date, do_not_call_notes, do_not_call_private_notes,
created_at, updated_at)
VALUES
(:id, :territory_id, :address, :street_number, :street_name,
:latitude, :longitude, :is_business, :do_not_call,
:do_not_call_date, :do_not_call_notes, :do_not_call_private_notes,
:created_at, :updated_at)',
function (array $row) use ($now): array {
return [
':id' => (int) $row['Id'],
':territory_id' => (int) $row['TerritoryId'],
':address' => normaliseString($row['Address']),
':street_number' => normaliseInt($row['StreetNumber']),
':street_name' => normaliseString($row['StreetName']),
':latitude' => normaliseFloat($row['Latitude']),
':longitude' => normaliseFloat($row['Longitude']),
':is_business' => (int) ($row['IsBusiness'] ?? 0),
':do_not_call' => (int) ($row['DoNotCall'] ?? 0),
':do_not_call_date' => normaliseDate($row['DoNotCallDate']),
':do_not_call_notes' => normaliseString($row['DoNotCallNotes']),
':do_not_call_private_notes' => normaliseString($row['DoNotCallPrivateNotes']),
':created_at' => $now,
':updated_at' => $now,
];
},
$dryRun
);

// ── 3. HouseholderNames ────────────────────────────────────────────────────
echo "\n[3/3] HouseholderNames\n";
migrateTable(
$reader,
$sqlite,
'HouseholderNames',
'householder_names',
'INSERT INTO householder_names
(id, household_id, name, letter_returned, return_date, created_at, updated_at)
VALUES
(:id, :household_id, :name, :letter_returned, :return_date, :created_at, :updated_at)',
function (array $row) use ($now): array {
return [
':id' => (int) $row['Id'],
':household_id' => (int) $row['HouseholdId'],
':name' => normaliseString($row['Name']),
':letter_returned' => (int) ($row['LetterReturned'] ?? 0),
':return_date' => normaliseDateTime($row['ReturnDate']),
':created_at' => normaliseDateTime($row['Created']) ?? $now,
':updated_at' => $now,
];
},
$dryRun
);

// ── Finalise ───────────────────────────────────────────────────────────────
$sqlite->exec('PRAGMA foreign_keys = ON');

echo "\n";
if ($dryRun) {
echo "Dry run complete — no data was written.\n";
} else {
echo "Migration complete. SQLite row counts:\n";
foreach (['territories', 'households', 'householder_names'] as $t) {
$n = $sqlite->query("SELECT COUNT(*) FROM [{$t}]")->fetchColumn();
printf(" %-25s %d\n", $t, $n);
}
}

+ 63
- 0
database/migrations/20260531_000002_create_territory_tables.php 파일 보기

@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$database->execute('
CREATE TABLE IF NOT EXISTS territories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT,
coordinates TEXT,
created_at DATETIME,
updated_at DATETIME
)
');

$database->execute('
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
territory_id INTEGER NOT NULL,
address VARCHAR(255) NOT NULL,
street_number INTEGER,
street_name VARCHAR(255),
latitude REAL,
longitude REAL,
is_business INTEGER NOT NULL DEFAULT 0,
do_not_call INTEGER NOT NULL DEFAULT 0,
do_not_call_date DATE,
do_not_call_notes TEXT,
do_not_call_private_notes TEXT,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (territory_id) REFERENCES territories(id)
)
');

$database->execute('
CREATE TABLE IF NOT EXISTS householder_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
letter_returned INTEGER NOT NULL DEFAULT 0,
return_date DATETIME,
created_at DATETIME,
updated_at DATETIME,
FOREIGN KEY (household_id) REFERENCES households(id)
)
');
}

public function down(Database $database): void
{
foreach (['householder_names', 'households', 'territories'] as $table) {
$database->execute("DROP TABLE IF EXISTS {$table}");
}
}
};

BIN
database/myAccessFile.accdb 파일 보기


+ 300
- 0
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;
}
}

+ 39
- 1
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']);

불러오는 중...
취소
저장

Powered by TurnKey Linux.