feat: 新增租戶品牌客製化系統(Logo、主色系)、修正 hardcoded 顏色為 CSS 變數
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 47s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-01-16 14:36:24 +08:00
parent a2c99e3a36
commit 55272d5d43
36 changed files with 568 additions and 70 deletions

View File

@@ -92,6 +92,26 @@ class TenantController extends Controller
]);
}
/**
* 顯示租戶樣式管理頁面
*/
public function showBranding(Tenant $tenant)
{
$logoUrl = null;
if (isset($tenant->branding['logo_path'])) {
$logoUrl = \Storage::url($tenant->branding['logo_path']);
}
return Inertia::render('Landlord/Tenant/Branding', [
'tenant' => [
'id' => $tenant->id,
'name' => $tenant->name ?? $tenant->id,
'branding' => $tenant->branding ?? [],
],
'logo_url' => $logoUrl,
]);
}
/**
* 顯示編輯租戶表單
*/
@@ -171,4 +191,44 @@ class TenantController extends Controller
return back()->with('success', "域名 {$domainName} 已移除!");
}
/**
* 更新租戶品牌樣式設定
*/
public function updateBranding(Request $request, Tenant $tenant)
{
$validated = $request->validate([
'logo' => 'nullable|image|max:2048',
'primary_color' => 'required|regex:/^#[0-9A-Fa-f]{6}$/',
'text_color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
]);
$branding = $tenant->branding ?? [];
// 處理 Logo 上傳
if ($request->hasFile('logo')) {
// 刪除舊 Logo
if (isset($branding['logo_path'])) {
\Storage::disk('public')->delete($branding['logo_path']);
}
// 儲存新 Logo
$path = $request->file('logo')->store('tenant-logos', 'public');
$branding['logo_path'] = $path;
}
// 更新主色系
$branding['primary_color'] = $validated['primary_color'];
// 如果有傳入字體顏色則更新,否則保留原值(或預設值)
if (isset($validated['text_color'])) {
$branding['text_color'] = $validated['text_color'];
} elseif (!isset($branding['text_color'])) {
$branding['text_color'] = '#1a1a1a';
}
$tenant->update(['branding' => $branding]);
return redirect()->back()->with('success', '樣式設定已更新');
}
}

View File

@@ -55,6 +55,23 @@ class HandleInertiaRequests extends Middleware
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
'branding' => function () {
$tenant = tenancy()->tenant;
if (!$tenant) {
return null;
}
$logoUrl = null;
if (isset($tenant->branding['logo_path'])) {
$logoUrl = \Storage::url($tenant->branding['logo_path']);
}
return [
'logo_url' => $logoUrl,
'primary_color' => $tenant->branding['primary_color'] ?? '#01ab83',
'text_color' => $tenant->branding['text_color'] ?? '#1a1a1a',
];
},
];
}
}

