feat: 實作 Multi-tenancy 多租戶架構 (stancl/tenancy)
- 安裝並設定 stancl/tenancy 套件 - 分離 Central / Tenant migrations - 建立 Tenant Model 與資料遷移指令 - 建立房東後台 CRUD (Landlord Dashboard) - 新增租戶管理頁面 (列表、新增、編輯、詳情) - 新增域名管理功能 - 更新部署手冊
This commit is contained in:
@@ -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
|
||||
|
||||
131
app/Console/Commands/MigrateToTenant.php
Normal file
131
app/Console/Commands/MigrateToTenant.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 將現有資料遷移到租戶資料庫
|
||||
*
|
||||
* 此指令用於初次設定多租戶時,將現有的 ERP 資料遷移到第一個租戶
|
||||
*/
|
||||
class MigrateToTenant extends Command
|
||||
{
|
||||
protected $signature = 'tenancy:migrate-data {tenant_id} {--dry-run : 只顯示會遷移的表,不實際執行}';
|
||||
protected $description = '將現有 central DB 資料遷移到指定租戶資料庫';
|
||||
|
||||
/**
|
||||
* 需要遷移的表 (依賴順序排列)
|
||||
*/
|
||||
protected array $tablesToMigrate = [
|
||||
'users',
|
||||
'password_reset_tokens',
|
||||
'sessions',
|
||||
'cache',
|
||||
'cache_locks',
|
||||
'jobs',
|
||||
'job_batches',
|
||||
'failed_jobs',
|
||||
'categories',
|
||||
'units',
|
||||
'vendors',
|
||||
'products',
|
||||
'product_vendor',
|
||||
'warehouses',
|
||||
'inventories',
|
||||
'inventory_transactions',
|
||||
'purchase_orders',
|
||||
'purchase_order_items',
|
||||
'permissions',
|
||||
'roles',
|
||||
'model_has_permissions',
|
||||
'model_has_roles',
|
||||
'role_has_permissions',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = $this->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;
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Landlord;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$stats = [
|
||||
'totalTenants' => 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);
|
||||
}
|
||||
}
|
||||
172
app/Http/Controllers/Landlord/TenantController.php
Normal file
172
app/Http/Controllers/Landlord/TenantController.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Landlord;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示租戶列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$tenants = Tenant::with('domains')->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} 已移除!");
|
||||
}
|
||||
}
|
||||
34
app/Models/Tenant.php
Normal file
34
app/Models/Tenant.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
|
||||
use Stancl\Tenancy\Contracts\TenantWithDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDatabase;
|
||||
use Stancl\Tenancy\Database\Concerns\HasDomains;
|
||||
|
||||
/**
|
||||
* 租戶 Model
|
||||
*
|
||||
* 代表 ERP 系統中的每一個客戶公司 (如:小小冰室、酒水客戶等)
|
||||
*
|
||||
* 自訂屬性 (存在 data JSON 欄位中,可透過 $tenant->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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
148
app/Providers/TenancyServiceProvider.php
Normal file
148
app/Providers/TenancyServiceProvider.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Stancl\JobPipeline\JobPipeline;
|
||||
use Stancl\Tenancy\Events;
|
||||
use Stancl\Tenancy\Jobs;
|
||||
use Stancl\Tenancy\Listeners;
|
||||
use Stancl\Tenancy\Middleware;
|
||||
|
||||
class TenancyServiceProvider extends ServiceProvider
|
||||
{
|
||||
// By default, no namespace is used to support the callable array syntax.
|
||||
public static string $controllerNamespace = '';
|
||||
|
||||
public function events()
|
||||
{
|
||||
return [
|
||||
// Tenant events
|
||||
Events\CreatingTenant::class => [],
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\TenancyServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -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": {
|
||||
|
||||
228
composer.lock
generated
228
composer.lock
generated
@@ -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",
|
||||
|
||||
199
config/tenancy.php
Normal file
199
config/tenancy.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Stancl\Tenancy\Database\Models\Domain;
|
||||
use App\Models\Tenant;
|
||||
|
||||
return [
|
||||
'tenant_model' => 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
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateTenantsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenants', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CreateDomainsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('domains', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
}
|
||||
71
docs/multi-tenancy-deployment.md
Normal file
71
docs/multi-tenancy-deployment.md
Normal file
@@ -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: 建立房東後台
|
||||
**手動操作**:無
|
||||
|
||||
---
|
||||
|
||||
## 其他注意事項
|
||||
- 待補充...
|
||||
132
resources/js/Layouts/LandlordLayout.tsx
Normal file
132
resources/js/Layouts/LandlordLayout.tsx
Normal file
@@ -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 (
|
||||
<div className="flex min-h-screen bg-slate-50">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-64 bg-slate-900 text-white flex flex-col">
|
||||
<div className="h-16 flex items-center px-6 border-b border-slate-800">
|
||||
<Link href="/landlord" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary-main rounded-lg flex items-center justify-center">
|
||||
<Building2 className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-lg">房東後台</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors",
|
||||
item.active
|
||||
? "bg-primary-main text-white"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-800">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
← 返回 ERP 系統
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 ml-64">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
|
||||
<h1 className="text-lg font-semibold text-slate-900">
|
||||
{title || "房東管理後台"}
|
||||
</h1>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
|
||||
<div className="flex flex-col items-end mr-1">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">系統管理員</span>
|
||||
</div>
|
||||
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 z-[100]">
|
||||
<DropdownMenuLabel>我的帳號</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={route('logout')}
|
||||
method="post"
|
||||
as="button"
|
||||
className="w-full flex items-center cursor-pointer text-red-600"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>登出系統</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Toaster richColors closeButton position="top-center" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
resources/js/Pages/Landlord/Dashboard.tsx
Normal file
106
resources/js/Pages/Landlord/Dashboard.tsx
Normal file
@@ -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 (
|
||||
<LandlordLayout title="儀表板">
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{statsCards.map((stat) => (
|
||||
<div
|
||||
key={stat.title}
|
||||
className="bg-white rounded-xl border border-slate-200 p-6 flex items-center gap-4"
|
||||
>
|
||||
<div className={`w-12 h-12 ${stat.color} rounded-lg flex items-center justify-center`}>
|
||||
<stat.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{stat.title}</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Tenants */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900">最近新增的租戶</h2>
|
||||
<Link
|
||||
href="/landlord/tenants"
|
||||
className="text-sm text-primary-main hover:underline"
|
||||
>
|
||||
查看全部 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{recentTenants.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-slate-500">
|
||||
尚無租戶資料
|
||||
</div>
|
||||
) : (
|
||||
recentTenants.map((tenant) => (
|
||||
<div key={tenant.id} className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{tenant.name}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{tenant.domains.length > 0 ? tenant.domains.join(", ") : "無綁定域名"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{tenant.is_active ? "啟用" : "停用"}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">{tenant.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LandlordLayout>
|
||||
);
|
||||
}
|
||||
104
resources/js/Pages/Landlord/Tenant/Create.tsx
Normal file
104
resources/js/Pages/Landlord/Tenant/Create.tsx
Normal file
@@ -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 (
|
||||
<LandlordLayout title="新增租戶">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">新增租戶</h1>
|
||||
<p className="text-slate-500 mt-1">建立一個新的租戶帳號</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
租戶 ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.id}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-slate-500">只能使用英文、數字、底線</p>
|
||||
{errors.id && <p className="mt-1 text-sm text-red-500">{errors.id}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
租戶名稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.name}
|
||||
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
聯絡信箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
綁定域名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.domain}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-slate-500">可之後再設定</p>
|
||||
{errors.domain && <p className="mt-1 text-sm text-red-500">{errors.domain}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{processing ? "處理中..." : "建立租戶"}
|
||||
</button>
|
||||
<Link
|
||||
href="/landlord/tenants"
|
||||
className="text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
取消
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</LandlordLayout>
|
||||
);
|
||||
}
|
||||
107
resources/js/Pages/Landlord/Tenant/Edit.tsx
Normal file
107
resources/js/Pages/Landlord/Tenant/Edit.tsx
Normal file
@@ -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 (
|
||||
<LandlordLayout title="編輯租戶">
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">編輯租戶</h1>
|
||||
<p className="text-slate-500 mt-1">修改租戶 {tenant.id} 的資訊</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
租戶 ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tenant.id}
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-slate-500">租戶 ID 無法修改</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
租戶名稱 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.name}
|
||||
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
聯絡信箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={(e) => 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 && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.is_active}
|
||||
onChange={(e) => setData("is_active", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-slate-300 text-primary-main focus:ring-primary-main"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">啟用此租戶</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{processing ? "儲存中..." : "儲存變更"}
|
||||
</button>
|
||||
<Link
|
||||
href="/landlord/tenants"
|
||||
className="text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
取消
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</LandlordLayout>
|
||||
);
|
||||
}
|
||||
188
resources/js/Pages/Landlord/Tenant/Index.tsx
Normal file
188
resources/js/Pages/Landlord/Tenant/Index.tsx
Normal file
@@ -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<Tenant | null>(null);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteTarget) {
|
||||
router.delete(route("landlord.tenants.destroy", deleteTarget.id));
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LandlordLayout title="租戶管理">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">租戶管理</h1>
|
||||
<p className="text-slate-500 mt-1">管理系統中的所有租戶</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/landlord/tenants/create"
|
||||
className="bg-primary-main hover:bg-primary-dark text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新增租戶
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||
名稱
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||
域名
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||
狀態
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||
建立時間
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{tenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-500">
|
||||
尚無租戶資料,請點擊「新增租戶」建立第一個租戶
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenants.map((tenant) => (
|
||||
<tr key={tenant.id} className="hover:bg-slate-50">
|
||||
<td className="px-6 py-4 font-mono text-sm text-slate-600">
|
||||
{tenant.id}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{tenant.name}</p>
|
||||
{tenant.email && (
|
||||
<p className="text-sm text-slate-500">{tenant.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{tenant.domains.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{tenant.domains.map((domain) => (
|
||||
<span
|
||||
key={domain}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-xs"
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
{domain}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 text-sm">無</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{tenant.is_active ? "啟用" : "停用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
{tenant.created_at}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/landlord/tenants/${tenant.id}`}
|
||||
className="p-2 text-slate-400 hover:text-primary-main hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="查看詳情"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/landlord/tenants/${tenant.id}/edit`}
|
||||
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="編輯"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(tenant)}
|
||||
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要刪除這個租戶嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
刪除租戶將會同時刪除其資料庫和所有資料,此操作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
確定刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</LandlordLayout>
|
||||
);
|
||||
}
|
||||
165
resources/js/Pages/Landlord/Tenant/Show.tsx
Normal file
165
resources/js/Pages/Landlord/Tenant/Show.tsx
Normal file
@@ -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 (
|
||||
<LandlordLayout title="租戶詳情">
|
||||
<div className="max-w-3xl space-y-6">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/landlord/tenants"
|
||||
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回列表
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{tenant.name}</h1>
|
||||
<p className="text-slate-500 mt-1">租戶 ID: {tenant.id}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/landlord/tenants/${tenant.id}/edit`}
|
||||
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
編輯
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">基本資訊</h2>
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm text-slate-500">狀態</dt>
|
||||
<dd className="mt-1">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{tenant.is_active ? "啟用" : "停用"}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-slate-500">聯絡信箱</dt>
|
||||
<dd className="mt-1 text-slate-900">{tenant.email || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-slate-500">建立時間</dt>
|
||||
<dd className="mt-1 text-slate-900">{tenant.created_at}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-slate-500">更新時間</dt>
|
||||
<dd className="mt-1 text-slate-900">{tenant.updated_at}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Domains Card */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">綁定域名</h2>
|
||||
<button
|
||||
onClick={() => setShowAddDomain(!showAddDomain)}
|
||||
className="text-primary-main hover:text-primary-dark flex items-center gap-1 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
新增域名
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddDomain && (
|
||||
<form onSubmit={handleAddDomain} className="mb-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={data.domain}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="bg-primary-main hover:bg-primary-dark text-white px-4 py-2 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
新增
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{errors.domain && <p className="mb-4 text-sm text-red-500">{errors.domain}</p>}
|
||||
|
||||
{tenant.domains.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">尚未綁定任何域名</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{tenant.domains.map((domain) => (
|
||||
<li
|
||||
key={domain.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-900">{domain.domain}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveDomain(domain.id)}
|
||||
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LandlordLayout>
|
||||
);
|
||||
}
|
||||
27
routes/landlord.php
Normal file
27
routes/landlord.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Landlord\DashboardController;
|
||||
use App\Http\Controllers\Landlord\TenantController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Landlord Routes (房東後台路由)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 這些路由用於中央管理後台,只能透過 central domain 存取。
|
||||
| 用於管理租戶 (新增、編輯、停用) 等系統級操作。
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('landlord')->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');
|
||||
});
|
||||
26
routes/tenant.php
Normal file
26
routes/tenant.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Tenant Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 租戶專屬路由。當使用者透過租戶網域 (如 koori.koori-erp.test) 存取時,
|
||||
| 會自動初始化租戶 context 並連接到對應的租戶資料庫。
|
||||
|
|
||||
*/
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
InitializeTenancyByDomain::class,
|
||||
PreventAccessFromCentralDomains::class,
|
||||
])->group(function () {
|
||||
// 載入與 central 相同的 ERP 路由,但運行在租戶資料庫 context 中
|
||||
require base_path('routes/web.php');
|
||||
});
|
||||
Reference in New Issue
Block a user