Feature: Tenant Short Name and Branding Implementation
- Added short_name to Tenant model and controller - Updated Landlord/Tenant pages (Create, Edit, Show, Index) - Implemented branding customization (Favicon, Login Copyright, Sidebar Title) - Updated HandleInertiaRequests to share branding data
This commit is contained in:
@@ -19,6 +19,7 @@ class TenantController extends Controller
|
|||||||
return [
|
return [
|
||||||
'id' => $tenant->id,
|
'id' => $tenant->id,
|
||||||
'name' => $tenant->name ?? $tenant->id,
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'short_name' => $tenant->short_name ?? null,
|
||||||
'email' => $tenant->email ?? null,
|
'email' => $tenant->email ?? null,
|
||||||
'is_active' => $tenant->is_active ?? true,
|
'is_active' => $tenant->is_active ?? true,
|
||||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
@@ -47,6 +48,7 @@ class TenantController extends Controller
|
|||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
|
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
|
||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'short_name' => ['nullable', 'string', 'max:50'],
|
||||||
'email' => ['nullable', 'email', 'max:100'],
|
'email' => ['nullable', 'email', 'max:100'],
|
||||||
'domain' => ['nullable', 'string', 'max:100'],
|
'domain' => ['nullable', 'string', 'max:100'],
|
||||||
]);
|
]);
|
||||||
@@ -54,6 +56,7 @@ class TenantController extends Controller
|
|||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'id' => $validated['id'],
|
'id' => $validated['id'],
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
|
'short_name' => $validated['short_name'] ?? null,
|
||||||
'email' => $validated['email'] ?? null,
|
'email' => $validated['email'] ?? null,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'branding' => [
|
'branding' => [
|
||||||
@@ -85,6 +88,7 @@ class TenantController extends Controller
|
|||||||
'tenant' => [
|
'tenant' => [
|
||||||
'id' => $tenant->id,
|
'id' => $tenant->id,
|
||||||
'name' => $tenant->name ?? $tenant->id,
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'short_name' => $tenant->short_name ?? null,
|
||||||
'email' => $tenant->email ?? null,
|
'email' => $tenant->email ?? null,
|
||||||
'is_active' => $tenant->is_active ?? true,
|
'is_active' => $tenant->is_active ?? true,
|
||||||
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
@@ -128,6 +132,7 @@ class TenantController extends Controller
|
|||||||
'tenant' => [
|
'tenant' => [
|
||||||
'id' => $tenant->id,
|
'id' => $tenant->id,
|
||||||
'name' => $tenant->name ?? $tenant->id,
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'short_name' => $tenant->short_name ?? null,
|
||||||
'email' => $tenant->email ?? null,
|
'email' => $tenant->email ?? null,
|
||||||
'is_active' => $tenant->is_active ?? true,
|
'is_active' => $tenant->is_active ?? true,
|
||||||
],
|
],
|
||||||
@@ -143,6 +148,7 @@ class TenantController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'short_name' => ['nullable', 'string', 'max:50'],
|
||||||
'email' => ['nullable', 'email', 'max:100'],
|
'email' => ['nullable', 'email', 'max:100'],
|
||||||
'is_active' => ['boolean'],
|
'is_active' => ['boolean'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -64,25 +64,30 @@ class HandleInertiaRequests extends Middleware
|
|||||||
],
|
],
|
||||||
'branding' => function () {
|
'branding' => function () {
|
||||||
$tenant = tenancy()->tenant;
|
$tenant = tenancy()->tenant;
|
||||||
if (!$tenant) {
|
|
||||||
// 中央後台預設 Branding
|
// 決定名稱顯示邏輯
|
||||||
return [
|
$fullName = $tenant ? ($tenant->name ?? 'Star ERP') : 'Star ERP 中央後台';
|
||||||
'logo_url' => \Storage::url('defaults/logo.png'), // 中央後台也使用預設 Logo
|
$shortName = $tenant ? ($tenant->short_name ?? $fullName) : 'Start ERP';
|
||||||
'primary_color' => '#4F46E5',
|
|
||||||
'text_color' => '#1a1a1a',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$logoUrl = null;
|
$logoUrl = null;
|
||||||
if (isset($tenant->branding['logo_path'])) {
|
if ($tenant && isset($tenant->branding['logo_path'])) {
|
||||||
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
$logoUrl = \Storage::url($tenant->branding['logo_path']);
|
||||||
|
} elseif (!$tenant) {
|
||||||
|
$logoUrl = \Storage::url('defaults/logo.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
$brandingData = [
|
||||||
|
'name' => $fullName,
|
||||||
|
'short_name' => $shortName,
|
||||||
'logo_url' => $logoUrl,
|
'logo_url' => $logoUrl,
|
||||||
'primary_color' => $tenant->branding['primary_color'] ?? '#01ab83',
|
'primary_color' => $tenant->branding['primary_color'] ?? ($tenant ? '#01ab83' : '#4F46E5'),
|
||||||
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
|
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 同步分享給 Blade View (給 app.blade.php 使用 Favicon)
|
||||||
|
\Illuminate\Support\Facades\View::share('branding', $brandingData);
|
||||||
|
|
||||||
|
return $brandingData;
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ export default function AuthenticatedLayout({
|
|||||||
</button>
|
</button>
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
|
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
|
||||||
<span className="font-bold text-slate-900">小小冰室 ERP</span>
|
<span className="font-bold text-slate-900">{branding?.short_name || '小小冰室'} ERP</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -510,7 +510,7 @@ export default function AuthenticatedLayout({
|
|||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<Link href="/" className="flex items-center gap-2 group">
|
<Link href="/" className="flex items-center gap-2 group">
|
||||||
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain group-hover:scale-110 transition-transform" />
|
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain group-hover:scale-110 transition-transform" />
|
||||||
<span className="font-extrabold text-primary-main text-lg tracking-tight">小小冰室 ERP</span>
|
<span className="font-extrabold text-primary-main text-lg tracking-tight">{branding?.short_name || '小小冰室'} ERP</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isCollapsed && (
|
{isCollapsed && (
|
||||||
@@ -559,7 +559,7 @@ export default function AuthenticatedLayout({
|
|||||||
<div className="h-16 flex items-center justify-between px-6 border-b border-slate-100">
|
<div className="h-16 flex items-center justify-between px-6 border-b border-slate-100">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
|
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
|
||||||
<span className="font-extrabold text-primary-main text-lg">小小冰室 ERP</span>
|
<span className="font-extrabold text-primary-main text-lg">{branding?.short_name || '小小冰室'} ERP</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button onClick={() => setIsMobileOpen(false)} className="p-2 text-slate-400">
|
<button onClick={() => setIsMobileOpen(false)} className="p-2 text-slate-400">
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
@@ -588,7 +588,7 @@ export default function AuthenticatedLayout({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<footer className="mt-auto py-6 text-center text-sm text-slate-400">
|
<footer className="mt-auto py-6 text-center text-sm text-slate-400">
|
||||||
Copyright © {new Date().getFullYear()} 小小冰室. All rights reserved. Design by 星科技
|
Copyright © {new Date().getFullYear()} {branding?.name || '小小冰室'}. All rights reserved. Design by 星科技
|
||||||
</footer>
|
</footer>
|
||||||
<Toaster richColors closeButton position="top-center" />
|
<Toaster richColors closeButton position="top-center" />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Head, useForm } from "@inertiajs/react";
|
import { Head, useForm, usePage } from "@inertiajs/react";
|
||||||
|
import { PageProps } from "@/types/global";
|
||||||
import { FormEventHandler, useEffect } from "react";
|
import { FormEventHandler, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
@@ -8,6 +9,7 @@ import InputError from "../../Components/InputError";
|
|||||||
import ApplicationLogo from "../../Components/ApplicationLogo";
|
import ApplicationLogo from "../../Components/ApplicationLogo";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
const { props } = usePage<PageProps>();
|
||||||
const { data, setData, post, processing, errors, reset } = useForm({
|
const { data, setData, post, processing, errors, reset } = useForm({
|
||||||
username: localStorage.getItem("saved_username") || "",
|
username: localStorage.getItem("saved_username") || "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -134,7 +136,7 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-gray-400 text-sm mt-8">
|
<p className="text-center text-gray-400 text-sm mt-8">
|
||||||
© 2026 小小冰室. All rights reserved.
|
© {new Date().getFullYear()} {props.branding?.name || '小小冰室'}. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default function TenantCreate() {
|
|||||||
const { data, setData, post, processing, errors } = useForm({
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
id: "",
|
id: "",
|
||||||
name: "",
|
name: "",
|
||||||
|
short_name: "",
|
||||||
email: "",
|
email: "",
|
||||||
domain: "",
|
domain: "",
|
||||||
});
|
});
|
||||||
@@ -55,6 +56,21 @@ export default function TenantCreate() {
|
|||||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
客戶簡稱
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.short_name}
|
||||||
|
onChange={(e) => setData("short_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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">選填</p>
|
||||||
|
{errors.short_name && <p className="mt-1 text-sm text-red-500">{errors.short_name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
聯絡信箱
|
聯絡信箱
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { FormEvent } from "react";
|
|||||||
interface Tenant {
|
interface Tenant {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
short_name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
@@ -16,6 +17,7 @@ interface Props {
|
|||||||
export default function TenantEdit({ tenant }: Props) {
|
export default function TenantEdit({ tenant }: Props) {
|
||||||
const { data, setData, put, processing, errors } = useForm({
|
const { data, setData, put, processing, errors } = useForm({
|
||||||
name: tenant.name,
|
name: tenant.name,
|
||||||
|
short_name: tenant.short_name || "",
|
||||||
email: tenant.email || "",
|
email: tenant.email || "",
|
||||||
is_active: tenant.is_active,
|
is_active: tenant.is_active,
|
||||||
});
|
});
|
||||||
@@ -62,6 +64,19 @@ export default function TenantEdit({ tenant }: Props) {
|
|||||||
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
客戶簡稱
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.short_name}
|
||||||
|
onChange={(e) => setData("short_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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">選填</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
聯絡信箱
|
聯絡信箱
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
interface Tenant {
|
interface Tenant {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
short_name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -67,6 +68,9 @@ export default function TenantIndex({ tenants }: Props) {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||||
名稱
|
名稱
|
||||||
</th>
|
</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 className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||||
域名
|
域名
|
||||||
</th>
|
</th>
|
||||||
@@ -84,7 +88,7 @@ export default function TenantIndex({ tenants }: Props) {
|
|||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{tenants.length === 0 ? (
|
{tenants.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-500">
|
<td colSpan={7} className="px-6 py-12 text-center text-slate-500">
|
||||||
尚無客戶資料,請點擊「新增客戶」建立第一個客戶
|
尚無客戶資料,請點擊「新增客戶」建立第一個客戶
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,6 +106,9 @@ export default function TenantIndex({ tenants }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{tenant.short_name || '-'}
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{tenant.domains.length > 0 ? (
|
{tenant.domains.length > 0 ? (
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Domain {
|
|||||||
interface Tenant {
|
interface Tenant {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
short_name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -97,6 +98,10 @@ export default function TenantShow({ tenant }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-slate-500">客戶簡稱</dt>
|
||||||
|
<dd className="mt-1 text-slate-900">{tenant.short_name || "-"}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-slate-500">聯絡信箱</dt>
|
<dt className="text-sm text-slate-500">聯絡信箱</dt>
|
||||||
<dd className="mt-1 text-slate-900">{tenant.email || "-"}</dd>
|
<dd className="mt-1 text-slate-900">{tenant.email || "-"}</dd>
|
||||||
|
|||||||
3
resources/js/types/global.d.ts
vendored
3
resources/js/types/global.d.ts
vendored
@@ -12,6 +12,8 @@ export interface AuthUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Branding {
|
export interface Branding {
|
||||||
|
name?: string;
|
||||||
|
short_name?: string;
|
||||||
logo_url?: string | null;
|
logo_url?: string | null;
|
||||||
primary_color?: string;
|
primary_color?: string;
|
||||||
text_color?: string;
|
text_color?: string;
|
||||||
@@ -26,6 +28,7 @@ export interface PageProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
branding?: Branding | null;
|
branding?: Branding | null;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
<title inertia>{{ config('app.name', 'Laravel') }}</title>
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
|
<link rel="icon" type="image/png" href="{{ $branding['logo_url'] ?? '/favicon.png' }}">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
Reference in New Issue
Block a user