diff --git a/.env.example b/.env.example index 81ce928..a4eae50 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ APP_KEY= APP_DEBUG=true APP_URL=http://localhost +# Multi-tenancy 設定 (用逗號分隔多個中央網域) +CENTRAL_DOMAINS=localhost,127.0.0.1 + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Console/Commands/MigrateToTenant.php b/app/Console/Commands/MigrateToTenant.php new file mode 100644 index 0000000..0f1f0fd --- /dev/null +++ b/app/Console/Commands/MigrateToTenant.php @@ -0,0 +1,131 @@ +argument('tenant_id'); + $dryRun = $this->option('dry-run'); + + // 檢查租戶是否存在 + $tenant = Tenant::find($tenantId); + if (!$tenant) { + $this->error("租戶 '{$tenantId}' 不存在!請先建立租戶。"); + return self::FAILURE; + } + + $this->info("開始遷移資料到租戶: {$tenantId}"); + $this->info("租戶資料庫: tenant{$tenantId}"); + + if ($dryRun) { + $this->warn('⚠️ Dry Run 模式 - 不會實際遷移資料'); + } + + // 取得 central 資料庫連線 + $centralConnection = config('database.default'); + $tenantDbName = 'tenant' . $tenantId; + + foreach ($this->tablesToMigrate as $table) { + // 檢查表是否存在於 central + if (!Schema::connection($centralConnection)->hasTable($table)) { + $this->line(" ⏭️ 跳過 {$table} (表不存在)"); + continue; + } + + // 計算資料筆數 + $count = DB::connection($centralConnection)->table($table)->count(); + if ($count === 0) { + $this->line(" ⏭️ 跳過 {$table} (無資料)"); + continue; + } + + if ($dryRun) { + $this->info(" 📋 {$table}: {$count} 筆資料"); + continue; + } + + // 實際遷移資料 + $this->info(" 🔄 遷移 {$table}: {$count} 筆資料..."); + + try { + // 使用租戶上下文執行 + $tenant->run(function () use ($centralConnection, $table) { + // 取得 central 資料 + $data = DB::connection($centralConnection)->table($table)->get(); + + if ($data->isEmpty()) { + return; + } + + // 關閉外鍵檢查 + DB::statement('SET FOREIGN_KEY_CHECKS=0'); + + // 清空目標表 + DB::table($table)->truncate(); + + // 分批插入 (每批 100 筆) + foreach ($data->chunk(100) as $chunk) { + DB::table($table)->insert($chunk->map(fn($item) => (array) $item)->toArray()); + } + + // 恢復外鍵檢查 + DB::statement('SET FOREIGN_KEY_CHECKS=1'); + }); + + $this->info(" ✅ {$table} 遷移完成"); + } catch (\Exception $e) { + $this->error(" ❌ {$table} 遷移失敗: " . $e->getMessage()); + return self::FAILURE; + } + } + + $this->newLine(); + $this->info('🎉 資料遷移完成!'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Landlord/DashboardController.php b/app/Http/Controllers/Landlord/DashboardController.php new file mode 100644 index 0000000..82680d1 --- /dev/null +++ b/app/Http/Controllers/Landlord/DashboardController.php @@ -0,0 +1,29 @@ + Tenant::count(), + 'activeTenants' => Tenant::whereJsonContains('data->is_active', true)->count(), + 'recentTenants' => Tenant::latest()->take(5)->get()->map(function ($tenant) { + return [ + 'id' => $tenant->id, + 'name' => $tenant->name ?? $tenant->id, + 'is_active' => $tenant->is_active ?? true, + 'created_at' => $tenant->created_at->format('Y-m-d H:i'), + 'domains' => $tenant->domains->pluck('domain')->toArray(), + ]; + }), + ]; + + return Inertia::render('Landlord/Dashboard', $stats); + } +} diff --git a/app/Http/Controllers/Landlord/TenantController.php b/app/Http/Controllers/Landlord/TenantController.php new file mode 100644 index 0000000..98ad551 --- /dev/null +++ b/app/Http/Controllers/Landlord/TenantController.php @@ -0,0 +1,172 @@ +get()->map(function ($tenant) { + return [ + 'id' => $tenant->id, + 'name' => $tenant->name ?? $tenant->id, + 'email' => $tenant->email ?? null, + 'is_active' => $tenant->is_active ?? true, + 'created_at' => $tenant->created_at->format('Y-m-d H:i'), + 'domains' => $tenant->domains->pluck('domain')->toArray(), + ]; + }); + + return Inertia::render('Landlord/Tenant/Index', [ + 'tenants' => $tenants, + ]); + } + + /** + * 顯示新增租戶表單 + */ + public function create() + { + return Inertia::render('Landlord/Tenant/Create'); + } + + /** + * 儲存新租戶 + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')], + 'name' => ['required', 'string', 'max:100'], + 'email' => ['nullable', 'email', 'max:100'], + 'domain' => ['nullable', 'string', 'max:100'], + ]); + + $tenant = Tenant::create([ + 'id' => $validated['id'], + 'name' => $validated['name'], + 'email' => $validated['email'] ?? null, + 'is_active' => true, + ]); + + // 如果有指定域名,則綁定 + if (!empty($validated['domain'])) { + $tenant->domains()->create(['domain' => $validated['domain']]); + } + + return redirect()->route('landlord.tenants.index') + ->with('success', "租戶 {$validated['name']} 建立成功!"); + } + + /** + * 顯示單一租戶詳情 + */ + public function show(string $id) + { + $tenant = Tenant::with('domains')->findOrFail($id); + + return Inertia::render('Landlord/Tenant/Show', [ + 'tenant' => [ + 'id' => $tenant->id, + 'name' => $tenant->name ?? $tenant->id, + 'email' => $tenant->email ?? null, + 'is_active' => $tenant->is_active ?? true, + 'created_at' => $tenant->created_at->format('Y-m-d H:i'), + 'updated_at' => $tenant->updated_at->format('Y-m-d H:i'), + 'domains' => $tenant->domains->map(fn($d) => [ + 'id' => $d->id, + 'domain' => $d->domain, + ])->toArray(), + ], + ]); + } + + /** + * 顯示編輯租戶表單 + */ + public function edit(string $id) + { + $tenant = Tenant::findOrFail($id); + + return Inertia::render('Landlord/Tenant/Edit', [ + 'tenant' => [ + 'id' => $tenant->id, + 'name' => $tenant->name ?? $tenant->id, + 'email' => $tenant->email ?? null, + 'is_active' => $tenant->is_active ?? true, + ], + ]); + } + + /** + * 更新租戶 + */ + public function update(Request $request, string $id) + { + $tenant = Tenant::findOrFail($id); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:100'], + 'email' => ['nullable', 'email', 'max:100'], + 'is_active' => ['boolean'], + ]); + + $tenant->update($validated); + + return redirect()->route('landlord.tenants.index') + ->with('success', "租戶 {$validated['name']} 更新成功!"); + } + + /** + * 刪除租戶 + */ + public function destroy(string $id) + { + $tenant = Tenant::findOrFail($id); + $name = $tenant->name ?? $id; + + $tenant->delete(); + + return redirect()->route('landlord.tenants.index') + ->with('success', "租戶 {$name} 已刪除!"); + } + + /** + * 新增域名到租戶 + */ + public function addDomain(Request $request, string $id) + { + $tenant = Tenant::findOrFail($id); + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:100', Rule::unique('domains', 'domain')], + ]); + + $tenant->domains()->create(['domain' => $validated['domain']]); + + return back()->with('success', "域名 {$validated['domain']} 已綁定!"); + } + + /** + * 移除租戶的域名 + */ + public function removeDomain(string $id, int $domainId) + { + $tenant = Tenant::findOrFail($id); + $domain = $tenant->domains()->findOrFail($domainId); + $domainName = $domain->domain; + + $domain->delete(); + + return back()->with('success', "域名 {$domainName} 已移除!"); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php new file mode 100644 index 0000000..09d7601 --- /dev/null +++ b/app/Models/Tenant.php @@ -0,0 +1,34 @@ +name 存取): + * - name: 租戶名稱 (如: 小小冰室) + * - email: 聯絡信箱 + * - is_active: 是否啟用 + */ +class Tenant extends BaseTenant implements TenantWithDatabase +{ + use HasDatabase, HasDomains; + + /** + * 定義獨立欄位 (非 data JSON) + * 只有 id 是獨立欄位,其他自訂屬性都存在 data JSON 中 + */ + public static function getCustomColumns(): array + { + return [ + 'id', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1d2694d..641a050 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\Route; class AppServiceProvider extends ServiceProvider { @@ -26,5 +27,13 @@ class AppServiceProvider extends ServiceProvider \Illuminate\Support\Facades\Gate::before(function ($user, $ability) { return $user->hasRole('super-admin') ? true : null; }); + + // 載入房東後台路由 (只在 central domain 可用) + $this->app->booted(function () { + if (file_exists(base_path('routes/landlord.php'))) { + Route::middleware('web')->group(base_path('routes/landlord.php')); + } + }); } } + diff --git a/app/Providers/TenancyServiceProvider.php b/app/Providers/TenancyServiceProvider.php new file mode 100644 index 0000000..d94703c --- /dev/null +++ b/app/Providers/TenancyServiceProvider.php @@ -0,0 +1,148 @@ + [], + Events\TenantCreated::class => [ + JobPipeline::make([ + Jobs\CreateDatabase::class, + Jobs\MigrateDatabase::class, + // Jobs\SeedDatabase::class, + + // Your own jobs to prepare the tenant. + // Provision API keys, create S3 buckets, anything you want! + + ])->send(function (Events\TenantCreated $event) { + return $event->tenant; + })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. + ], + Events\SavingTenant::class => [], + Events\TenantSaved::class => [], + Events\UpdatingTenant::class => [], + Events\TenantUpdated::class => [], + Events\DeletingTenant::class => [], + Events\TenantDeleted::class => [ + JobPipeline::make([ + Jobs\DeleteDatabase::class, + ])->send(function (Events\TenantDeleted $event) { + return $event->tenant; + })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. + ], + + // Domain events + Events\CreatingDomain::class => [], + Events\DomainCreated::class => [], + Events\SavingDomain::class => [], + Events\DomainSaved::class => [], + Events\UpdatingDomain::class => [], + Events\DomainUpdated::class => [], + Events\DeletingDomain::class => [], + Events\DomainDeleted::class => [], + + // Database events + Events\DatabaseCreated::class => [], + Events\DatabaseMigrated::class => [], + Events\DatabaseSeeded::class => [], + Events\DatabaseRolledBack::class => [], + Events\DatabaseDeleted::class => [], + + // Tenancy events + Events\InitializingTenancy::class => [], + Events\TenancyInitialized::class => [ + Listeners\BootstrapTenancy::class, + ], + + Events\EndingTenancy::class => [], + Events\TenancyEnded::class => [ + Listeners\RevertToCentralContext::class, + ], + + Events\BootstrappingTenancy::class => [], + Events\TenancyBootstrapped::class => [], + Events\RevertingToCentralContext::class => [], + Events\RevertedToCentralContext::class => [], + + // Resource syncing + Events\SyncedResourceSaved::class => [ + Listeners\UpdateSyncedResource::class, + ], + + // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) + Events\SyncedResourceChangedInForeignDatabase::class => [], + ]; + } + + public function register() + { + // + } + + public function boot() + { + $this->bootEvents(); + $this->mapRoutes(); + + $this->makeTenancyMiddlewareHighestPriority(); + } + + protected function bootEvents() + { + foreach ($this->events() as $event => $listeners) { + foreach ($listeners as $listener) { + if ($listener instanceof JobPipeline) { + $listener = $listener->toListener(); + } + + Event::listen($event, $listener); + } + } + } + + protected function mapRoutes() + { + $this->app->booted(function () { + if (file_exists(base_path('routes/tenant.php'))) { + Route::namespace(static::$controllerNamespace) + ->group(base_path('routes/tenant.php')); + } + }); + } + + protected function makeTenancyMiddlewareHighestPriority() + { + $tenancyMiddleware = [ + // Even higher priority than the initialization middleware + Middleware\PreventAccessFromCentralDomains::class, + + Middleware\InitializeTenancyByDomain::class, + Middleware\InitializeTenancyBySubdomain::class, + Middleware\InitializeTenancyByDomainOrSubdomain::class, + Middleware\InitializeTenancyByPath::class, + Middleware\InitializeTenancyByRequestData::class, + ]; + + foreach (array_reverse($tenancyMiddleware) as $middleware) { + $this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware); + } + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..c6a4f12 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\TenancyServiceProvider::class, ]; diff --git a/composer.json b/composer.json index e3ca062..b04034e 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "spatie/laravel-permission": "^6.24", + "stancl/tenancy": "^3.9", "tightenco/ziggy": "^2.6" }, "require-dev": { diff --git a/composer.lock b/composer.lock index fe204f0..2c30e85 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "edfbf8e1cc43c925ea8b04fc1f93da65", + "content-hash": "931b01f076d9ee28568cd36f178a0c04", "packages": [ { "name": "brick/math", @@ -508,6 +508,59 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "facade/ignition-contracts", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/facade/ignition-contracts.git", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v2.15.8", + "phpunit/phpunit": "^9.3.11", + "vimeo/psalm": "^3.17.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Facade\\IgnitionContracts\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://flareapp.io", + "role": "Developer" + } + ], + "description": "Solution contracts for Ignition", + "homepage": "https://github.com/facade/ignition-contracts", + "keywords": [ + "contracts", + "flare", + "ignition" + ], + "support": { + "issues": "https://github.com/facade/ignition-contracts/issues", + "source": "https://github.com/facade/ignition-contracts/tree/1.0.2" + }, + "time": "2020-10-16T08:27:54+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -3443,6 +3496,179 @@ ], "time": "2025-12-13T21:45:21+00:00" }, + { + "name": "stancl/jobpipeline", + "version": "v1.8.1", + "source": { + "type": "git", + "url": "https://github.com/archtechx/jobpipeline.git", + "reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/archtechx/jobpipeline/zipball/c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c", + "reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c", + "shasum": "" + }, + "require": { + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "ext-redis": "*", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "spatie/valuestore": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Stancl\\JobPipeline\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Štancl", + "email": "samuel.stancl@gmail.com" + } + ], + "description": "Turn any series of jobs into Laravel listeners.", + "support": { + "issues": "https://github.com/archtechx/jobpipeline/issues", + "source": "https://github.com/archtechx/jobpipeline/tree/v1.8.1" + }, + "time": "2025-07-29T20:21:17+00:00" + }, + { + "name": "stancl/tenancy", + "version": "v3.9.1", + "source": { + "type": "git", + "url": "https://github.com/archtechx/tenancy.git", + "reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/archtechx/tenancy/zipball/d98a170fbd2e114604bfec3bc6267a3d6e02dec1", + "reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "facade/ignition-contracts": "^1.0.2", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.0", + "ramsey/uuid": "^4.7.3", + "stancl/jobpipeline": "^1.8.0", + "stancl/virtualcolumn": "^1.5.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.0", + "laravel/framework": "^10.0|^11.0|^12.0", + "league/flysystem-aws-s3-v3": "^3.12.2", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "spatie/valuestore": "^1.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Tenancy": "Stancl\\Tenancy\\Facades\\Tenancy", + "GlobalCache": "Stancl\\Tenancy\\Facades\\GlobalCache" + }, + "providers": [ + "Stancl\\Tenancy\\TenancyServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Stancl\\Tenancy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Štancl", + "email": "samuel.stancl@gmail.com" + } + ], + "description": "Automatic multi-tenancy for your Laravel application.", + "keywords": [ + "laravel", + "multi-database", + "multi-tenancy", + "tenancy" + ], + "support": { + "issues": "https://github.com/archtechx/tenancy/issues", + "source": "https://github.com/archtechx/tenancy/tree/v3.9.1" + }, + "funding": [ + { + "url": "https://tenancyforlaravel.com/donate", + "type": "custom" + }, + { + "url": "https://github.com/stancl", + "type": "github" + } + ], + "time": "2025-03-13T16:02:11+00:00" + }, + { + "name": "stancl/virtualcolumn", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/archtechx/virtualcolumn.git", + "reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/archtechx/virtualcolumn/zipball/75718edcfeeb19abc1970f5395043f7d43cce5bc", + "reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc", + "shasum": "" + }, + "require": { + "illuminate/database": ">=10.0", + "illuminate/support": ">=10.0" + }, + "require-dev": { + "orchestra/testbench": ">=8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Stancl\\VirtualColumn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Štancl", + "email": "samuel.stancl@gmail.com" + } + ], + "description": "Eloquent virtual column.", + "support": { + "issues": "https://github.com/archtechx/virtualcolumn/issues", + "source": "https://github.com/archtechx/virtualcolumn/tree/v1.5.0" + }, + "time": "2025-02-25T13:12:44+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", diff --git a/config/tenancy.php b/config/tenancy.php new file mode 100644 index 0000000..73908cf --- /dev/null +++ b/config/tenancy.php @@ -0,0 +1,199 @@ + Tenant::class, + 'id_generator' => Stancl\Tenancy\UUIDGenerator::class, + + 'domain_model' => Domain::class, + + /** + * The list of domains hosting your central app. + * + * Only relevant if you're using the domain or subdomain identification middleware. + */ + 'central_domains' => [ + '127.0.0.1', + 'localhost', + ], + + /** + * Tenancy bootstrappers are executed when tenancy is initialized. + * Their responsibility is making Laravel features tenant-aware. + * + * To configure their behavior, see the config keys below. + */ + 'bootstrappers' => [ + Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class, + Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class, + Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class, + Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, + // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed + ], + + /** + * Database tenancy config. Used by DatabaseTenancyBootstrapper. + */ + 'database' => [ + 'central_connection' => env('DB_CONNECTION', 'central'), + + /** + * Connection used as a "template" for the dynamically created tenant database connection. + * Note: don't name your template connection tenant. That name is reserved by package. + */ + 'template_tenant_connection' => null, + + /** + * Tenant database names are created like this: + * prefix + tenant_id + suffix. + */ + 'prefix' => 'tenant', + 'suffix' => '', + + /** + * TenantDatabaseManagers are classes that handle the creation & deletion of tenant databases. + */ + 'managers' => [ + 'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class, + 'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class, + 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class, + + /** + * Use this database manager for MySQL to have a DB user created for each tenant database. + * You can customize the grants given to these users by changing the $grants property. + */ + // 'mysql' => Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class, + + /** + * Disable the pgsql manager above, and enable the one below if you + * want to separate tenant DBs by schemas rather than databases. + */ + // 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database + ], + ], + + /** + * Cache tenancy config. Used by CacheTenancyBootstrapper. + * + * This works for all Cache facade calls, cache() helper + * calls and direct calls to injected cache stores. + * + * Each key in cache will have a tag applied on it. This tag is used to + * scope the cache both when writing to it and when reading from it. + * + * You can clear cache selectively by specifying the tag. + */ + 'cache' => [ + 'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call. + ], + + /** + * Filesystem tenancy config. Used by FilesystemTenancyBootstrapper. + * https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper. + */ + 'filesystem' => [ + /** + * Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id. + */ + 'suffix_base' => 'tenant', + 'disks' => [ + 'local', + 'public', + // 's3', + ], + + /** + * Use this for local disks. + * + * See https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper + */ + 'root_override' => [ + // Disks whose roots should be overridden after storage_path() is suffixed. + 'local' => '%storage_path%/app/', + 'public' => '%storage_path%/app/public/', + ], + + /** + * Should storage_path() be suffixed. + * + * Note: Disabling this will likely break local disk tenancy. Only disable this if you're using an external file storage service like S3. + * + * For the vast majority of applications, this feature should be enabled. But in some + * edge cases, it can cause issues (like using Passport with Vapor - see #196), so + * you may want to disable this if you are experiencing these edge case issues. + */ + 'suffix_storage_path' => true, + + /** + * By default, asset() calls are made multi-tenant too. You can use global_asset() and mix() + * for global, non-tenant-specific assets. However, you might have some issues when using + * packages that use asset() calls inside the tenant app. To avoid such issues, you can + * disable asset() helper tenancy and explicitly use tenant_asset() calls in places + * where you want to use tenant-specific assets (product images, avatars, etc). + */ + 'asset_helper_tenancy' => true, + ], + + /** + * Redis tenancy config. Used by RedisTenancyBootstrapper. + * + * Note: You need phpredis to use Redis tenancy. + * + * Note: You don't need to use this if you're using Redis only for cache. + * Redis tenancy is only relevant if you're making direct Redis calls, + * either using the Redis facade or by injecting it as a dependency. + */ + 'redis' => [ + 'prefix_base' => 'tenant', // Each key in Redis will be prepended by this prefix_base, followed by the tenant id. + 'prefixed_connections' => [ // Redis connections whose keys are prefixed, to separate one tenant's keys from another. + // 'default', + ], + ], + + /** + * Features are classes that provide additional functionality + * not needed for tenancy to be bootstrapped. They are run + * regardless of whether tenancy has been initialized. + * + * See the documentation page for each class to + * understand which ones you want to enable. + */ + 'features' => [ + // Stancl\Tenancy\Features\UserImpersonation::class, + // Stancl\Tenancy\Features\TelescopeTags::class, + // Stancl\Tenancy\Features\UniversalRoutes::class, + // Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config + // Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect + // Stancl\Tenancy\Features\ViteBundler::class, + ], + + /** + * Should tenancy routes be registered. + * + * Tenancy routes include tenant asset routes. By default, this route is + * enabled. But it may be useful to disable them if you use external + * storage (e.g. S3 / Dropbox) or have a custom asset controller. + */ + 'routes' => true, + + /** + * Parameters used by the tenants:migrate command. + */ + 'migration_parameters' => [ + '--force' => true, // This needs to be true to run migrations in production. + '--path' => [database_path('migrations/tenant')], + '--realpath' => true, + ], + + /** + * Parameters used by the tenants:seed command. + */ + 'seeder_parameters' => [ + '--class' => 'DatabaseSeeder', // root seeder class + // '--force' => true, // This needs to be true to seed tenant databases in production + ], +]; diff --git a/database/migrations/2019_09_15_000010_create_tenants_table.php b/database/migrations/2019_09_15_000010_create_tenants_table.php new file mode 100644 index 0000000..ec73065 --- /dev/null +++ b/database/migrations/2019_09_15_000010_create_tenants_table.php @@ -0,0 +1,37 @@ +string('id')->primary(); + + // your custom columns may go here + + $table->timestamps(); + $table->json('data')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('tenants'); + } +} diff --git a/database/migrations/2019_09_15_000020_create_domains_table.php b/database/migrations/2019_09_15_000020_create_domains_table.php new file mode 100644 index 0000000..77c1b88 --- /dev/null +++ b/database/migrations/2019_09_15_000020_create_domains_table.php @@ -0,0 +1,37 @@ +increments('id'); + $table->string('domain', 255)->unique(); + $table->string('tenant_id'); + + $table->timestamps(); + $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('domains'); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/tenant/0001_01_01_000000_create_users_table.php similarity index 100% rename from database/migrations/0001_01_01_000000_create_users_table.php rename to database/migrations/tenant/0001_01_01_000000_create_users_table.php diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/tenant/0001_01_01_000001_create_cache_table.php similarity index 100% rename from database/migrations/0001_01_01_000001_create_cache_table.php rename to database/migrations/tenant/0001_01_01_000001_create_cache_table.php diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/tenant/0001_01_01_000002_create_jobs_table.php similarity index 100% rename from database/migrations/0001_01_01_000002_create_jobs_table.php rename to database/migrations/tenant/0001_01_01_000002_create_jobs_table.php diff --git a/database/migrations/2025_12_26_151600_create_categories_table.php b/database/migrations/tenant/2025_12_26_151600_create_categories_table.php similarity index 100% rename from database/migrations/2025_12_26_151600_create_categories_table.php rename to database/migrations/tenant/2025_12_26_151600_create_categories_table.php diff --git a/database/migrations/2025_12_26_151700_create_products_table.php b/database/migrations/tenant/2025_12_26_151700_create_products_table.php similarity index 100% rename from database/migrations/2025_12_26_151700_create_products_table.php rename to database/migrations/tenant/2025_12_26_151700_create_products_table.php diff --git a/database/migrations/2025_12_29_012901_create_vendors_table.php b/database/migrations/tenant/2025_12_29_012901_create_vendors_table.php similarity index 100% rename from database/migrations/2025_12_29_012901_create_vendors_table.php rename to database/migrations/tenant/2025_12_29_012901_create_vendors_table.php diff --git a/database/migrations/2025_12_29_033314_optimize_string_columns.php b/database/migrations/tenant/2025_12_29_033314_optimize_string_columns.php similarity index 100% rename from database/migrations/2025_12_29_033314_optimize_string_columns.php rename to database/migrations/tenant/2025_12_29_033314_optimize_string_columns.php diff --git a/database/migrations/2025_12_29_034457_create_warehouse_tables.php b/database/migrations/tenant/2025_12_29_034457_create_warehouse_tables.php similarity index 100% rename from database/migrations/2025_12_29_034457_create_warehouse_tables.php rename to database/migrations/tenant/2025_12_29_034457_create_warehouse_tables.php diff --git a/database/migrations/2025_12_29_064110_update_inventory_transactions_type_comment.php b/database/migrations/tenant/2025_12_29_064110_update_inventory_transactions_type_comment.php similarity index 100% rename from database/migrations/2025_12_29_064110_update_inventory_transactions_type_comment.php rename to database/migrations/tenant/2025_12_29_064110_update_inventory_transactions_type_comment.php diff --git a/database/migrations/2025_12_29_065101_add_actual_time_to_inventory_transactions_table.php b/database/migrations/tenant/2025_12_29_065101_add_actual_time_to_inventory_transactions_table.php similarity index 100% rename from database/migrations/2025_12_29_065101_add_actual_time_to_inventory_transactions_table.php rename to database/migrations/tenant/2025_12_29_065101_add_actual_time_to_inventory_transactions_table.php diff --git a/database/migrations/2025_12_29_101000_create_product_vendor_table.php b/database/migrations/tenant/2025_12_29_101000_create_product_vendor_table.php similarity index 100% rename from database/migrations/2025_12_29_101000_create_product_vendor_table.php rename to database/migrations/tenant/2025_12_29_101000_create_product_vendor_table.php diff --git a/database/migrations/2025_12_30_103000_update_inventories_safety_stock_default.php b/database/migrations/tenant/2025_12_30_103000_update_inventories_safety_stock_default.php similarity index 100% rename from database/migrations/2025_12_30_103000_update_inventories_safety_stock_default.php rename to database/migrations/tenant/2025_12_30_103000_update_inventories_safety_stock_default.php diff --git a/database/migrations/2025_12_30_130612_create_purchase_orders_table.php b/database/migrations/tenant/2025_12_30_130612_create_purchase_orders_table.php similarity index 100% rename from database/migrations/2025_12_30_130612_create_purchase_orders_table.php rename to database/migrations/tenant/2025_12_30_130612_create_purchase_orders_table.php diff --git a/database/migrations/2025_12_30_130613_create_purchase_order_items_table.php b/database/migrations/tenant/2025_12_30_130613_create_purchase_order_items_table.php similarity index 100% rename from database/migrations/2025_12_30_130613_create_purchase_order_items_table.php rename to database/migrations/tenant/2025_12_30_130613_create_purchase_order_items_table.php diff --git a/database/migrations/2025_12_31_172200_make_conversion_rate_nullable.php b/database/migrations/tenant/2025_12_31_172200_make_conversion_rate_nullable.php similarity index 100% rename from database/migrations/2025_12_31_172200_make_conversion_rate_nullable.php rename to database/migrations/tenant/2025_12_31_172200_make_conversion_rate_nullable.php diff --git a/database/migrations/2026_01_07_132554_add_username_to_users_table.php b/database/migrations/tenant/2026_01_07_132554_add_username_to_users_table.php similarity index 100% rename from database/migrations/2026_01_07_132554_add_username_to_users_table.php rename to database/migrations/tenant/2026_01_07_132554_add_username_to_users_table.php diff --git a/database/migrations/2026_01_08_103000_create_units_table.php b/database/migrations/tenant/2026_01_08_103000_create_units_table.php similarity index 100% rename from database/migrations/2026_01_08_103000_create_units_table.php rename to database/migrations/tenant/2026_01_08_103000_create_units_table.php diff --git a/database/migrations/2026_01_08_103500_update_products_table_units.php b/database/migrations/tenant/2026_01_08_103500_update_products_table_units.php similarity index 100% rename from database/migrations/2026_01_08_103500_update_products_table_units.php rename to database/migrations/tenant/2026_01_08_103500_update_products_table_units.php diff --git a/database/migrations/2026_01_08_152856_add_unit_to_purchase_order_items_table.php b/database/migrations/tenant/2026_01_08_152856_add_unit_to_purchase_order_items_table.php similarity index 100% rename from database/migrations/2026_01_08_152856_add_unit_to_purchase_order_items_table.php rename to database/migrations/tenant/2026_01_08_152856_add_unit_to_purchase_order_items_table.php diff --git a/database/migrations/2026_01_08_154909_add_unit_id_to_purchase_order_items_table.php b/database/migrations/tenant/2026_01_08_154909_add_unit_id_to_purchase_order_items_table.php similarity index 100% rename from database/migrations/2026_01_08_154909_add_unit_id_to_purchase_order_items_table.php rename to database/migrations/tenant/2026_01_08_154909_add_unit_id_to_purchase_order_items_table.php diff --git a/database/migrations/2026_01_09_095718_add_invoice_fields_to_purchase_orders_table.php b/database/migrations/tenant/2026_01_09_095718_add_invoice_fields_to_purchase_orders_table.php similarity index 100% rename from database/migrations/2026_01_09_095718_add_invoice_fields_to_purchase_orders_table.php rename to database/migrations/tenant/2026_01_09_095718_add_invoice_fields_to_purchase_orders_table.php diff --git a/database/migrations/2026_01_13_113720_create_permission_tables.php b/database/migrations/tenant/2026_01_13_113720_create_permission_tables.php similarity index 100% rename from database/migrations/2026_01_13_113720_create_permission_tables.php rename to database/migrations/tenant/2026_01_13_113720_create_permission_tables.php diff --git a/database/migrations/2026_01_13_160117_add_display_name_to_roles_table.php b/database/migrations/tenant/2026_01_13_160117_add_display_name_to_roles_table.php similarity index 100% rename from database/migrations/2026_01_13_160117_add_display_name_to_roles_table.php rename to database/migrations/tenant/2026_01_13_160117_add_display_name_to_roles_table.php diff --git a/database/migrations/2026_01_13_160741_add_inventory_delete_permission.php b/database/migrations/tenant/2026_01_13_160741_add_inventory_delete_permission.php similarity index 100% rename from database/migrations/2026_01_13_160741_add_inventory_delete_permission.php rename to database/migrations/tenant/2026_01_13_160741_add_inventory_delete_permission.php diff --git a/database/migrations/2026_01_13_162407_add_safety_stock_permissions.php b/database/migrations/tenant/2026_01_13_162407_add_safety_stock_permissions.php similarity index 100% rename from database/migrations/2026_01_13_162407_add_safety_stock_permissions.php rename to database/migrations/tenant/2026_01_13_162407_add_safety_stock_permissions.php diff --git a/database/migrations/2026_01_13_171300_assign_admin_user_super_admin_role.php b/database/migrations/tenant/2026_01_13_171300_assign_admin_user_super_admin_role.php similarity index 100% rename from database/migrations/2026_01_13_171300_assign_admin_user_super_admin_role.php rename to database/migrations/tenant/2026_01_13_171300_assign_admin_user_super_admin_role.php diff --git a/database/migrations/2026_01_13_171900_sync_super_admin_all_permissions.php b/database/migrations/tenant/2026_01_13_171900_sync_super_admin_all_permissions.php similarity index 100% rename from database/migrations/2026_01_13_171900_sync_super_admin_all_permissions.php rename to database/migrations/tenant/2026_01_13_171900_sync_super_admin_all_permissions.php diff --git a/database/migrations/2026_01_13_172500_ensure_admin_is_super_admin.php b/database/migrations/tenant/2026_01_13_172500_ensure_admin_is_super_admin.php similarity index 100% rename from database/migrations/2026_01_13_172500_ensure_admin_is_super_admin.php rename to database/migrations/tenant/2026_01_13_172500_ensure_admin_is_super_admin.php diff --git a/database/migrations/2026_01_14_090000_update_role_display_names.php b/database/migrations/tenant/2026_01_14_090000_update_role_display_names.php similarity index 100% rename from database/migrations/2026_01_14_090000_update_role_display_names.php rename to database/migrations/tenant/2026_01_14_090000_update_role_display_names.php diff --git a/docs/multi-tenancy-deployment.md b/docs/multi-tenancy-deployment.md new file mode 100644 index 0000000..a86e49e --- /dev/null +++ b/docs/multi-tenancy-deployment.md @@ -0,0 +1,71 @@ +# Multi-tenancy 部署手冊 + +> 記錄本地開發完成後,上 Demo/Production 環境時需要手動執行的操作。 +> CI/CD 會自動執行的項目已排除。 + +--- + +## Step 1: 安裝 stancl/tenancy +**CI/CD 會自動執行**:`composer install` +**手動操作**:無 + +--- + +## Step 2: 設定 Central Domain + Tenant 識別 +**手動操作**: +1. 修改 `.env`,加入: + ```bash + # Demo 環境 (192.168.0.103) + CENTRAL_DOMAINS=192.168.0.103,localhost + + # Production 環境 (erp.koori.tw) + CENTRAL_DOMAINS=erp.koori.tw + ``` + +--- + +## Step 3: 分離 Migrations +**CI/CD 會自動執行**:`php artisan migrate --force` +**手動操作**:無 + +> 注意:migrations 結構已調整如下: +> - `database/migrations/` - Central tables (tenants, domains) +> - `database/migrations/tenant/` - Tenant tables (所有業務表) + +--- + +## Step 4: 遷移現有資料到 tenant_koori +**首次部署手動操作**: +1. 授予 MySQL sail 使用者 CREATE DATABASE 權限: + ```bash + docker exec koori-erp-mysql mysql -uroot -p[PASSWORD] -e "GRANT ALL PRIVILEGES ON *.* TO 'sail'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;" + ``` + +2. 建立第一個租戶 (小小冰室): + ```bash + docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute=" + use App\Models\Tenant; + Tenant::create(['id' => 'koori', 'name' => '小小冰室']); + " + ``` + +3. 為租戶綁定域名: + ```bash + docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute=" + use App\Models\Tenant; + Tenant::find('koori')->domains()->create(['domain' => 'koori.your-domain.com']); + " + ``` + +4. 執行資料遷移 (從 central DB 複製到 tenant DB): + ```bash + docker exec -w /var/www/html koori-erp-laravel php artisan tenancy:migrate-data koori + ``` + +## Step 5: 建立房東後台 +**手動操作**:無 + +--- + +## 其他注意事項 +- 待補充... diff --git a/resources/js/Layouts/LandlordLayout.tsx b/resources/js/Layouts/LandlordLayout.tsx new file mode 100644 index 0000000..f5e9958 --- /dev/null +++ b/resources/js/Layouts/LandlordLayout.tsx @@ -0,0 +1,132 @@ +import { Link, usePage } from "@inertiajs/react"; +import { cn } from "@/lib/utils"; +import { + Building2, + LayoutDashboard, + LogOut, + User, +} from "lucide-react"; +import { Toaster } from "sonner"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/Components/ui/dropdown-menu"; + +interface LandlordLayoutProps { + children: React.ReactNode; + title?: string; +} + +export default function LandlordLayout({ children, title }: LandlordLayoutProps) { + const { url, props } = usePage(); + // @ts-ignore + const user = props.auth?.user || { name: 'Admin', username: 'admin' }; + + const menuItems = [ + { + label: "儀表板", + href: "/landlord", + icon: LayoutDashboard, + active: url === "/landlord" || url === "/landlord/", + }, + { + label: "租戶管理", + href: "/landlord/tenants", + icon: Building2, + active: url.startsWith("/landlord/tenants"), + }, + ]; + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {/* Header */} +
+

