Feature: Tenant Short Name and Branding Implementation
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s

- 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:
2026-01-29 16:28:34 +08:00
parent 746eeb6f01
commit 2e71a1cb29
10 changed files with 78 additions and 18 deletions

View File

@@ -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'],
]); ]);

View File

@@ -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;
}, },
]; ];
} }

View File

@@ -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 &copy; {new Date().getFullYear()} . All rights reserved. Design by Copyright &copy; {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>

View File

@@ -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">
&copy; 2026 . All rights reserved. &copy; {new Date().getFullYear()} {props.branding?.name || '小小冰室'}. All rights reserved.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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">