added countries support expanded for ISO 3166 standard merged in new support
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncIsoCountriesCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'countries:sync-iso {--subdivisions : Also seed bundled ISO 3166-2 subdivision data}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Synchronise bundled ISO 3166 country reference data.';
|
||||
|
||||
/**
|
||||
* Execute the command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add enterprise ISO 3166 support fields used by platform administration,
|
||||
* address validation, logistics, billing, carrier and compliance workflows.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('countries', function (Blueprint $table): void {
|
||||
$columns = Schema::getColumnListing('countries');
|
||||
|
||||
if (! in_array('native_name', $columns, true)) {
|
||||
$table->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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class CountrySyncController extends Controller
|
||||
{
|
||||
/**
|
||||
* Synchronise bundled ISO 3166 reference data.
|
||||
*/
|
||||
public function __invoke(): RedirectResponse
|
||||
{
|
||||
Artisan::call('countries:sync-iso', [
|
||||
'--subdivisions' => true,
|
||||
]);
|
||||
|
||||
return redirect()->route('countries.index')->with('success', 'ISO 3166 country reference data synchronised.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Countries\Support\Iso3166;
|
||||
|
||||
class StoreCountryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Authorize country reference-data administration.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
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<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> */
|
||||
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<string, string> */
|
||||
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');
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
@php
|
||||
$required = '<span class="text-red-600 dark:text-red-400">*</span>';
|
||||
$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
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="alpha2">Alpha-2 {!! $required !!}</label>
|
||||
<input id="alpha2" name="alpha2" maxlength="2" value="{{ old('alpha2', $country->alpha2) }}" class="{{ $textInput }} uppercase" placeholder="GB" />
|
||||
@error('alpha2')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="alpha3">Alpha-3 {!! $required !!}</label>
|
||||
<input id="alpha3" name="alpha3" maxlength="3" value="{{ old('alpha3', $country->alpha3) }}" class="{{ $textInput }} uppercase" placeholder="GBR" />
|
||||
@error('alpha3')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="numeric">Numeric {!! $required !!}</label>
|
||||
<input id="numeric" name="numeric" maxlength="3" value="{{ old('numeric', $country->numeric) }}" class="{{ $textInput }}" placeholder="826" />
|
||||
@error('numeric')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="name">Canonical ISO name {!! $required !!}</label>
|
||||
<input id="name" name="name" value="{{ old('name', $country->name) }}" class="{{ $textInput }}" />
|
||||
@error('name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="official_name">Official name</label>
|
||||
<input id="official_name" name="official_name" value="{{ old('official_name', $country->official_name) }}" class="{{ $textInput }}" />
|
||||
@error('official_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="common_name">Common name</label>
|
||||
<input id="common_name" name="common_name" value="{{ old('common_name', $country->common_name) }}" class="{{ $textInput }}" />
|
||||
@error('common_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="display_name">Display name override</label>
|
||||
<input id="display_name" name="display_name" value="{{ old('display_name', $country->display_name) }}" class="{{ $textInput }}" />
|
||||
@error('display_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="native_name">Native name</label>
|
||||
<input id="native_name" name="native_name" value="{{ old('native_name', $country->native_name) }}" class="{{ $textInput }}" />
|
||||
@error('native_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="capital">Capital</label>
|
||||
<input id="capital" name="capital" value="{{ old('capital', $country->capital) }}" class="{{ $textInput }}" />
|
||||
@error('capital')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-100">Regional and sovereignty metadata</h2>
|
||||
<div class="mt-4 grid gap-5 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="iso_status">ISO lifecycle status {!! $required !!}</label>
|
||||
<select id="iso_status" name="iso_status" class="{{ $textInput }}">
|
||||
@foreach (\Modules\Countries\Support\Iso3166::statuses() as $status)
|
||||
<option value="{{ $status }}" @selected(old('iso_status', $country->iso_status ?? 'official') === $status)>{{ str($status)->replace('_', ' ')->title() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('iso_status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="sovereignty_status">Sovereignty status</label>
|
||||
<input id="sovereignty_status" name="sovereignty_status" value="{{ old('sovereignty_status', $country->sovereignty_status) }}" class="{{ $textInput }}" />
|
||||
@error('sovereignty_status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="continent">Continent</label>
|
||||
<input id="continent" name="continent" value="{{ old('continent', $country->continent) }}" class="{{ $textInput }}" />
|
||||
@error('continent')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="region">Region</label>
|
||||
<input id="region" name="region" value="{{ old('region', $country->region) }}" class="{{ $textInput }}" />
|
||||
@error('region')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="subregion">Subregion</label>
|
||||
<input id="subregion" name="subregion" value="{{ old('subregion', $country->subregion) }}" class="{{ $textInput }}" />
|
||||
@error('subregion')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="intermediate_region">Intermediate region</label>
|
||||
<input id="intermediate_region" name="intermediate_region" value="{{ old('intermediate_region', $country->intermediate_region) }}" class="{{ $textInput }}" />
|
||||
@error('intermediate_region')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-100">Logistics, billing and web metadata</h2>
|
||||
<div class="mt-4 grid gap-5 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="flag_emoji">Flag emoji</label>
|
||||
<input id="flag_emoji" name="flag_emoji" value="{{ old('flag_emoji', $country->flag_emoji) }}" class="{{ $textInput }}" />
|
||||
@error('flag_emoji')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="calling_code">Calling code</label>
|
||||
<input id="calling_code" name="calling_code" value="{{ old('calling_code', $country->calling_code) }}" class="{{ $textInput }}" placeholder="+44" />
|
||||
@error('calling_code')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="tld">Top-level domain</label>
|
||||
<input id="tld" name="tld" value="{{ old('tld', $country->tld) }}" class="{{ $textInput }}" placeholder=".uk" />
|
||||
@error('tld')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="currency_code">Currency code</label>
|
||||
<input id="currency_code" name="currency_code" maxlength="3" value="{{ old('currency_code', $country->currency_code) }}" class="{{ $textInput }} uppercase" placeholder="GBP" />
|
||||
@error('currency_code')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="currency_name">Currency name</label>
|
||||
<input id="currency_name" name="currency_name" value="{{ old('currency_name', $country->currency_name) }}" class="{{ $textInput }}" />
|
||||
@error('currency_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="currency_symbol">Currency symbol</label>
|
||||
<input id="currency_symbol" name="currency_symbol" value="{{ old('currency_symbol', $country->currency_symbol) }}" class="{{ $textInput }}" />
|
||||
@error('currency_symbol')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="latitude">Latitude</label>
|
||||
<input id="latitude" name="latitude" type="number" step="0.0000001" value="{{ old('latitude', $country->latitude) }}" class="{{ $textInput }}" />
|
||||
@error('latitude')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="longitude">Longitude</label>
|
||||
<input id="longitude" name="longitude" type="number" step="0.0000001" value="{{ old('longitude', $country->longitude) }}" class="{{ $textInput }}" />
|
||||
@error('longitude')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="sort_order">Sort order</label>
|
||||
<input id="sort_order" name="sort_order" type="number" min="0" value="{{ old('sort_order', $country->sort_order ?? 0) }}" class="{{ $textInput }}" />
|
||||
@error('sort_order')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="ioc_code">IOC code</label>
|
||||
<input id="ioc_code" name="ioc_code" value="{{ old('ioc_code', $country->ioc_code) }}" class="{{ $textInput }} uppercase" />
|
||||
@error('ioc_code')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="fifa_code">FIFA code</label>
|
||||
<input id="fifa_code" name="fifa_code" value="{{ old('fifa_code', $country->fifa_code) }}" class="{{ $textInput }} uppercase" />
|
||||
@error('fifa_code')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="postal_code_format">Postal-code format</label>
|
||||
<input id="postal_code_format" name="postal_code_format" value="{{ old('postal_code_format', $country->postal_code_format) }}" class="{{ $textInput }}" />
|
||||
@error('postal_code_format')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="{{ $labelClass }}" for="flag_svg_url">Flag SVG URL</label>
|
||||
<input id="flag_svg_url" name="flag_svg_url" value="{{ old('flag_svg_url', $country->flag_svg_url) }}" class="{{ $textInput }}" />
|
||||
@error('flag_svg_url')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="{{ $labelClass }}" for="flag_png_url">Flag PNG URL</label>
|
||||
<input id="flag_png_url" name="flag_png_url" value="{{ old('flag_png_url', $country->flag_png_url) }}" class="{{ $textInput }}" />
|
||||
@error('flag_png_url')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-100">JSON metadata</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">Use valid JSON. Examples: languages as <code class="rounded bg-zinc-100 px-1 dark:bg-zinc-800">["en"]</code>, timezones as <code class="rounded bg-zinc-100 px-1 dark:bg-zinc-800">["Europe/London"]</code>.</p>
|
||||
<div class="mt-4 grid gap-5 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="languages">Languages JSON</label>
|
||||
<textarea id="languages" name="languages" class="{{ $textareaInput }}">{{ old('languages', $country->languages ? json_encode($country->languages, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '') }}</textarea>
|
||||
@error('languages')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="timezones">Timezones JSON</label>
|
||||
<textarea id="timezones" name="timezones" class="{{ $textareaInput }}">{{ old('timezones', $country->timezones ? json_encode($country->timezones, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '') }}</textarea>
|
||||
@error('timezones')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="metadata">Metadata JSON</label>
|
||||
<textarea id="metadata" name="metadata" class="{{ $textareaInput }}">{{ old('metadata', $country->metadata ? json_encode($country->metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '') }}</textarea>
|
||||
@error('metadata')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-zinc-100">Operational support</h2>
|
||||
<div class="mt-4 grid gap-5 md:grid-cols-3">
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<input type="hidden" name="is_active" value="0" />
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $country->exists ? $country->is_active : true)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span><span class="block text-sm font-semibold">Supported {!! $required !!}</span><span class="block text-xs text-zinc-500">Available in WMS selectors and workflows.</span></span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<input type="hidden" name="independent" value="0" />
|
||||
<input type="checkbox" name="independent" value="1" @checked(old('independent', $country->independent)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span><span class="block text-sm font-semibold">Independent state</span><span class="block text-xs text-zinc-500">Used for reporting and compliance filters.</span></span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<input type="hidden" name="un_member" value="0" />
|
||||
<input type="checkbox" name="un_member" value="1" @checked(old('un_member', $country->un_member)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span><span class="block text-sm font-semibold">UN member</span><span class="block text-xs text-zinc-500">Optional global reference attribute.</span></span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="source">Source</label>
|
||||
<input id="source" name="source" value="{{ old('source', $country->source) }}" class="{{ $textInput }}" />
|
||||
@error('source')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="source_version">Source version</label>
|
||||
<input id="source_version" name="source_version" value="{{ old('source_version', $country->source_version) }}" class="{{ $textInput }}" />
|
||||
@error('source_version')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="{{ $labelClass }}" for="last_synced_at">Last synced at</label>
|
||||
<input id="last_synced_at" name="last_synced_at" type="datetime-local" value="{{ old('last_synced_at', $country->last_synced_at?->format('Y-m-d\\TH:i')) }}" class="{{ $textInput }}" />
|
||||
@error('last_synced_at')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
<label class="{{ $labelClass }}" for="address_format">Address format</label>
|
||||
<textarea id="address_format" name="address_format" class="{{ $textareaInput }}">{{ old('address_format', $country->address_format) }}</textarea>
|
||||
@error('address_format')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<x-layouts.app>
|
||||
<div class="mx-auto max-w-[1400px] space-y-6" dusk="countries-create">
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country support</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Create country</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Create a governed country reference record for platform, address, billing, carrier, customs and integration workflows.</p>
|
||||
</div>
|
||||
<a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back to countries</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('countries.store') }}" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
@csrf
|
||||
<div class="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
|
||||
Fields marked with <span class="font-bold text-red-600 dark:text-red-300">*</span> are mandatory ISO/WMS support fields.
|
||||
</div>
|
||||
|
||||
@include('countries::_form', ['country' => $country])
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-2 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
||||
<button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Create country</button>
|
||||
<a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-zinc-100 px-4 py-2.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
@@ -1,66 +1,31 @@
|
||||
<x-layouts.app>
|
||||
<div class="mx-auto max-w-[1000px] space-y-6" dusk="countries-edit">
|
||||
<div class="mx-auto max-w-[1400px] space-y-6" dusk="countries-edit">
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country support</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Edit {{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">ISO identity codes are locked. Platform administrators may control support status, display naming and operational metadata.</p>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country support</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Edit {{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Expose and manage every ISO, operational, regional, currency, logistics and WMS support parameter.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">View country</a>
|
||||
<a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('countries.update', $country) }}" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-sm font-semibold" for="display_name">Display name override</label>
|
||||
<input id="display_name" name="display_name" value="{{ old('display_name', $country->display_name) }}" class="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" />
|
||||
@error('display_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="sort_order">Sort order</label>
|
||||
<input id="sort_order" name="sort_order" type="number" min="0" value="{{ old('sort_order', $country->sort_order) }}" class="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" />
|
||||
@error('sort_order')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="iso_status">ISO lifecycle status</label>
|
||||
<select id="iso_status" name="iso_status" class="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">
|
||||
@foreach (\Modules\Countries\Support\Iso3166::statuses() as $status)
|
||||
<option value="{{ $status }}" @selected(old('iso_status', $country->iso_status ?? 'official') === $status)>{{ str($status)->replace('_', ' ')->title() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('iso_status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="region">Region</label>
|
||||
<input id="region" name="region" value="{{ old('region', $country->region) }}" class="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" />
|
||||
@error('region')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="subregion">Subregion</label>
|
||||
<input id="subregion" name="subregion" value="{{ old('subregion', $country->subregion) }}" class="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" />
|
||||
@error('subregion')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<input type="hidden" name="is_active" value="0" />
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $country->is_active)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span><span class="block text-sm font-semibold">Supported in business workflows</span><span class="block text-xs text-zinc-500">Unsupported countries stay in the ISO table but are hidden from normal selectors.</span></span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<input type="hidden" name="independent" value="0" />
|
||||
<input type="checkbox" name="independent" value="1" @checked(old('independent', $country->independent)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span><span class="block text-sm font-semibold">Independent state</span><span class="block text-xs text-zinc-500">Optional reference flag for reporting and compliance workflows.</span></span>
|
||||
</label>
|
||||
<div class="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
|
||||
Fields marked with <span class="font-bold text-red-600 dark:text-red-300">*</span> are mandatory ISO/WMS support fields.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
@include('countries::_form', ['country' => $country])
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-2 border-t border-zinc-200 pt-6 dark:border-zinc-800">
|
||||
<button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Save country</button>
|
||||
<a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-xl bg-zinc-100 px-4 py-2.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700">Cancel</a>
|
||||
</div>
|
||||
|
||||
@@ -2,24 +2,87 @@
|
||||
<div class="mx-auto max-w-[1800px] space-y-6" dusk="countries-index">
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Reference data</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Countries</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">ISO 3166 country records used by addresses, tenants, billing, carriers, customs and integrations.</p>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Reference data</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Countries</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Governed ISO 3166 country records for tenants, companies, warehouses, billing, shipping, carriers, customs and integrations.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="POST" action="{{ route('countries.sync') }}">
|
||||
@csrf
|
||||
<button type="submit" class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Sync ISO data</button>
|
||||
</form>
|
||||
<a href="{{ route('countries.create') }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">New country</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (session('success'))<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('success') }}</div>@endif
|
||||
|
||||
@if (session('success'))
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="text-sm text-zinc-500">Total countries</div><div class="mt-2 text-3xl font-semibold">{{ number_format($totalCount) }}</div></div>
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="text-sm text-zinc-500">Active</div><div class="mt-2 text-3xl font-semibold text-emerald-700 dark:text-emerald-300">{{ number_format($activeCount) }}</div></div>
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="text-sm text-zinc-500">Inactive</div><div class="mt-2 text-3xl font-semibold text-amber-700 dark:text-amber-300">{{ number_format($inactiveCount) }}</div></div>
|
||||
</div>
|
||||
<form method="GET" action="{{ route('countries.index') }}" class="flex flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:flex-row md:items-center">
|
||||
<input name="search" value="{{ $search }}" placeholder="Search by country, ISO code or numeric code" class="min-h-11 flex-1 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" />
|
||||
<select name="status" class="min-h-11 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"><option value="all" @selected($status === 'all')>All</option><option value="active" @selected($status === 'active')>Active</option><option value="inactive" @selected($status === 'inactive')>Inactive</option></select>
|
||||
|
||||
<form method="GET" action="{{ route('countries.index') }}" class="grid gap-3 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:grid-cols-[1fr_220px_220px_auto] md:items-center">
|
||||
<input name="search" value="{{ $search }}" placeholder="Search by country, ISO code, currency or calling code" class="min-h-11 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" />
|
||||
<select name="status" class="min-h-11 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">
|
||||
<option value="all" @selected($status === 'all')>All statuses</option>
|
||||
<option value="active" @selected($status === 'active')>Active</option>
|
||||
<option value="inactive" @selected($status === 'inactive')>Inactive</option>
|
||||
</select>
|
||||
<select name="region" class="min-h-11 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">
|
||||
<option value="all" @selected($region === 'all')>All regions</option>
|
||||
@foreach ($regions as $availableRegion)
|
||||
<option value="{{ $availableRegion }}" @selected($region === $availableRegion)>{{ $availableRegion }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Filter</button>
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800"><thead class="bg-zinc-50 dark:bg-zinc-950"><tr><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Country</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-2</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-3</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Numeric</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">ISO status</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Status</th><th class="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-zinc-500">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($countries as $country)<tr class="hover:bg-blue-50/50 dark:hover:bg-blue-950/20"><td class="px-4 py-3"><div class="flex items-center gap-3"><span class="text-xl">{{ $country->flag_emoji }}</span><div><div class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $country->effective_name }}</div><div class="text-xs text-zinc-500">{{ $country->official_name ?: $country->name }}</div></div></div></td><td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $country->alpha2 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->alpha3 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->numeric }}</td><td class="px-4 py-3 text-sm text-zinc-500">{{ str($country->iso_status ?? 'official')->replace('_', ' ')->title() }}</td><td class="px-4 py-3">@if ($country->is_active)<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-bold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Active</span>@else<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950 dark:text-amber-200">Inactive</span>@endif</td><td class="px-4 py-3 text-right"><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a> <a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Edit</a></td></tr>@empty<tr><td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-500">No countries matched your search.</td></tr>@endforelse
|
||||
</tbody></table></div><div class="border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">{{ $countries->links() }}</div></div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
<thead class="bg-zinc-50 dark:bg-zinc-950">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Country</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">ISO</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Region</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Currency</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Support</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-zinc-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($countries as $country)
|
||||
<tr class="hover:bg-blue-50/50 dark:hover:bg-blue-950/20">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">{{ $country->flag_emoji }}</span>
|
||||
<div>
|
||||
<div class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $country->effective_name }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ $country->official_name ?: $country->name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3"><div class="font-mono text-sm text-blue-700 dark:text-blue-300">{{ $country->alpha2 }} / {{ $country->alpha3 }} / {{ $country->numeric }}</div><div class="text-xs text-zinc-500">{{ str($country->iso_status ?? 'official')->replace('_', ' ')->title() }}</div></td>
|
||||
<td class="px-4 py-3 text-sm text-zinc-600 dark:text-zinc-300"><div>{{ $country->region ?: '—' }}</div><div class="text-xs text-zinc-500">{{ $country->subregion ?: '—' }}</div></td>
|
||||
<td class="px-4 py-3 text-sm"><div class="font-mono text-zinc-900 dark:text-zinc-100">{{ $country->currency_code ?: '—' }}</div><div class="text-xs text-zinc-500">{{ $country->currency_name ?: '—' }}</div></td>
|
||||
<td class="px-4 py-3">@if ($country->is_active)<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-bold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Active</span>@else<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950 dark:text-amber-200">Inactive</span>@endif</td>
|
||||
<td class="px-4 py-3 text-right"><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a> <a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Edit</a></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-500">No countries matched your search.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">{{ $countries->links() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<x-layouts.app>
|
||||
<div class="mx-auto max-w-[1200px] space-y-6" dusk="countries-show">
|
||||
<div class="mx-auto max-w-[1400px] space-y-6" dusk="countries-show">
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">{{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">{{ $country->official_name ?: $country->name }}</p>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700">Edit support</a>
|
||||
<a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back</a>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">{{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">{{ $country->official_name ?: $country->name }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700">Edit country</a>
|
||||
<a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,8 +20,8 @@
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@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
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($fields as $label => $value)
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wide text-zinc-500">{{ $label }}</div>
|
||||
<div class="mt-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ $value }}</div>
|
||||
<div class="mt-2 break-words text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ $value }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
@foreach (['Languages' => $country->languages, 'Timezones' => $country->timezones, 'Metadata' => $country->metadata] as $label => $value)
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $label }}</h2>
|
||||
<pre class="mt-3 max-h-80 overflow-auto rounded-xl bg-zinc-950 p-4 text-xs text-zinc-100">{{ $value ? json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : '—' }}</pre>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($country->address_format || $country->flag_svg_url || $country->flag_png_url)
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 lg:col-span-2">
|
||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Address format</h2>
|
||||
<pre class="mt-3 whitespace-pre-wrap rounded-xl bg-zinc-100 p-4 text-sm text-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">{{ $country->address_format ?: '—' }}</pre>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Flag assets</h2>
|
||||
<div class="mt-3 space-y-2 text-sm text-zinc-600 dark:text-zinc-300">
|
||||
<div>SVG: {{ $country->flag_svg_url ?: '—' }}</div>
|
||||
<div>PNG: {{ $country->flag_png_url ?: '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
|
||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">WMS usage report</h2>
|
||||
<p class="text-xs text-zinc-500">Best-effort counts across known WMS tables. Missing optional tables are skipped safely.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
<thead class="bg-zinc-50 dark:bg-zinc-950">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Area</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Table</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Column</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-zinc-500">References</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($usageRows ?? [] as $row)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $row['label'] }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $row['table'] }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-blue-700 dark:text-blue-300">{{ $row['column'] }}</td>
|
||||
<td class="px-4 py-3 text-right text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ number_format($row['count']) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-zinc-500">No country-aware WMS usage tables were detected yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
|
||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">ISO 3166-2 subdivisions</h2>
|
||||
<p class="text-xs text-zinc-500">Subdivisions are stored separately for address, compliance and shipping workflows.</p>
|
||||
<p class="text-xs text-zinc-500">Subdivisions support address, compliance and shipping workflows.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
@@ -54,7 +139,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($country->subdivisions()->ordered()->limit(100)->get() as $subdivision)
|
||||
@forelse ($country->subdivisions as $subdivision)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $subdivision->code }}</td>
|
||||
<td class="px-4 py-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $subdivision->name }}</td>
|
||||
@@ -68,5 +153,17 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('countries.destroy', $country) }}" class="rounded-2xl border border-red-200 bg-red-50 p-5 dark:border-red-900 dark:bg-red-950">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-red-900 dark:text-red-100">Archive country</h2>
|
||||
<p class="text-sm text-red-700 dark:text-red-200">Soft-deletes the record for administration. Do not archive countries referenced by active business data.</p>
|
||||
</div>
|
||||
<button type="submit" class="rounded-xl bg-red-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-red-700" onclick="return confirm('Archive this country?')">Archive</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Countries\Models\Country;
|
||||
|
||||
class CountryUsageReport
|
||||
{
|
||||
/**
|
||||
* Tables commonly using country_id in WMS and SaaS workflows.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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<int, array{label: string, table: string, column: string, count: int}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user