+ {title || "房東管理後台"} +

+ + + +
+ + {user.name} + + 系統管理員 +
+
+ +
+
+ + 我的帳號 + + + + + 登出系統 + + + +
+
+ + {/* Content */} +
+ {children} +
+
+ + +
+ ); +} diff --git a/resources/js/Pages/Landlord/Dashboard.tsx b/resources/js/Pages/Landlord/Dashboard.tsx new file mode 100644 index 0000000..a4b4d72 --- /dev/null +++ b/resources/js/Pages/Landlord/Dashboard.tsx @@ -0,0 +1,106 @@ +import LandlordLayout from "@/Layouts/LandlordLayout"; +import { Building2, Users, Activity } from "lucide-react"; +import { Link } from "@inertiajs/react"; + +interface Tenant { + id: string; + name: string; + is_active: boolean; + created_at: string; + domains: string[]; +} + +interface DashboardProps { + totalTenants: number; + activeTenants: number; + recentTenants: Tenant[]; +} + +export default function Dashboard({ totalTenants, activeTenants, recentTenants }: DashboardProps) { + const statsCards = [ + { + title: "租戶總數", + value: totalTenants, + icon: Building2, + color: "bg-blue-500", + }, + { + title: "啟用中", + value: activeTenants, + icon: Activity, + color: "bg-green-500", + }, + { + title: "停用中", + value: totalTenants - activeTenants, + icon: Users, + color: "bg-slate-400", + }, + ]; + + return ( + +
+ {/* Stats Cards */} +
+ {statsCards.map((stat) => ( +
+
+ +
+
+

{stat.title}

+

{stat.value}

+
+
+ ))} +
+ + {/* Recent Tenants */} +
+
+

最近新增的租戶

+ + 查看全部 → + +
+
+ {recentTenants.length === 0 ? ( +
+ 尚無租戶資料 +
+ ) : ( + recentTenants.map((tenant) => ( +
+
+

{tenant.name}

+

+ {tenant.domains.length > 0 ? tenant.domains.join(", ") : "無綁定域名"} +

+
+
+ + {tenant.is_active ? "啟用" : "停用"} + + {tenant.created_at} +
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Landlord/Tenant/Create.tsx b/resources/js/Pages/Landlord/Tenant/Create.tsx new file mode 100644 index 0000000..1e47b47 --- /dev/null +++ b/resources/js/Pages/Landlord/Tenant/Create.tsx @@ -0,0 +1,104 @@ +import LandlordLayout from "@/Layouts/LandlordLayout"; +import { useForm, Link } from "@inertiajs/react"; +import { FormEvent } from "react"; + +export default function TenantCreate() { + const { data, setData, post, processing, errors } = useForm({ + id: "", + name: "", + email: "", + domain: "", + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + post(route("landlord.tenants.store")); + }; + + return ( + +
+
+

新增租戶

+

建立一個新的租戶帳號

+
+ +
+
+ + setData("id", e.target.value.toLowerCase())} + placeholder="例如:koori, alcohol" + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> +

只能使用英文、數字、底線

+ {errors.id &&

{errors.id}

} +
+ +
+ + setData("name", e.target.value)} + placeholder="例如:小小冰室" + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> + {errors.name &&

{errors.name}

} +
+ +
+ + setData("email", e.target.value)} + placeholder="admin@example.com" + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> + {errors.email &&

{errors.email}

} +
+ +
+ + setData("domain", e.target.value)} + placeholder="例如:koori.erp.koori.tw" + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> +

可之後再設定

+ {errors.domain &&

{errors.domain}

} +
+ +
+ + + 取消 + +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Landlord/Tenant/Edit.tsx b/resources/js/Pages/Landlord/Tenant/Edit.tsx new file mode 100644 index 0000000..87d7675 --- /dev/null +++ b/resources/js/Pages/Landlord/Tenant/Edit.tsx @@ -0,0 +1,107 @@ +import LandlordLayout from "@/Layouts/LandlordLayout"; +import { useForm, Link } from "@inertiajs/react"; +import { FormEvent } from "react"; + +interface Tenant { + id: string; + name: string; + email: string | null; + is_active: boolean; +} + +interface Props { + tenant: Tenant; +} + +export default function TenantEdit({ tenant }: Props) { + const { data, setData, put, processing, errors } = useForm({ + name: tenant.name, + email: tenant.email || "", + is_active: tenant.is_active, + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + put(route("landlord.tenants.update", tenant.id)); + }; + + return ( + +
+
+

編輯租戶

+

修改租戶 {tenant.id} 的資訊

+
+ +
+
+ + +

租戶 ID 無法修改

+
+ +
+ + setData("name", e.target.value)} + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> + {errors.name &&

{errors.name}

} +
+ +
+ + setData("email", e.target.value)} + className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> + {errors.email &&

{errors.email}

} +
+ +
+ +
+ +
+ + + 取消 + +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Landlord/Tenant/Index.tsx b/resources/js/Pages/Landlord/Tenant/Index.tsx new file mode 100644 index 0000000..9776265 --- /dev/null +++ b/resources/js/Pages/Landlord/Tenant/Index.tsx @@ -0,0 +1,188 @@ +import LandlordLayout from "@/Layouts/LandlordLayout"; +import { Link, router } from "@inertiajs/react"; +import { Plus, Edit, Trash2, Globe } from "lucide-react"; +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; + +interface Tenant { + id: string; + name: string; + email: string | null; + is_active: boolean; + created_at: string; + domains: string[]; +} + +interface Props { + tenants: Tenant[]; +} + +export default function TenantIndex({ tenants }: Props) { + const [deleteTarget, setDeleteTarget] = useState(null); + + const handleDelete = () => { + if (deleteTarget) { + router.delete(route("landlord.tenants.destroy", deleteTarget.id)); + setDeleteTarget(null); + } + }; + + return ( + +
+ {/* Header */} +
+
+

租戶管理

+

管理系統中的所有租戶

+
+ + + 新增租戶 + +
+ + {/* Table */} +
+ + + + + + + + + + + + + {tenants.length === 0 ? ( + + + + ) : ( + tenants.map((tenant) => ( + + + + + + + + + )) + )} + +
+ ID + + 名稱 + + 域名 + + 狀態 + + 建立時間 + + 操作 +
+ 尚無租戶資料,請點擊「新增租戶」建立第一個租戶 +
+ {tenant.id} + +
+

{tenant.name}

+ {tenant.email && ( +

{tenant.email}

+ )} +
+
+ {tenant.domains.length > 0 ? ( +
+ {tenant.domains.map((domain) => ( + + + {domain} + + ))} +
+ ) : ( + + )} +
+ + {tenant.is_active ? "啟用" : "停用"} + + + {tenant.created_at} + +
+ + + + + + + +
+
+
+
+ + {/* Delete Confirmation */} + setDeleteTarget(null)}> + + + 確定要刪除這個租戶嗎? + + 刪除租戶將會同時刪除其資料庫和所有資料,此操作無法復原。 + + + + 取消 + + 確定刪除 + + + + +
+ ); +} diff --git a/resources/js/Pages/Landlord/Tenant/Show.tsx b/resources/js/Pages/Landlord/Tenant/Show.tsx new file mode 100644 index 0000000..aaf9c38 --- /dev/null +++ b/resources/js/Pages/Landlord/Tenant/Show.tsx @@ -0,0 +1,165 @@ +import LandlordLayout from "@/Layouts/LandlordLayout"; +import { Link, useForm, router } from "@inertiajs/react"; +import { Globe, Plus, Trash2, ArrowLeft } from "lucide-react"; +import { FormEvent, useState } from "react"; + +interface Domain { + id: number; + domain: string; +} + +interface Tenant { + id: string; + name: string; + email: string | null; + is_active: boolean; + created_at: string; + updated_at: string; + domains: Domain[]; +} + +interface Props { + tenant: Tenant; +} + +export default function TenantShow({ tenant }: Props) { + const [showAddDomain, setShowAddDomain] = useState(false); + const { data, setData, post, processing, errors, reset } = useForm({ + domain: "", + }); + + const handleAddDomain = (e: FormEvent) => { + e.preventDefault(); + post(route("landlord.tenants.domains.store", tenant.id), { + onSuccess: () => { + reset(); + setShowAddDomain(false); + }, + }); + }; + + const handleRemoveDomain = (domainId: number) => { + if (confirm("確定要移除這個域名嗎?")) { + router.delete(route("landlord.tenants.domains.destroy", [tenant.id, domainId])); + } + }; + + return ( + +
+ {/* Back Link */} + + + 返回列表 + + + {/* Header */} +
+
+

{tenant.name}

+

租戶 ID: {tenant.id}

+
+ + 編輯 + +
+ + {/* Info Card */} +
+

基本資訊

+
+
+
狀態
+
+ + {tenant.is_active ? "啟用" : "停用"} + +
+
+
+
聯絡信箱
+
{tenant.email || "-"}
+
+
+
建立時間
+
{tenant.created_at}
+
+
+
更新時間
+
{tenant.updated_at}
+
+
+
+ + {/* Domains Card */} +
+
+

綁定域名

+ +
+ + {showAddDomain && ( +
+ setData("domain", e.target.value)} + placeholder="例如:koori.erp.koori.tw" + className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> + +
+ )} + {errors.domain &&

{errors.domain}

} + + {tenant.domains.length === 0 ? ( +

尚未綁定任何域名

+ ) : ( +
    + {tenant.domains.map((domain) => ( +
  • +
    + + {domain.domain} +
    + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/routes/landlord.php b/routes/landlord.php new file mode 100644 index 0000000..4cac6c5 --- /dev/null +++ b/routes/landlord.php @@ -0,0 +1,27 @@ +name('landlord.')->middleware(['web', 'auth'])->group(function () { + // 房東儀表板 + Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); + + // 租戶管理 CRUD + Route::resource('tenants', TenantController::class); + + // 租戶域名管理 + Route::post('tenants/{tenant}/domains', [TenantController::class, 'addDomain'])->name('tenants.domains.store'); + Route::delete('tenants/{tenant}/domains/{domain}', [TenantController::class, 'removeDomain'])->name('tenants.domains.destroy'); +}); diff --git a/routes/tenant.php b/routes/tenant.php new file mode 100644 index 0000000..bc3b131 --- /dev/null +++ b/routes/tenant.php @@ -0,0 +1,26 @@ +group(function () { + // 載入與 central 相同的 ERP 路由,但運行在租戶資料庫 context 中 + require base_path('routes/web.php'); +});