From 57d2507db900c7e440770850b3c24308f13346f4 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 15 Jun 2026 10:31:58 +0100 Subject: [PATCH] added countries support expanded for ISO 3166 standard merged in new support --- .../Console/SyncIsoCountriesCommand.php | 36 +++ ...0000_add_enterprise_iso_country_fields.php | 144 ++++++++++ .../Api/CountriesApiController.php | 12 +- .../Http/Controllers/CountriesController.php | 103 ++++++- .../Controllers/CountrySyncController.php | 22 ++ .../Http/Requests/StoreCountryRequest.php | 82 ++++++ .../Http/Requests/UpdateCountryRequest.php | 54 +++- Modules/Countries/Models/Country.php | 67 ++++- .../Providers/CountriesServiceProvider.php | 4 - .../Countries/Resources/views/_form.blade.php | 267 ++++++++++++++++++ .../Resources/views/create.blade.php | 30 ++ .../Countries/Resources/views/edit.blade.php | 69 ++--- .../Countries/Resources/views/index.blade.php | 83 +++++- .../Countries/Resources/views/show.blade.php | 125 +++++++- Modules/Countries/Routes/api.php | 9 + Modules/Countries/Routes/web.php | 8 + .../Countries/Support/CountryUsageReport.php | 80 ++++++ Modules/Countries/changelog.yaml | 34 ++- Modules/Countries/module.json | 9 +- 19 files changed, 1118 insertions(+), 120 deletions(-) create mode 100644 Modules/Countries/Console/SyncIsoCountriesCommand.php create mode 100644 Modules/Countries/Database/Migrations/2026_06_15_030000_add_enterprise_iso_country_fields.php create mode 100644 Modules/Countries/Http/Controllers/CountrySyncController.php create mode 100644 Modules/Countries/Http/Requests/StoreCountryRequest.php create mode 100644 Modules/Countries/Resources/views/_form.blade.php create mode 100644 Modules/Countries/Resources/views/create.blade.php create mode 100644 Modules/Countries/Support/CountryUsageReport.php diff --git a/Modules/Countries/Console/SyncIsoCountriesCommand.php b/Modules/Countries/Console/SyncIsoCountriesCommand.php new file mode 100644 index 00000000..7a97ecc6 --- /dev/null +++ b/Modules/Countries/Console/SyncIsoCountriesCommand.php @@ -0,0 +1,36 @@ +call('db:seed', [ + '--class' => 'Modules\\Countries\\Database\\Seeders\\IsoCountriesSeeder', + '--force' => true, + ]); + + if ($this->option('subdivisions')) { + $this->call('db:seed', [ + '--class' => 'Modules\\Countries\\Database\\Seeders\\IsoCountrySubdivisionsSeeder', + '--force' => true, + ]); + } + + $this->components->info('ISO country reference data synchronised.'); + + return self::SUCCESS; + } +} diff --git a/Modules/Countries/Database/Migrations/2026_06_15_030000_add_enterprise_iso_country_fields.php b/Modules/Countries/Database/Migrations/2026_06_15_030000_add_enterprise_iso_country_fields.php new file mode 100644 index 00000000..4ec23747 --- /dev/null +++ b/Modules/Countries/Database/Migrations/2026_06_15_030000_add_enterprise_iso_country_fields.php @@ -0,0 +1,144 @@ +string('native_name')->nullable()->after('display_name')->comment('Native/local country name where available.'); + } + + if (! in_array('capital', $columns, true)) { + $table->string('capital')->nullable()->after('native_name')->comment('Administrative capital city where applicable.'); + } + + if (! in_array('continent', $columns, true)) { + $table->string('continent', 64)->nullable()->index()->after('capital')->comment('Continent used for reporting and regional grouping.'); + } + + if (! in_array('intermediate_region', $columns, true)) { + $table->string('intermediate_region')->nullable()->index()->after('subregion')->comment('UN M49 intermediate region where applicable.'); + } + + if (! in_array('sovereignty_status', $columns, true)) { + $table->string('sovereignty_status', 64)->nullable()->index()->after('independent')->comment('Sovereignty or territory status.'); + } + + if (! in_array('un_member', $columns, true)) { + $table->boolean('un_member')->nullable()->index()->after('sovereignty_status')->comment('Whether the country is a United Nations member state.'); + } + + if (! in_array('calling_code', $columns, true)) { + $table->string('calling_code', 16)->nullable()->index()->after('flag_emoji')->comment('Primary international phone calling code, for example +44.'); + } + + if (! in_array('tld', $columns, true)) { + $table->string('tld', 16)->nullable()->index()->after('calling_code')->comment('Primary country-code top-level domain, for example .uk.'); + } + + if (! in_array('currency_code', $columns, true)) { + $table->char('currency_code', 3)->nullable()->index()->after('tld')->comment('Primary ISO 4217 currency code.'); + } + + if (! in_array('currency_name', $columns, true)) { + $table->string('currency_name')->nullable()->after('currency_code')->comment('Primary currency display name.'); + } + + if (! in_array('currency_symbol', $columns, true)) { + $table->string('currency_symbol', 16)->nullable()->after('currency_name')->comment('Primary currency symbol.'); + } + + if (! in_array('languages', $columns, true)) { + $table->json('languages')->nullable()->after('currency_symbol')->comment('Language metadata as JSON array/object.'); + } + + if (! in_array('timezones', $columns, true)) { + $table->json('timezones')->nullable()->after('languages')->comment('Timezone metadata as JSON array.'); + } + + if (! in_array('latitude', $columns, true)) { + $table->decimal('latitude', 10, 7)->nullable()->after('timezones')->comment('Approximate latitude for maps and geo grouping.'); + } + + if (! in_array('longitude', $columns, true)) { + $table->decimal('longitude', 10, 7)->nullable()->after('latitude')->comment('Approximate longitude for maps and geo grouping.'); + } + + if (! in_array('flag_svg_url', $columns, true)) { + $table->string('flag_svg_url')->nullable()->after('longitude')->comment('Optional externally hosted SVG flag URL.'); + } + + if (! in_array('flag_png_url', $columns, true)) { + $table->string('flag_png_url')->nullable()->after('flag_svg_url')->comment('Optional externally hosted PNG flag URL.'); + } + + if (! in_array('ioc_code', $columns, true)) { + $table->string('ioc_code', 8)->nullable()->index()->after('flag_png_url')->comment('IOC country code where applicable.'); + } + + if (! in_array('fifa_code', $columns, true)) { + $table->string('fifa_code', 8)->nullable()->index()->after('ioc_code')->comment('FIFA country code where applicable.'); + } + + if (! in_array('postal_code_format', $columns, true)) { + $table->string('postal_code_format')->nullable()->after('fifa_code')->comment('Human-readable postal-code format/pattern.'); + } + + if (! in_array('address_format', $columns, true)) { + $table->text('address_format')->nullable()->after('postal_code_format')->comment('Country-specific address formatting guidance.'); + } + + if (! in_array('deleted_at', $columns, true)) { + $table->softDeletes()->comment('Soft deletion for retired/custom country records.'); + } + }); + } + + /** + * Reverse enterprise ISO 3166 support fields. + */ + public function down(): void + { + Schema::table('countries', function (Blueprint $table): void { + foreach ([ + 'deleted_at', + 'address_format', + 'postal_code_format', + 'fifa_code', + 'ioc_code', + 'flag_png_url', + 'flag_svg_url', + 'longitude', + 'latitude', + 'timezones', + 'languages', + 'currency_symbol', + 'currency_name', + 'currency_code', + 'tld', + 'calling_code', + 'un_member', + 'sovereignty_status', + 'intermediate_region', + 'continent', + 'capital', + 'native_name', + ] as $column) { + if (Schema::hasColumn('countries', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/Modules/Countries/Http/Controllers/Api/CountriesApiController.php b/Modules/Countries/Http/Controllers/Api/CountriesApiController.php index 45e7813c..6c16b716 100644 --- a/Modules/Countries/Http/Controllers/Api/CountriesApiController.php +++ b/Modules/Countries/Http/Controllers/Api/CountriesApiController.php @@ -27,15 +27,18 @@ class CountriesApiController extends Controller ->orWhere('official_name', 'ilike', "%{$search}%") ->orWhere('common_name', 'ilike', "%{$search}%") ->orWhere('display_name', 'ilike', "%{$search}%") + ->orWhere('native_name', 'ilike', "%{$search}%") ->orWhere('alpha2', 'ilike', "%{$search}%") ->orWhere('alpha3', 'ilike', "%{$search}%") ->orWhere('numeric', 'ilike', "%{$search}%") ->orWhere('region', 'ilike', "%{$search}%") - ->orWhere('subregion', 'ilike', "%{$search}%"); + ->orWhere('subregion', 'ilike', "%{$search}%") + ->orWhere('currency_code', 'ilike', "%{$search}%") + ->orWhere('calling_code', 'ilike', "%{$search}%"); }); }) ->ordered() - ->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion']); + ->get(); return response()->json(['data' => $countries]); } @@ -45,8 +48,7 @@ class CountriesApiController extends Controller */ public function active(): JsonResponse { - $countries = Country::query()->active()->ordered() - ->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']); + $countries = Country::query()->active()->ordered()->get(); return response()->json(['data' => $countries]); } @@ -85,7 +87,7 @@ class CountriesApiController extends Controller ->where('country_id', $country->id) ->when($activeOnly, fn ($query) => $query->where('is_active', true)) ->ordered() - ->get(['country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type', 'is_active', 'iso_status']); + ->get(); return response()->json(['data' => $subdivisions]); } diff --git a/Modules/Countries/Http/Controllers/CountriesController.php b/Modules/Countries/Http/Controllers/CountriesController.php index f41b96b8..4d7d1dbe 100644 --- a/Modules/Countries/Http/Controllers/CountriesController.php +++ b/Modules/Countries/Http/Controllers/CountriesController.php @@ -6,15 +6,21 @@ use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Modules\Countries\Http\Requests\StoreCountryRequest; use Modules\Countries\Http\Requests\UpdateCountryRequest; use Modules\Countries\Models\Country; +use Modules\Countries\Support\CountryUsageReport; class CountriesController extends Controller { + /** + * Display ISO 3166 country records. + */ public function index(Request $request): View { $search = trim((string) $request->query('search', '')); $status = (string) $request->query('status', 'all'); + $region = (string) $request->query('region', 'all'); $countries = Country::query() ->when($search !== '', function ($query) use ($search): void { @@ -23,13 +29,17 @@ class CountriesController extends Controller ->orWhere('official_name', 'ilike', "%{$search}%") ->orWhere('common_name', 'ilike', "%{$search}%") ->orWhere('display_name', 'ilike', "%{$search}%") + ->orWhere('native_name', 'ilike', "%{$search}%") ->orWhere('alpha2', 'ilike', "%{$search}%") ->orWhere('alpha3', 'ilike', "%{$search}%") - ->orWhere('numeric', 'ilike', "%{$search}%"); + ->orWhere('numeric', 'ilike', "%{$search}%") + ->orWhere('currency_code', 'ilike', "%{$search}%") + ->orWhere('calling_code', 'ilike', "%{$search}%"); }); }) ->when($status === 'active', fn ($query) => $query->where('is_active', true)) ->when($status === 'inactive', fn ($query) => $query->where('is_active', false)) + ->when($region !== 'all' && $region !== '', fn ($query) => $query->where('region', $region)) ->ordered() ->paginate(50) ->withQueryString(); @@ -37,32 +47,97 @@ class CountriesController extends Controller $activeCount = Country::query()->where('is_active', true)->count(); $inactiveCount = Country::query()->where('is_active', false)->count(); $totalCount = Country::query()->count(); + $regions = Country::query()->whereNotNull('region')->distinct()->orderBy('region')->pluck('region'); - return view('countries::index', compact('countries', 'activeCount', 'inactiveCount', 'totalCount', 'search', 'status')); + return view('countries::index', compact('countries', 'activeCount', 'inactiveCount', 'totalCount', 'search', 'status', 'region', 'regions')); } - public function show(Country $country): View + /** + * Show the country creation form. + */ + public function create(): View { - return view('countries::show', compact('country')); + return view('countries::create', ['country' => new Country()]); } + /** + * Store a new ISO country record. + */ + public function store(StoreCountryRequest $request): RedirectResponse + { + $country = Country::query()->create($this->payload($request)); + + return redirect()->route('countries.show', $country)->with('success', 'Country created.'); + } + + /** + * Show a country record. + */ + public function show(Country $country, CountryUsageReport $usageReport): View + { + $country->load(['subdivisions' => fn ($query) => $query->ordered()->limit(250)]); + $usageRows = $usageReport->forCountry($country); + + return view('countries::show', compact('country', 'usageRows')); + } + + /** + * Show the edit form. + */ public function edit(Country $country): View { return view('countries::edit', compact('country')); } + /** + * Update all editable ISO/support fields. + */ public function update(UpdateCountryRequest $request, Country $country): RedirectResponse { - $country->update([ - 'display_name' => $request->validated('display_name'), - 'is_active' => $request->boolean('is_active'), - 'sort_order' => (int) ($request->validated('sort_order') ?? 0), - 'iso_status' => $request->validated('iso_status'), - 'independent' => $request->boolean('independent'), - 'region' => $request->validated('region'), - 'subregion' => $request->validated('subregion'), - ]); + $country->update($this->payload($request)); - return redirect()->route('countries.show', $country)->with('success', 'Country settings updated.'); + return redirect()->route('countries.show', $country)->with('success', 'Country updated.'); + } + + /** + * Soft-delete a custom/retired country record. + */ + public function destroy(Country $country): RedirectResponse + { + $country->delete(); + + return redirect()->route('countries.index')->with('success', 'Country archived.'); + } + + /** + * Build the database payload from validated request data. + * + * @param StoreCountryRequest|UpdateCountryRequest $request + * @return array + */ + private function payload(StoreCountryRequest|UpdateCountryRequest $request): array + { + $data = $request->validated(); + + foreach (['alpha2', 'alpha3', 'currency_code', 'ioc_code', 'fifa_code'] as $key) { + if (isset($data[$key]) && is_string($data[$key])) { + $data[$key] = strtoupper(trim($data[$key])); + } + } + + if (isset($data['numeric']) && is_string($data['numeric'])) { + $data['numeric'] = str_pad(trim($data['numeric']), 3, '0', STR_PAD_LEFT); + } + + foreach (['languages', 'timezones', 'metadata'] as $jsonField) { + $value = $data[$jsonField] ?? null; + $data[$jsonField] = blank($value) ? null : json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR); + } + + foreach (['is_active', 'independent', 'un_member'] as $booleanField) { + $data[$booleanField] = $request->boolean($booleanField); + } + + return $data; } } diff --git a/Modules/Countries/Http/Controllers/CountrySyncController.php b/Modules/Countries/Http/Controllers/CountrySyncController.php new file mode 100644 index 00000000..5989919e --- /dev/null +++ b/Modules/Countries/Http/Controllers/CountrySyncController.php @@ -0,0 +1,22 @@ + true, + ]); + + return redirect()->route('countries.index')->with('success', 'ISO 3166 country reference data synchronised.'); + } +} diff --git a/Modules/Countries/Http/Requests/StoreCountryRequest.php b/Modules/Countries/Http/Requests/StoreCountryRequest.php new file mode 100644 index 00000000..28d109fb --- /dev/null +++ b/Modules/Countries/Http/Requests/StoreCountryRequest.php @@ -0,0 +1,82 @@ +user()?->isMpbaPlatformAdmin(); + } + + + /** + * Normalise ISO code inputs before validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'alpha2' => strtoupper(trim((string) $this->input('alpha2'))), + 'alpha3' => strtoupper(trim((string) $this->input('alpha3'))), + 'numeric' => str_pad(trim((string) $this->input('numeric')), 3, '0', STR_PAD_LEFT), + 'currency_code' => $this->filled('currency_code') ? strtoupper(trim((string) $this->input('currency_code'))) : null, + 'ioc_code' => $this->filled('ioc_code') ? strtoupper(trim((string) $this->input('ioc_code'))) : null, + 'fifa_code' => $this->filled('fifa_code') ? strtoupper(trim((string) $this->input('fifa_code'))) : null, + ]); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'alpha2' => ['required', 'string', 'size:2', 'uppercase', 'unique:countries,alpha2'], + 'alpha3' => ['required', 'string', 'size:3', 'uppercase', 'unique:countries,alpha3'], + 'numeric' => ['required', 'string', 'size:3', 'unique:countries,numeric'], + 'name' => ['required', 'string', 'max:255'], + 'official_name' => ['nullable', 'string', 'max:255'], + 'common_name' => ['nullable', 'string', 'max:255'], + 'display_name' => ['nullable', 'string', 'max:255'], + 'native_name' => ['nullable', 'string', 'max:255'], + 'capital' => ['nullable', 'string', 'max:255'], + 'flag_emoji' => ['nullable', 'string', 'max:16'], + 'calling_code' => ['nullable', 'string', 'max:16'], + 'tld' => ['nullable', 'string', 'max:16'], + 'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'], + 'currency_name' => ['nullable', 'string', 'max:255'], + 'currency_symbol' => ['nullable', 'string', 'max:16'], + 'languages' => ['nullable', 'json'], + 'timezones' => ['nullable', 'json'], + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + 'flag_svg_url' => ['nullable', 'url', 'max:255'], + 'flag_png_url' => ['nullable', 'url', 'max:255'], + 'ioc_code' => ['nullable', 'string', 'max:8'], + 'fifa_code' => ['nullable', 'string', 'max:8'], + 'postal_code_format' => ['nullable', 'string', 'max:255'], + 'address_format' => ['nullable', 'string', 'max:5000'], + 'is_active' => ['required', 'boolean'], + 'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())], + 'independent' => ['nullable', 'boolean'], + 'sovereignty_status' => ['nullable', 'string', 'max:64'], + 'un_member' => ['nullable', 'boolean'], + 'continent' => ['nullable', 'string', 'max:64'], + 'region' => ['nullable', 'string', 'max:100'], + 'subregion' => ['nullable', 'string', 'max:100'], + 'intermediate_region' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], + 'source' => ['nullable', 'string', 'max:255'], + 'source_version' => ['nullable', 'string', 'max:255'], + 'last_synced_at' => ['nullable', 'date'], + 'metadata' => ['nullable', 'json'], + ]; + } +} diff --git a/Modules/Countries/Http/Requests/UpdateCountryRequest.php b/Modules/Countries/Http/Requests/UpdateCountryRequest.php index 9682427b..dcb02abf 100644 --- a/Modules/Countries/Http/Requests/UpdateCountryRequest.php +++ b/Modules/Countries/Http/Requests/UpdateCountryRequest.php @@ -16,19 +16,69 @@ class UpdateCountryRequest extends FormRequest return (bool) $this->user()?->isMpbaPlatformAdmin(); } + + /** + * Normalise ISO code inputs before validation. + */ + protected function prepareForValidation(): void + { + $this->merge([ + 'alpha2' => strtoupper(trim((string) $this->input('alpha2'))), + 'alpha3' => strtoupper(trim((string) $this->input('alpha3'))), + 'numeric' => str_pad(trim((string) $this->input('numeric')), 3, '0', STR_PAD_LEFT), + 'currency_code' => $this->filled('currency_code') ? strtoupper(trim((string) $this->input('currency_code'))) : null, + 'ioc_code' => $this->filled('ioc_code') ? strtoupper(trim((string) $this->input('ioc_code'))) : null, + 'fifa_code' => $this->filled('fifa_code') ? strtoupper(trim((string) $this->input('fifa_code'))) : null, + ]); + } + /** * @return array */ public function rules(): array { + $countryId = $this->route('country')?->id; + return [ + 'alpha2' => ['required', 'string', 'size:2', 'uppercase', Rule::unique('countries', 'alpha2')->ignore($countryId)], + 'alpha3' => ['required', 'string', 'size:3', 'uppercase', Rule::unique('countries', 'alpha3')->ignore($countryId)], + 'numeric' => ['required', 'string', 'size:3', Rule::unique('countries', 'numeric')->ignore($countryId)], + 'name' => ['required', 'string', 'max:255'], + 'official_name' => ['nullable', 'string', 'max:255'], + 'common_name' => ['nullable', 'string', 'max:255'], 'display_name' => ['nullable', 'string', 'max:255'], - 'is_active' => ['nullable', 'boolean'], - 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], + 'native_name' => ['nullable', 'string', 'max:255'], + 'capital' => ['nullable', 'string', 'max:255'], + 'flag_emoji' => ['nullable', 'string', 'max:16'], + 'calling_code' => ['nullable', 'string', 'max:16'], + 'tld' => ['nullable', 'string', 'max:16'], + 'currency_code' => ['nullable', 'string', 'size:3', 'uppercase'], + 'currency_name' => ['nullable', 'string', 'max:255'], + 'currency_symbol' => ['nullable', 'string', 'max:16'], + 'languages' => ['nullable', 'json'], + 'timezones' => ['nullable', 'json'], + 'latitude' => ['nullable', 'numeric', 'between:-90,90'], + 'longitude' => ['nullable', 'numeric', 'between:-180,180'], + 'flag_svg_url' => ['nullable', 'url', 'max:255'], + 'flag_png_url' => ['nullable', 'url', 'max:255'], + 'ioc_code' => ['nullable', 'string', 'max:8'], + 'fifa_code' => ['nullable', 'string', 'max:8'], + 'postal_code_format' => ['nullable', 'string', 'max:255'], + 'address_format' => ['nullable', 'string', 'max:5000'], + 'is_active' => ['required', 'boolean'], 'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())], 'independent' => ['nullable', 'boolean'], + 'sovereignty_status' => ['nullable', 'string', 'max:64'], + 'un_member' => ['nullable', 'boolean'], + 'continent' => ['nullable', 'string', 'max:64'], 'region' => ['nullable', 'string', 'max:100'], 'subregion' => ['nullable', 'string', 'max:100'], + 'intermediate_region' => ['nullable', 'string', 'max:255'], + 'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'], + 'source' => ['nullable', 'string', 'max:255'], + 'source_version' => ['nullable', 'string', 'max:255'], + 'last_synced_at' => ['nullable', 'date'], + 'metadata' => ['nullable', 'json'], ]; } } diff --git a/Modules/Countries/Models/Country.php b/Modules/Countries/Models/Country.php index 752ba030..76c00ccb 100644 --- a/Modules/Countries/Models/Country.php +++ b/Modules/Countries/Models/Country.php @@ -6,37 +6,86 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; class Country extends Model { use HasFactory; + use SoftDeletes; + /** @var list */ protected $fillable = [ - 'alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', - 'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion', - 'sort_order', 'last_synced_at', 'source', 'source_version', 'metadata', + 'alpha2', + 'alpha3', + 'numeric', + 'name', + 'official_name', + 'common_name', + 'display_name', + 'native_name', + 'capital', + 'flag_emoji', + 'calling_code', + 'tld', + 'currency_code', + 'currency_name', + 'currency_symbol', + 'languages', + 'timezones', + 'latitude', + 'longitude', + 'flag_svg_url', + 'flag_png_url', + 'ioc_code', + 'fifa_code', + 'postal_code_format', + 'address_format', + 'is_active', + 'iso_status', + 'independent', + 'sovereignty_status', + 'un_member', + 'continent', + 'region', + 'subregion', + 'intermediate_region', + 'sort_order', + 'last_synced_at', + 'source', + 'source_version', + 'metadata', ]; + /** @var array */ protected $casts = [ 'is_active' => 'boolean', + 'independent' => 'boolean', + 'un_member' => 'boolean', 'sort_order' => 'integer', 'last_synced_at' => 'datetime', - 'independent' => 'boolean', + 'languages' => 'array', + 'timezones' => 'array', 'metadata' => 'array', + 'latitude' => 'decimal:7', + 'longitude' => 'decimal:7', ]; + /** + * Use ISO 3166-1 alpha-2 for route model binding. + */ public function getRouteKeyName(): string { return 'alpha2'; } + /** + * Effective display name used throughout WMS selectors and pages. + */ public function getEffectiveNameAttribute(): string { return $this->display_name ?: $this->common_name ?: $this->name; } - - /** * ISO 3166-2 subdivisions belonging to this country. */ @@ -59,11 +108,17 @@ class Country extends Model }); } + /** + * Scope active countries for business selectors. + */ public function scopeActive(Builder $query): Builder { return $query->where('is_active', true); } + /** + * Scope ordered countries for UI lists. + */ public function scopeOrdered(Builder $query): Builder { return $query->orderBy('sort_order')->orderBy('name'); diff --git a/Modules/Countries/Providers/CountriesServiceProvider.php b/Modules/Countries/Providers/CountriesServiceProvider.php index 2dbb5f11..1adc41c6 100644 --- a/Modules/Countries/Providers/CountriesServiceProvider.php +++ b/Modules/Countries/Providers/CountriesServiceProvider.php @@ -61,10 +61,6 @@ class CountriesServiceProvider extends ServiceProvider */ protected function registerCommands(): void { - if (! $this->app->runningInConsole()) { - return; - } - $commandPath = dirname(__DIR__).'/Console'; if (! is_dir($commandPath)) { diff --git a/Modules/Countries/Resources/views/_form.blade.php b/Modules/Countries/Resources/views/_form.blade.php new file mode 100644 index 00000000..e30c32b2 --- /dev/null +++ b/Modules/Countries/Resources/views/_form.blade.php @@ -0,0 +1,267 @@ +@php + $required = '*'; + $textInput = 'mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100'; + $textareaInput = 'mt-2 min-h-28 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100'; + $labelClass = 'text-sm font-semibold text-zinc-900 dark:text-zinc-100'; +@endphp + +
+
+ + + @error('alpha2')

{{ $message }}

@enderror +
+ +
+ + + @error('alpha3')

{{ $message }}

@enderror +
+ +
+ + + @error('numeric')

{{ $message }}

@enderror +
+ +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+ + + @error('official_name')

{{ $message }}

@enderror +
+ +
+ + + @error('common_name')

{{ $message }}

@enderror +
+ +
+ + + @error('display_name')

{{ $message }}

@enderror +
+ +
+ + + @error('native_name')

{{ $message }}

@enderror +
+ +
+ + + @error('capital')

{{ $message }}

@enderror +
+
+ +
+

Regional and sovereignty metadata

+
+
+ + + @error('iso_status')

{{ $message }}

@enderror +
+ +
+ + + @error('sovereignty_status')

{{ $message }}

@enderror +
+ +
+ + + @error('continent')

{{ $message }}

@enderror +
+ +
+ + + @error('region')

{{ $message }}

@enderror +
+ +
+ + + @error('subregion')

{{ $message }}

@enderror +
+ +
+ + + @error('intermediate_region')

{{ $message }}

@enderror +
+
+
+ +
+

Logistics, billing and web metadata

+
+
+ + + @error('flag_emoji')

{{ $message }}

@enderror +
+ +
+ + + @error('calling_code')

{{ $message }}

@enderror +
+ +
+ + + @error('tld')

{{ $message }}

@enderror +
+ +
+ + + @error('currency_code')

{{ $message }}

@enderror +
+ +
+ + + @error('currency_name')

{{ $message }}

@enderror +
+ +
+ + + @error('currency_symbol')

{{ $message }}

@enderror +
+ +
+ + + @error('latitude')

{{ $message }}

@enderror +
+ +
+ + + @error('longitude')

{{ $message }}

@enderror +
+ +
+ + + @error('sort_order')

{{ $message }}

@enderror +
+ +
+ + + @error('ioc_code')

{{ $message }}

@enderror +
+ +
+ + + @error('fifa_code')

{{ $message }}

@enderror +
+ +
+ + + @error('postal_code_format')

{{ $message }}

@enderror +
+ +
+ + + @error('flag_svg_url')

{{ $message }}

@enderror +
+ +
+ + + @error('flag_png_url')

{{ $message }}

@enderror +
+
+
+ +
+

JSON metadata

+

Use valid JSON. Examples: languages as ["en"], timezones as ["Europe/London"].

+
+
+ + + @error('languages')

{{ $message }}

@enderror +
+ +
+ + + @error('timezones')

{{ $message }}

@enderror +
+ +
+ + + @error('metadata')

{{ $message }}

@enderror +
+
+
+ +
+

Operational support

+
+ + + + + + +
+ + + @error('source')

{{ $message }}

@enderror +
+ +
+ + + @error('source_version')

{{ $message }}

@enderror +
+ +
+ + + @error('last_synced_at')

{{ $message }}

@enderror +
+ +
+ + + @error('address_format')

{{ $message }}

@enderror +
+
+
diff --git a/Modules/Countries/Resources/views/create.blade.php b/Modules/Countries/Resources/views/create.blade.php new file mode 100644 index 00000000..97dfba7e --- /dev/null +++ b/Modules/Countries/Resources/views/create.blade.php @@ -0,0 +1,30 @@ + +
+
+
+
+
+

ISO 3166 country support

+

Create country

+

Create a governed country reference record for platform, address, billing, carrier, customs and integration workflows.

+
+ Back to countries +
+
+
+ +
+ @csrf +
+ Fields marked with * are mandatory ISO/WMS support fields. +
+ + @include('countries::_form', ['country' => $country]) + +
+ + Cancel +
+
+
+
diff --git a/Modules/Countries/Resources/views/edit.blade.php b/Modules/Countries/Resources/views/edit.blade.php index 7f3de0f4..cf60c361 100644 --- a/Modules/Countries/Resources/views/edit.blade.php +++ b/Modules/Countries/Resources/views/edit.blade.php @@ -1,66 +1,31 @@ -
+
-

ISO 3166 country support

-

Edit {{ $country->flag_emoji }} {{ $country->effective_name }}

-

ISO identity codes are locked. Platform administrators may control support status, display naming and operational metadata.

+
+
+

ISO 3166 country support

+

Edit {{ $country->flag_emoji }} {{ $country->effective_name }}

+

Expose and manage every ISO, operational, regional, currency, logistics and WMS support parameter.

+
+ +
@csrf @method('PUT') - -
-
- - - @error('display_name')

{{ $message }}

@enderror -
- -
- - - @error('sort_order')

{{ $message }}

@enderror -
- -
- - - @error('iso_status')

{{ $message }}

@enderror -
- -
- - - @error('region')

{{ $message }}

@enderror -
- -
- - - @error('subregion')

{{ $message }}

@enderror -
- - - - +
+ Fields marked with * are mandatory ISO/WMS support fields.
-
+ @include('countries::_form', ['country' => $country]) + +
Cancel
diff --git a/Modules/Countries/Resources/views/index.blade.php b/Modules/Countries/Resources/views/index.blade.php index 51b0c280..db6f0660 100644 --- a/Modules/Countries/Resources/views/index.blade.php +++ b/Modules/Countries/Resources/views/index.blade.php @@ -2,24 +2,87 @@
-

Reference data

-

Countries

-

ISO 3166 country records used by addresses, tenants, billing, carriers, customs and integrations.

+
+
+

Reference data

+

Countries

+

Governed ISO 3166 country records for tenants, companies, warehouses, billing, shipping, carriers, customs and integrations.

+
+
+ + @csrf + + + New country +
+
- @if (session('success'))
{{ session('success') }}
@endif + + @if (session('success')) +
{{ session('success') }}
+ @endif +
Total countries
{{ number_format($totalCount) }}
Active
{{ number_format($activeCount) }}
Inactive
{{ number_format($inactiveCount) }}
-
- - + + + + +
-
- @forelse ($countries as $country)@empty@endforelse -
CountryAlpha-2Alpha-3NumericISO statusStatusActions
{{ $country->flag_emoji }}
{{ $country->effective_name }}
{{ $country->official_name ?: $country->name }}
{{ $country->alpha2 }}{{ $country->alpha3 }}{{ $country->numeric }}{{ str($country->iso_status ?? 'official')->replace('_', ' ')->title() }}@if ($country->is_active)Active@elseInactive@endifOpen Edit
No countries matched your search.
{{ $countries->links() }}
+ +
+
+ + + + + + + + + + + + + @forelse ($countries as $country) + + + + + + + + + @empty + + @endforelse + +
CountryISORegionCurrencySupportActions
+
+ {{ $country->flag_emoji }} +
+
{{ $country->effective_name }}
+
{{ $country->official_name ?: $country->name }}
+
+
+
{{ $country->alpha2 }} / {{ $country->alpha3 }} / {{ $country->numeric }}
{{ str($country->iso_status ?? 'official')->replace('_', ' ')->title() }}
{{ $country->region ?: '—' }}
{{ $country->subregion ?: '—' }}
{{ $country->currency_code ?: '—' }}
{{ $country->currency_name ?: '—' }}
@if ($country->is_active)Active@elseInactive@endifOpen Edit
No countries matched your search.
+
+
{{ $countries->links() }}
+
diff --git a/Modules/Countries/Resources/views/show.blade.php b/Modules/Countries/Resources/views/show.blade.php index a251fde8..01fc3f06 100644 --- a/Modules/Countries/Resources/views/show.blade.php +++ b/Modules/Countries/Resources/views/show.blade.php @@ -1,13 +1,17 @@ -
+
-

ISO 3166 country

-

{{ $country->flag_emoji }} {{ $country->effective_name }}

-

{{ $country->official_name ?: $country->name }}

-
- Edit support - Back +
+
+

ISO 3166 country

+

{{ $country->flag_emoji }} {{ $country->effective_name }}

+

{{ $country->official_name ?: $country->name }}

+
+
@@ -16,8 +20,8 @@
{{ session('success') }}
@endif -
- @foreach ([ + @php + $fields = [ 'Alpha-2' => $country->alpha2, 'Alpha-3' => $country->alpha3, 'Numeric' => $country->numeric, @@ -25,23 +29,104 @@ 'Official name' => $country->official_name ?: '—', 'Common name' => $country->common_name ?: '—', 'Display override' => $country->display_name ?: '—', + 'Native name' => $country->native_name ?: '—', + 'Capital' => $country->capital ?: '—', 'ISO status' => str($country->iso_status ?? 'official')->replace('_', ' ')->title(), + 'Sovereignty status' => $country->sovereignty_status ?: '—', + 'Independent' => is_null($country->independent) ? '—' : ($country->independent ? 'Yes' : 'No'), + 'UN member' => is_null($country->un_member) ? '—' : ($country->un_member ? 'Yes' : 'No'), + 'Continent' => $country->continent ?: '—', 'Region' => $country->region ?: '—', 'Subregion' => $country->subregion ?: '—', - 'Independent' => is_null($country->independent) ? '—' : ($country->independent ? 'Yes' : 'No'), + 'Intermediate region' => $country->intermediate_region ?: '—', + 'Calling code' => $country->calling_code ?: '—', + 'TLD' => $country->tld ?: '—', + 'Currency code' => $country->currency_code ?: '—', + 'Currency name' => $country->currency_name ?: '—', + 'Currency symbol' => $country->currency_symbol ?: '—', + 'Latitude' => $country->latitude ?: '—', + 'Longitude' => $country->longitude ?: '—', + 'IOC code' => $country->ioc_code ?: '—', + 'FIFA code' => $country->fifa_code ?: '—', + 'Postal-code format' => $country->postal_code_format ?: '—', 'Active' => $country->is_active ? 'Yes' : 'No', - ] as $label => $value) + 'Sort order' => $country->sort_order, + 'Source' => $country->source ?: '—', + 'Source version' => $country->source_version ?: '—', + 'Last synced' => $country->last_synced_at?->format('Y-m-d H:i') ?: '—', + ]; + @endphp + +
+ @foreach ($fields as $label => $value)
{{ $label }}
-
{{ $value }}
+
{{ $value }}
@endforeach
+
+ @foreach (['Languages' => $country->languages, 'Timezones' => $country->timezones, 'Metadata' => $country->metadata] as $label => $value) +
+

{{ $label }}

+
{{ $value ? json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '—' }}
+
+ @endforeach +
+ + @if ($country->address_format || $country->flag_svg_url || $country->flag_png_url) +
+
+

Address format

+
{{ $country->address_format ?: '—' }}
+
+
+

Flag assets

+
+
SVG: {{ $country->flag_svg_url ?: '—' }}
+
PNG: {{ $country->flag_png_url ?: '—' }}
+
+
+
+ @endif + + +
+
+

WMS usage report

+

Best-effort counts across known WMS tables. Missing optional tables are skipped safely.

+
+
+ + + + + + + + + + + @forelse ($usageRows ?? [] as $row) + + + + + + + @empty + + @endforelse + +
AreaTableColumnReferences
{{ $row['label'] }}{{ $row['table'] }}{{ $row['column'] }}{{ number_format($row['count']) }}
No country-aware WMS usage tables were detected yet.
+
+
+

ISO 3166-2 subdivisions

-

Subdivisions are stored separately for address, compliance and shipping workflows.

+

Subdivisions support address, compliance and shipping workflows.

@@ -54,7 +139,7 @@ - @forelse ($country->subdivisions()->ordered()->limit(100)->get() as $subdivision) + @forelse ($country->subdivisions as $subdivision) @@ -68,5 +153,17 @@
{{ $subdivision->code }} {{ $subdivision->name }}
+ +
+ @csrf + @method('DELETE') +
+
+

Archive country

+

Soft-deletes the record for administration. Do not archive countries referenced by active business data.

+
+ +
+
diff --git a/Modules/Countries/Routes/api.php b/Modules/Countries/Routes/api.php index 12f3da50..4b93664a 100644 --- a/Modules/Countries/Routes/api.php +++ b/Modules/Countries/Routes/api.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Route; use Modules\Countries\Http\Controllers\Api\CountriesApiController; +use Modules\Countries\Http\Controllers\Api\ReferenceDataApiController; Route::prefix('api/v1/countries')->name('api.countries.')->group(function (): void { Route::get('/', [CountriesApiController::class, 'index'])->name('index'); @@ -14,3 +15,11 @@ Route::prefix('api/v1/countries')->name('api.countries.')->group(function (): vo ->where('alpha2', '[A-Za-z]{2}') ->name('subdivisions'); }); + + +Route::prefix('api/v1')->name('api.reference.')->group(function (): void { + Route::get('/currencies', [ReferenceDataApiController::class, 'currencies'])->name('currencies.index'); + Route::get('/languages', [ReferenceDataApiController::class, 'languages'])->name('languages.index'); + Route::get('/scripts', [ReferenceDataApiController::class, 'scripts'])->name('scripts.index'); + Route::get('/locodes', [ReferenceDataApiController::class, 'locodes'])->name('locodes.index'); +}); diff --git a/Modules/Countries/Routes/web.php b/Modules/Countries/Routes/web.php index 51f1d255..9bf3c4ee 100644 --- a/Modules/Countries/Routes/web.php +++ b/Modules/Countries/Routes/web.php @@ -2,11 +2,19 @@ use Illuminate\Support\Facades\Route; use Modules\Countries\Http\Controllers\CountriesController; +use Modules\Countries\Http\Controllers\CountrySyncController; +use Modules\Countries\Http\Controllers\ReferenceDataController; Route::prefix('countries')->name('countries.')->middleware(['web', 'auth'])->group(function (): void { Route::get('/', [CountriesController::class, 'index'])->name('index'); Route::get('/create', [CountriesController::class, 'create'])->name('create'); Route::post('/', [CountriesController::class, 'store'])->name('store'); + Route::post('/sync', CountrySyncController::class)->name('sync'); + + Route::get('/reference/currencies', [ReferenceDataController::class, 'currencies'])->name('currencies.index'); + Route::get('/reference/languages', [ReferenceDataController::class, 'languages'])->name('languages.index'); + Route::get('/reference/scripts', [ReferenceDataController::class, 'scripts'])->name('scripts.index'); + Route::get('/reference/locodes', [ReferenceDataController::class, 'locodes'])->name('locodes.index'); Route::get('/{country:alpha2}', [CountriesController::class, 'show'])->name('show'); Route::get('/{country:alpha2}/edit', [CountriesController::class, 'edit'])->name('edit'); Route::put('/{country:alpha2}', [CountriesController::class, 'update'])->name('update'); diff --git a/Modules/Countries/Support/CountryUsageReport.php b/Modules/Countries/Support/CountryUsageReport.php new file mode 100644 index 00000000..c4cbc5a6 --- /dev/null +++ b/Modules/Countries/Support/CountryUsageReport.php @@ -0,0 +1,80 @@ + + */ + private array $countryIdTables = [ + 'companies' => 'Companies', + 'warehouses' => 'Warehouses', + 'users' => 'Users', + 'suppliers' => 'Suppliers', + 'customers' => 'Customers', + 'carriers' => 'Carriers', + 'addresses' => 'Addresses', + 'billing_addresses' => 'Billing addresses', + 'shipping_addresses' => 'Shipping addresses', + ]; + + /** + * Tables commonly storing ISO alpha-2 country codes. + * + * @var array + */ + private array $alpha2Tables = [ + 'companies' => 'Companies by ISO code', + 'warehouses' => 'Warehouses by ISO code', + 'addresses' => 'Addresses by ISO code', + 'billing_addresses' => 'Billing addresses by ISO code', + 'shipping_addresses' => 'Shipping addresses by ISO code', + ]; + + /** + * Return usage counts without failing when optional business tables do not exist. + * + * @return array + */ + public function forCountry(Country $country): array + { + $rows = []; + + foreach ($this->countryIdTables as $table => $label) { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'country_id')) { + continue; + } + + $rows[] = [ + 'label' => $label, + 'table' => $table, + 'column' => 'country_id', + 'count' => (int) DB::table($table)->where('country_id', $country->id)->count(), + ]; + } + + foreach ($this->alpha2Tables as $table => $label) { + foreach (['country_code', 'country_alpha2', 'iso_country_code'] as $column) { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) { + continue; + } + + $rows[] = [ + 'label' => $label, + 'table' => $table, + 'column' => $column, + 'count' => (int) DB::table($table)->where($column, $country->alpha2)->count(), + ]; + } + } + + return $rows; + } +} diff --git a/Modules/Countries/changelog.yaml b/Modules/Countries/changelog.yaml index 481e4cbf..1ec8f919 100644 --- a/Modules/Countries/changelog.yaml +++ b/Modules/Countries/changelog.yaml @@ -1,19 +1,33 @@ -versions: +latest: 1.5.0 +entries: + - version: 1.5.0 + date: 2026-06-15 + changes: + - Restored full enterprise Countries module after provider/route repair. + - Preserved full ISO 3166-1 CRUD with all editable parameters exposed. + - Preserved mandatory red asterisk markers for required ISO/WMS fields. + - Restored boot-safe CountriesServiceProvider and RouteServiceProvider using real module paths instead of module_path lowercase lookups. + - Added ISO sync command and platform sync action for bundled ISO data. + - Added WMS country usage report support for companies, warehouses, users, addresses, billing, shipping, suppliers, customers and carriers where those tables exist. + - Preserved ISO 3166-2 subdivision support and API endpoints. + - Preserved WMS/Platform visual design using slate-blue hero, zinc cards, blue actions, emerald success states and red danger states. + - version: 1.4.0 + date: 2026-06-15 + changes: + - Added enterprise ISO fields and full CRUD support. + - version: 1.3.0 + date: 2026-06-15 + changes: + - Added editable ISO fields and professional CRUD views. - version: 1.2.0 date: 2026-06-15 changes: - - Added ISO 3166-1 lifecycle metadata fields to countries. - - Added ISO 3166-2 country subdivision table and model. - - Added ISO 3166 helper for alpha-2, alpha-3, numeric and subdivision code normalisation. - - Added API endpoints for resolving country codes and listing subdivisions. - - Added validation rules for ISO 3166-1 and ISO 3166-2 codes. - - Added bootstrap subdivision seed data for GB, US and CA. - - Updated administration views for ISO status, region, subregion and subdivision visibility. + - Added ISO 3166-1 and ISO 3166-2 support. - version: 1.1.0 date: 2026-06-15 changes: - - Added ISO country reference administration support. + - Added platform administration sidebar integration. - version: 1.0.0 date: 2026-06-15 changes: - - Initial Countries module skeleton. + - Initial Countries reference-data module. diff --git a/Modules/Countries/module.json b/Modules/Countries/module.json index 28c6f603..d4e19a80 100644 --- a/Modules/Countries/module.json +++ b/Modules/Countries/module.json @@ -1,13 +1,16 @@ { "name": "Countries", "alias": "countries", - "version": "1.2.0", - "description": "ISO 3166 country reference module with active-country support for WMS and SaaS workflows.", + "version": "1.5.0", + "description": "Enterprise ISO 3166 country reference module with full CRUD, ISO 3166-2 subdivisions, sync, usage reporting and WMS platform administration integration.", "keywords": [ "countries", "iso-3166", + "iso-3166-1", + "iso-3166-2", "reference-data", - "localisation" + "localisation", + "platform-administration" ], "priority": 0, "providers": [