BIN
public/favicon-landlord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -1,6 +1,22 @@
import { ImgHTMLAttributes } from 'react';
import { usePage } from '@inertiajs/react';
import { PageProps } from '@/types/global';
export default function ApplicationLogo(props: ImgHTMLAttributes<HTMLImageElement>) {
const { branding } = usePage<PageProps>().props;
// 如果有自訂 Logo優先使用
if (branding?.logo_url) {
return (
<img
{...props}
src={branding.logo_url}
alt="Logo"
/>
);
}
// 預設 Logo
return (
<img
{...props}

View File

@@ -39,7 +39,7 @@ const BreadcrumbNav = ({ items, className }: BreadcrumbNavProps) => {
<BreadcrumbLink asChild>
<Link
href={item.href}
className="text-[#01ab83] hover:text-[#018a6a] font-medium transition-colors"
className="text-primary-main hover:text-primary-dark font-medium transition-colors"
>
{item.label}
</Link>

View File

@@ -20,7 +20,7 @@ import {
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo } from "react";
import { Link, usePage } from "@inertiajs/react";
import { Link, usePage, Head } from "@inertiajs/react";
import { cn } from "@/lib/utils";
import BreadcrumbNav, { BreadcrumbItemType } from "@/Components/shared/BreadcrumbNav";
import {
@@ -33,6 +33,7 @@ import {
} from "@/Components/ui/dropdown-menu";
import { usePermission } from "@/hooks/usePermission";
import ApplicationLogo from "@/Components/ApplicationLogo";
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
interface MenuItem {
id: string;
@@ -328,6 +329,18 @@ export default function AuthenticatedLayout({
return (
<div className="flex min-h-screen bg-slate-50">
<Head>
<link rel="icon" type="image/png" href="/favicon.png" />
<style>{`
:root {
--primary-main: ${(props as any).branding?.primary_color || '#01ab83'};
--primary-dark: ${generateDarkColor((props as any).branding?.primary_color || '#01ab83')};
--primary-light: ${generateLightColor((props as any).branding?.primary_color || '#01ab83')};
--primary-lightest: ${generateLightestColor((props as any).branding?.primary_color || '#01ab83')};
--button-main-active: ${generateActiveColor((props as any).branding?.primary_color || '#01ab83')};
}
`}</style>
</Head>
{/* Mobile Header -> Global Header */}
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-slate-200 z-[60] flex items-center justify-between px-4 transition-all duration-300">
<div className="flex items-center gap-2">
@@ -395,7 +408,7 @@ export default function AuthenticatedLayout({
{!isCollapsed && (
<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" />
<span className="font-extrabold text-[#01ab83] text-lg tracking-tight"> ERP</span>
<span className="font-extrabold text-primary-main text-lg tracking-tight"> ERP</span>
</Link>
)}
{isCollapsed && (
@@ -444,7 +457,7 @@ export default function AuthenticatedLayout({
<div className="h-16 flex items-center justify-between px-6 border-b border-slate-100">
<Link href="/" className="flex items-center gap-2">
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
<span className="font-extrabold text-[#01ab83] text-lg"> ERP</span>
<span className="font-extrabold text-primary-main text-lg"> ERP</span>
</Link>
<button onClick={() => setIsMobileOpen(false)} className="p-2 text-slate-400">
<X className="h-5 w-5" />

View File

@@ -1,4 +1,4 @@
import { Link, usePage } from "@inertiajs/react";
import { Link, usePage, Head } from "@inertiajs/react";
import { cn } from "@/lib/utils";
import {
Building2,
@@ -8,6 +8,8 @@ import {
Menu,
X,
Settings,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { Toaster } from "sonner";
import {
@@ -18,7 +20,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { useState } from "react";
import { useState, useEffect } from "react";
interface LandlordLayoutProps {
children: React.ReactNode;
@@ -46,9 +48,24 @@ export default function LandlordLayout({ children, title }: LandlordLayoutProps)
];
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("landlord-sidebar-collapsed") === "true";
}
return false;
});
useEffect(() => {
localStorage.setItem("landlord-sidebar-collapsed", String(isCollapsed));
}, [isCollapsed]);
return (
<div className="flex min-h-screen bg-slate-50 relative">
<Head>
<link rel="icon" type="image/png" href="/favicon-landlord.png" />
<link rel="shortcut icon" href="/favicon-landlord.png" type="image/png" />
<link rel="apple-touch-icon" href="/favicon-landlord.png" />
</Head>
{/* Sidebar Overlay for mobile */}
{isSidebarOpen && (
<div
@@ -59,46 +76,75 @@ export default function LandlordLayout({ children, title }: LandlordLayoutProps)
{/* Sidebar */}
<aside className={cn(
"fixed left-0 top-0 bottom-0 w-64 bg-slate-900 text-white flex flex-col z-50 transition-transform duration-300 lg:translate-x-0",
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
"fixed left-0 top-0 bottom-0 bg-slate-900 text-white flex flex-col z-50 transition-all duration-300 ease-in-out lg:translate-x-0",
isSidebarOpen ? "translate-x-0" : "-translate-x-full",
isCollapsed ? "lg:w-20" : "lg:w-64"
)}>
<div className={cn(
"h-16 flex items-center border-b border-slate-800 transition-all",
isCollapsed ? "justify-center px-0" : "justify-between px-6"
)}>
<div className="h-16 flex items-center justify-between 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">
<div className="w-8 h-8 bg-primary-main rounded-lg flex items-center justify-center shrink-0">
<Building2 className="w-5 h-5 text-white" />
</div>
<span className="font-bold text-lg"></span>
{!isCollapsed && <span className="font-bold text-lg whitespace-nowrap"></span>}
</Link>
{!isCollapsed && (
<button
className="lg:hidden text-slate-400 hover:text-white"
onClick={() => setIsSidebarOpen(false)}
>
<X className="w-6 h-6" />
</button>
)}
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
<nav className="flex-1 p-3 space-y-1 overflow-y-auto overflow-x-hidden">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setIsSidebarOpen(false)}
title={isCollapsed ? item.label : ""}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors",
"flex items-center rounded-lg transition-all",
isCollapsed ? "justify-center px-0 h-10 w-10 mx-auto" : "gap-3 px-3 py-2.5",
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>
<item.icon className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="font-medium whitespace-nowrap">{item.label}</span>}
</Link>
))}
</nav>
<div className="p-4 border-t border-slate-800 hidden lg:block">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
"w-full flex items-center transition-colors text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg p-2",
isCollapsed ? "justify-center" : "gap-3"
)}
title={isCollapsed ? "展開側邊欄" : "收合側邊欄"}
>
{isCollapsed ? <PanelLeftOpen className="w-5 h-5" /> : (
<>
<PanelLeftClose className="w-5 h-5" />
<span className="text-sm font-medium"></span>
</>
)}
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 lg:ml-64 w-full">
<main className={cn(
"flex-1 flex flex-col transition-all duration-300 min-h-screen",
isCollapsed ? "lg:ml-20" : "lg:ml-64"
)}>
{/* Header */}
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-4 lg:px-6">
<div className="flex items-center gap-4">

View File

@@ -107,7 +107,7 @@ export default function RoleCreate({ groupedPermissions }: Props) {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Shield className="h-6 w-6 text-[#01ab83]" />
<Shield className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
@@ -179,7 +179,7 @@ export default function RoleCreate({ groupedPermissions }: Props) {
variant="ghost"
size="sm"
onClick={() => toggleGroup(group.permissions)}
className="text-xs h-7 text-[#01ab83] hover:text-[#01ab83] hover:bg-[#01ab83]/10"
className="text-xs h-7 text-primary-main hover:text-primary-main hover:bg-primary-main/10"
>
{allGroupSelected ? '取消全選' : '全選'}
</Button>

View File

@@ -114,7 +114,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Shield className="h-6 w-6 text-[#01ab83]" />
<Shield className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
@@ -193,7 +193,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
variant="ghost"
size="sm"
onClick={() => toggleGroup(group.permissions)}
className="text-xs h-7 text-[#01ab83] hover:text-[#01ab83] hover:bg-[#01ab83]/10"
className="text-xs h-7 text-primary-main hover:text-primary-main hover:bg-primary-main/10"
>
{allGroupSelected ? '取消全選' : '全選'}
</Button>

View File

@@ -68,7 +68,7 @@ export default function RoleIndex({ roles }: Props) {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Shield className="h-6 w-6 text-[#01ab83]" />
<Shield className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
@@ -123,7 +123,7 @@ export default function RoleIndex({ roles }: Props) {
className={cn(
"flex items-center justify-center gap-1 w-full h-full py-2 rounded-md transition-colors",
role.users_count > 0
? "text-[#01ab83] hover:bg-[#01ab83]/10 font-bold"
? "text-primary-main hover:bg-primary-main/10 font-bold"
: "text-gray-400 cursor-default"
)}
title={role.users_count > 0 ? "點擊查看成員名單" : ""}
@@ -177,7 +177,7 @@ export default function RoleIndex({ roles }: Props) {
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-[#01ab83]" />
<Users className="h-5 w-5 text-primary-main" />
{selectedRole?.display_name} -
</DialogTitle>
<DialogDescription>
@@ -204,7 +204,7 @@ export default function RoleIndex({ roles }: Props) {
</div>
<Link
href={route('users.edit', user.id)}
className="text-xs text-[#01ab83] hover:underline font-medium"
className="text-xs text-primary-main hover:underline font-medium"
>
</Link>

View File

@@ -55,7 +55,7 @@ export default function UserCreate({ roles }: Props) {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Users className="h-6 w-6 text-[#01ab83]" />
<Users className="h-6 w-6 text-primary-main" />
使
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -69,7 +69,7 @@ export default function UserEdit({ user, roles, currentRoles }: Props) {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Users className="h-6 w-6 text-[#01ab83]" />
<Users className="h-6 w-6 text-primary-main" />
使
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -86,7 +86,7 @@ export default function UserIndex({ users, filters }: Props) {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Users className="h-6 w-6 text-[#01ab83]" />
<Users className="h-6 w-6 text-primary-main" />
使
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -40,7 +40,9 @@ export default function Login() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 relative overflow-hidden">
<Head title="登入" />
<Head title="登入">
<link rel="icon" type="image/png" href="/favicon.png" />
</Head>
{/* 動態背景裝飾 */}
<div className="absolute top-0 -left-4 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob"></div>
@@ -91,7 +93,7 @@ export default function Login() {
/>
<div className={cn(
"w-9 h-5 rounded-full shadow-inner transition-colors duration-300 ease-in-out",
data.rememberUsername ? "bg-[#01ab83]" : "bg-gray-300"
data.rememberUsername ? "bg-primary-main" : "bg-gray-300"
)}></div>
<div className={cn(
"absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform duration-300 ease-in-out",
@@ -112,7 +114,7 @@ export default function Login() {
/>
<div className={cn(
"w-9 h-5 rounded-full shadow-inner transition-colors duration-300 ease-in-out",
data.remember ? "bg-[#01ab83]" : "bg-gray-300"
data.remember ? "bg-primary-main" : "bg-gray-300"
)}></div>
<div className={cn(
"absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform duration-300 ease-in-out",
@@ -123,7 +125,7 @@ export default function Login() {
</div>
<Button
className="w-full h-11 text-base bg-[#01ab83] hover:bg-[#018a6a] transition-all shadow-lg hover:shadow-xl"
className="w-full h-11 text-base bg-primary-main hover:bg-primary-dark transition-all shadow-lg hover:shadow-xl"
disabled={processing}
>
{processing ? "登入中..." : "登入系統"}

View File

@@ -31,9 +31,9 @@ export default function Dashboard({ stats }: Props) {
{
label: '商品總數',
value: stats.productsCount,
icon: <Package className="h-6 w-6 text-[#01ab83]" />,
icon: <Package className="h-6 w-6 text-primary-main" />,
description: '目前系統中的商品種類',
color: 'bg-[#01ab83]/10',
color: 'bg-primary-main/10',
},
{
label: '合作廠商',
@@ -80,7 +80,7 @@ export default function Dashboard({ stats }: Props) {
<div className="p-8 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<TrendingUp className="h-6 w-6 text-[#01ab83]" />
<TrendingUp className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"> ERP </p>
@@ -113,7 +113,7 @@ export default function Dashboard({ stats }: Props) {
{/* 警示與通知 */}
<div className="lg:col-span-1 space-y-6">
<h2 className="text-xl font-bold text-grey-0 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-[#01ab83]" />
<TrendingUp className="h-5 w-5 text-primary-main" />
</h2>
<div className="bg-white rounded-2xl border border-grey-4 shadow-sm divide-y divide-grey-4">
@@ -159,12 +159,12 @@ export default function Dashboard({ stats }: Props) {
<h2 className="text-xl font-bold text-grey-0"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link href="/products" className="group h-full">
<div className="bg-white p-6 rounded-2xl border border-grey-4 shadow-sm hover:border-[#01ab83] transition-all h-full flex flex-col justify-between">
<div className="bg-white p-6 rounded-2xl border border-grey-4 shadow-sm hover:border-primary-main transition-all h-full flex flex-col justify-between">
<div>
<h3 className="font-bold text-grey-0 mb-1 group-hover:text-[#01ab83]"></h3>
<h3 className="font-bold text-grey-0 mb-1 group-hover:text-primary-main"></h3>
<p className="text-sm text-grey-2"></p>
</div>
<div className="mt-4 flex items-center text-xs font-bold text-[#01ab83] group-hover:gap-2 transition-all">
<div className="mt-4 flex items-center text-xs font-bold text-primary-main group-hover:gap-2 transition-all">
<ChevronRight className="h-3 w-3" />
</div>
</div>

View File

@@ -27,17 +27,20 @@ export default function LandlordLogin() {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-900 relative overflow-hidden">
<Head title="總後台登入" />
<Head title="總後台登入">
<link rel="icon" type="image/png" href="/favicon-landlord.png" />
<link rel="shortcut icon" href="/favicon-landlord.png" type="image/png" />
<link rel="apple-touch-icon" href="/favicon-landlord.png" />
</Head>
{/* 深色背景裝飾 */}
<div className="absolute top-0 -left-4 w-96 h-96 bg-indigo-900/30 rounded-full mix-blend-screen filter blur-3xl opacity-50 animate-blob"></div>
<div className="absolute bottom-0 -right-4 w-96 h-96 bg-blue-900/30 rounded-full mix-blend-screen filter blur-3xl opacity-50 animate-blob animation-delay-2000"></div>
<div className="w-full max-w-md p-8 relative z-10">
<div className="flex flex-col items-center mb-8">
{/* 使用不同風格的 Logo 或純文字 */}
<div className="text-white text-3xl font-bold tracking-wider mb-2">Star ERP</div>
<div className="text-slate-400 text-sm tracking-widest uppercase"></div>
<div className="flex flex-col items-center mb-12">
<div className="text-white text-5xl font-bold tracking-wider mb-3">Star ERP</div>
<div className="text-slate-400 text-sm tracking-widest uppercase font-semibold"></div>
</div>
<div className="glass-panel p-8 rounded-xl shadow-2xl bg-slate-800/50 backdrop-blur-md border border-slate-700/50">

View File

@@ -0,0 +1,196 @@
import LandlordLayout from "@/Layouts/LandlordLayout";
import { Link, useForm } from "@inertiajs/react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Card } from "@/Components/ui/card";
import { ArrowLeft, LayoutDashboard } from "lucide-react";
import { FormEventHandler, useState } from "react";
import { toast } from "sonner";
import { generateLightestColor } from "@/utils/colorUtils";
interface Tenant {
id: string;
name: string;
branding?: {
logo_path?: string;
primary_color?: string;
text_color?: string;
};
}
interface BrandingProps {
tenant: Tenant;
logo_url?: string;
}
export default function Branding({ tenant, logo_url }: BrandingProps) {
const { data, setData, post, processing, errors } = useForm({
logo: null as File | null,
primary_color: tenant.branding?.primary_color || '#01ab83',
});
const [logoPreview, setLogoPreview] = useState<string | null>(logo_url || null);
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setData('logo', file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('landlord.tenants.branding.update', tenant.id), {
onSuccess: () => {
toast.success('樣式設定已更新');
},
onError: () => {
toast.error('更新失敗,請檢查輸入');
},
});
};
return (
<LandlordLayout title="客戶樣式管理">
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={route('landlord.tenants.show', tenant.id)}>
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</Link>
<div>
<h2 className="text-2xl font-bold text-slate-900">
</h2>
<p className="text-sm text-slate-500 mt-1">
{tenant.name}
</p>
</div>
</div>
</div>
{/* Branding Form */}
<Card className="p-6">
<form onSubmit={submit} className="space-y-6">
{/* Logo Upload */}
<div>
<Label htmlFor="logo" className="text-base font-semibold">
Logo
</Label>
<p className="text-sm text-slate-500 mb-3">
Logo 200×200 px 2MB
</p>
<Input
id="logo"
type="file"
accept="image/*"
onChange={handleLogoChange}
className="cursor-pointer"
/>
{errors.logo && (
<p className="text-sm text-red-600 mt-1">{errors.logo}</p>
)}
{logoPreview && (
<div className="mt-4 p-4 bg-slate-50 rounded-lg border border-slate-200">
<p className="text-sm text-slate-600 mb-2"></p>
<img
src={logoPreview}
alt="Logo Preview"
className="h-20 w-20 object-contain rounded-lg border border-slate-300"
/>
</div>
)}
</div>
{/* Primary Color */}
<div>
<Label htmlFor="primary_color" className="text-base font-semibold">
</Label>
<p className="text-sm text-slate-500 mb-3">
</p>
<div className="flex items-center gap-3">
<Input
id="primary_color"
type="color"
value={data.primary_color}
onChange={(e) => setData('primary_color', e.target.value)}
className="w-16 h-10 cursor-pointer"
/>
<Input
type="text"
value={data.primary_color}
onChange={(e) => setData('primary_color', e.target.value)}
pattern="^#[0-9A-Fa-f]{6}$"
className="w-32"
placeholder="#01ab83"
/>
</div>
{errors.primary_color && (
<p className="text-sm text-red-600 mt-1">{errors.primary_color}</p>
)}
{/* Preview */}
<div className="mt-4 p-4 bg-slate-50 rounded-lg border border-slate-200">
<p className="text-sm text-slate-600 mb-3"></p>
<div className="flex flex-wrap gap-4">
{/* 按鈕預覽 */}
<div>
<p className="text-xs text-slate-500 mb-2"></p>
<Button
type="button"
style={{ backgroundColor: data.primary_color }}
className="text-white"
>
</Button>
</div>
{/* 側邊欄選中狀態預覽 */}
<div>
<p className="text-xs text-slate-500 mb-2"></p>
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg"
style={{
backgroundColor: generateLightestColor(data.primary_color),
color: data.primary_color
}}
>
<LayoutDashboard className="w-5 h-5" />
<span className="font-medium"></span>
</div>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex justify-end gap-3 pt-4 border-t">
<Link href={route('landlord.tenants.show', tenant.id)}>
<Button type="button" variant="outline">
</Button>
</Link>
<Button
type="submit"
disabled={processing}
className="bg-primary-main hover:bg-primary-dark"
>
{processing ? '儲存中...' : '儲存樣式設定'}
</Button>
</div>
</form>
</Card>
</div>
</LandlordLayout>
);
}

View File

@@ -64,6 +64,13 @@ export default function TenantShow({ tenant }: Props) {
<h1 className="text-2xl font-bold text-slate-900">{tenant.name}</h1>
<p className="text-slate-500 mt-1"> ID: {tenant.id}</p>
</div>
<div className="flex gap-2">
<Link
href={`/landlord/tenants/${tenant.id}/branding`}
className="bg-primary-main hover:bg-primary-dark text-white px-4 py-2 rounded-lg transition-colors"
>
</Link>
<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"
@@ -71,6 +78,7 @@ export default function TenantShow({ tenant }: Props) {
</Link>
</div>
</div>
{/* Info Card */}
<div className="bg-white rounded-xl border border-slate-200 p-6">

View File

@@ -178,7 +178,7 @@ export default function ProductManagement({ products, categories, units, filters
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-[#01ab83]" />
<Package className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"></p>

View File

@@ -174,7 +174,7 @@ export default function CreatePurchaseOrder({
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ShoppingCart className="h-6 w-6 text-[#01ab83]" />
<ShoppingCart className="h-6 w-6 text-primary-main" />
{order ? "編輯採購單" : "建立採購單"}
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -109,7 +109,7 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ShoppingCart className="h-6 w-6 text-[#01ab83]" />
<ShoppingCart className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -38,7 +38,7 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ShoppingCart className="h-6 w-6 text-[#01ab83]" />
<ShoppingCart className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">{order.poNumber}</p>

View File

@@ -146,7 +146,7 @@ export default function VendorManagement({ vendors, filters }: PageProps) {
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Contact2 className="h-6 w-6 text-[#01ab83]" />
<Contact2 className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"> ERP </p>

View File

@@ -143,7 +143,7 @@ export default function VendorShow({ vendor, products }: ShowProps) {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Contact2 className="h-6 w-6 text-[#01ab83]" />
<Contact2 className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"></p>

View File

@@ -184,7 +184,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Boxes className="h-6 w-6 text-[#01ab83]" />
<Boxes className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -91,7 +91,7 @@ export default function EditInventory({ warehouse, inventory, transactions = []
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Boxes className="h-6 w-6 text-[#01ab83]" />
<Boxes className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -111,7 +111,7 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<WarehouseIcon className="h-6 w-6 text-[#01ab83]" />
<WarehouseIcon className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -109,7 +109,7 @@ export default function WarehouseInventoryPage({
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Boxes className="h-6 w-6 text-[#01ab83]" />
<Boxes className="h-6 w-6 text-primary-main" />
- {warehouse.name}
</h1>
<p className="text-gray-500 mt-1"></p>

View File

@@ -37,7 +37,7 @@ export default function InventoryHistory({ warehouse, inventory, transactions }:
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<History className="h-6 w-6 text-[#01ab83]" />
<History className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -120,7 +120,7 @@ export default function SafetyStockPage({
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Shield className="h-6 w-6 text-[#01ab83]" />
<Shield className="h-6 w-6 text-primary-main" />
- {warehouse.name}
</h1>
<p className="text-gray-500 mt-1">

View File

@@ -11,6 +11,12 @@ export interface AuthUser {
permissions: string[];
}
export interface Branding {
logo_url?: string | null;
primary_color?: string;
text_color?: string;
}
export interface PageProps {
auth: {
user: AuthUser | null;
@@ -19,6 +25,7 @@ export interface PageProps {
success?: string;
error?: string;
};
branding?: Branding | null;
}
declare global {

View File

@@ -0,0 +1,129 @@
/**
* 調整顏色亮度
* @param hex 原始顏色的 HEX 值(例如 "#01ab83"
* @param percent 調整百分比,正值變亮,負值變暗
* @returns 調整後的 HEX 色碼
*/
export function adjustBrightness(hex: string, percent: number): string {
// 移除 # 符號
const cleanHex = hex.replace('#', '');
// 解析 RGB
const num = parseInt(cleanHex, 16);
const r = (num >> 16) & 0xFF;
const g = (num >> 8) & 0xFF;
const b = num & 0xFF;
// 計算調整後的 RGB確保在 0-255 範圍內)
const newR = Math.min(255, Math.max(0, r + percent * 2.55));
const newG = Math.min(255, Math.max(0, g + percent * 2.55));
const newB = Math.min(255, Math.max(0, b + percent * 2.55));
// 轉回 HEX
const result = ((Math.round(newR) << 16) | (Math.round(newG) << 8) | Math.round(newB))
.toString(16)
.padStart(6, '0');
return `#${result}`;
}
/**
* 生成非常淺的背景色(類似原本的 primary-lightest: #e6f7f3
* 這個函式會保留原色的色調,但大幅提高亮度和降低飽和度
* @param hex 原始顏色的 HEX 值
* @returns 淺色背景的 HEX 色碼
*/
export function generateLightestColor(hex: string): string {
const cleanHex = hex.replace('#', '');
const num = parseInt(cleanHex, 16);
const r = (num >> 16) & 0xFF;
const g = (num >> 8) & 0xFF;
const b = num & 0xFF;
// 混合白色來創造非常淺的色調90% 白色 + 10% 原色)
const mixRatio = 0.1;
const newR = Math.round(255 * (1 - mixRatio) + r * mixRatio);
const newG = Math.round(255 * (1 - mixRatio) + g * mixRatio);
const newB = Math.round(255 * (1 - mixRatio) + b * mixRatio);
const result = ((newR << 16) | (newG << 8) | newB)
.toString(16)
.padStart(6, '0');
return `#${result}`;
}
/**
* 生成淺色(類似原本的 primary-light: #33bc9a
* @param hex 原始顏色的 HEX 值
* @returns 淺色的 HEX 色碼
*/
export function generateLightColor(hex: string): string {
const cleanHex = hex.replace('#', '');
const num = parseInt(cleanHex, 16);
const r = (num >> 16) & 0xFF;
const g = (num >> 8) & 0xFF;
const b = num & 0xFF;
// 混合白色70% 原色 + 30% 白色)
const mixRatio = 0.7;
const newR = Math.round(r * mixRatio + 255 * (1 - mixRatio));
const newG = Math.round(g * mixRatio + 255 * (1 - mixRatio));
const newB = Math.round(b * mixRatio + 255 * (1 - mixRatio));
const result = ((newR << 16) | (newG << 8) | newB)
.toString(16)
.padStart(6, '0');
return `#${result}`;
}
/**
* 生成深色(類似原本的 primary-dark: #018a6a
* @param hex 原始顏色的 HEX 值
* @returns 深色的 HEX 色碼
*/
export function generateDarkColor(hex: string): string {
const cleanHex = hex.replace('#', '');
const num = parseInt(cleanHex, 16);
const r = (num >> 16) & 0xFF;
const g = (num >> 8) & 0xFF;
const b = num & 0xFF;
// 降低亮度80% 原色)
const factor = 0.8;
const newR = Math.round(r * factor);
const newG = Math.round(g * factor);
const newB = Math.round(b * factor);
const result = ((newR << 16) | (newG << 8) | newB)
.toString(16)
.padStart(6, '0');
return `#${result}`;
}
/**
* 生成按鈕 active 狀態的顏色(比 dark 更深,約 60% 原色)
* @param hex 原始顏色的 HEX 值
* @returns 更深色的 HEX 色碼
*/
export function generateActiveColor(hex: string): string {
const cleanHex = hex.replace('#', '');
const num = parseInt(cleanHex, 16);
const r = (num >> 16) & 0xFF;
const g = (num >> 8) & 0xFF;
const b = num & 0xFF;
// 降低亮度60% 原色)
const factor = 0.6;
const newR = Math.round(r * factor);
const newG = Math.round(g * factor);
const newB = Math.round(b * factor);
const result = ((newR << 16) | (newG << 8) | newB)
.toString(16)
.padStart(6, '0');
return `#${result}`;
}

View File

@@ -6,9 +6,6 @@
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/png" href="/favicon.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -30,4 +30,8 @@ Route::prefix('landlord')->name('landlord.')->middleware(['web', 'auth', \App\Ht
// 租戶域名管理
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');
// 租戶樣式管理
Route::get('tenants/{tenant}/branding', [TenantController::class, 'showBranding'])->name('tenants.branding');
Route::post('tenants/{tenant}/branding', [TenantController::class, 'updateBranding'])->name('tenants.branding.update');
});