Files
star-erp/resources/js/Layouts/AuthenticatedLayout.tsx
sky121113 2e71a1cb29
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
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
2026-01-29 16:28:34 +08:00

598 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
LayoutDashboard,
ChevronRight,
Package,
ShoppingCart,
Menu,
X,
PanelLeftClose,
PanelLeftOpen,
Boxes,
Warehouse,
Truck,
Contact2,
LogOut,
User,
ChevronDown,
Settings,
Shield,
Users,
FileText,
Wallet,
BarChart3,
FileSpreadsheet,
BookOpen,
ClipboardCheck,
ArrowLeftRight
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo, useRef } from "react";
import { Link, usePage, Head } from "@inertiajs/react";
import { cn } from "@/lib/utils";
import BreadcrumbNav, { BreadcrumbItemType } from "@/Components/shared/BreadcrumbNav";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { usePermission } from "@/hooks/usePermission";
import ApplicationLogo from "@/Components/ApplicationLogo";
import { generateLightestColor, generateLightColor, generateDarkColor, generateActiveColor } from "@/utils/colorUtils";
import { PageProps } from "@/types/global";
interface MenuItem {
id: string;
label: string;
icon?: React.ReactNode;
route?: string;
children?: MenuItem[];
permission?: string | string[]; // 所需權限(單一或多個,滿足任一即可)
}
export default function AuthenticatedLayout({
children,
breadcrumbs
}: {
children: React.ReactNode,
breadcrumbs?: BreadcrumbItemType[]
}) {
const { url, props } = usePage<PageProps & { [key: string]: any }>();
const branding = props.branding;
const user = props.auth?.user || { name: 'Guest', username: 'guest', roles: [], role_labels: [], permissions: [] };
const { can, canAny } = usePermission();
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("sidebar-collapsed") === "true";
}
return false;
});
const [isMobileOpen, setIsMobileOpen] = useState(false);
// 完整的菜單定義(含權限配置)
const allMenuItems: MenuItem[] = [
{
id: "dashboard",
label: "儀表板",
icon: <LayoutDashboard className="h-5 w-5" />,
route: "/",
// 儀表板無需特定權限,所有登入使用者皆可存取
},
{
id: "inventory-management",
label: "商品與庫存管理",
icon: <Boxes className="h-5 w-5" />,
permission: ["products.view", "warehouses.view", "inventory.view"], // 滿足任一即可看到此群組
children: [
{
id: "product-management",
label: "商品資料管理",
icon: <Package className="h-4 w-4" />,
route: "/products",
permission: "products.view",
},
{
id: "warehouse-management",
label: "倉庫管理",
icon: <Warehouse className="h-4 w-4" />,
route: "/warehouses",
permission: "warehouses.view",
},
{
id: "stock-counting",
label: "庫存盤點",
icon: <ClipboardCheck className="h-4 w-4" />,
route: "/inventory/count-docs",
permission: "inventory.view",
},
{
id: "stock-adjustment",
label: "庫存盤調",
icon: <FileText className="h-4 w-4" />,
route: "/inventory/adjust-docs",
permission: "inventory.adjust",
},
{
id: "stock-transfer",
label: "庫存調撥",
icon: <ArrowLeftRight className="h-4 w-4" />,
route: "/inventory/transfer-orders",
permission: "inventory.transfer",
},
],
},
{
id: "supply-chain-management",
label: "供應鏈管理",
icon: <Truck className="h-5 w-5" />,
permission: ["vendors.view", "purchase_orders.view", "goods_receipts.view"],
children: [
{
id: "vendor-list",
label: "廠商資料管理",
icon: <Contact2 className="h-4 w-4" />,
route: "/vendors",
permission: "vendors.view",
},
{
id: "purchase-order-list",
label: "採購單管理",
icon: <ShoppingCart className="h-4 w-4" />,
route: "/purchase-orders",
permission: "purchase_orders.view",
},
{
id: "goods-receipt-list",
label: "進貨單管理",
icon: <ClipboardCheck className="h-4 w-4" />,
route: "/goods-receipts",
permission: "goods_receipts.view",
},
// {
// id: "delivery-note-list",
// label: "出貨單管理 (開發中)",
// icon: <Package className="h-4 w-4" />,
// // route: "/delivery-notes",
// permission: "delivery_notes.view",
// },
],
},
{
id: "production-management",
label: "生產管理",
icon: <Boxes className="h-5 w-5" />,
permission: ["production_orders.view", "recipes.view"],
children: [
{
id: "recipe-list",
label: "配方管理",
icon: <BookOpen className="h-4 w-4" />,
route: "/recipes",
permission: "recipes.view",
},
{
id: "production-order-list",
label: "生產工單",
icon: <Package className="h-4 w-4" />,
route: "/production-orders",
permission: "production_orders.view",
},
],
},
{
id: "finance-management",
label: "財務管理",
icon: <Wallet className="h-5 w-5" />,
permission: "utility_fees.view",
children: [
{
id: "utility-fee-list",
label: "公共事業費",
icon: <FileText className="h-4 w-4" />,
route: "/utility-fees",
permission: "utility_fees.view",
},
],
},
{
id: "report-management",
label: "報表管理",
icon: <BarChart3 className="h-5 w-5" />,
permission: "accounting.view",
children: [
{
id: "accounting-report",
label: "會計報表",
icon: <FileSpreadsheet className="h-4 w-4" />,
route: "/accounting-report",
permission: "accounting.view",
},
],
},
{
id: "system-management",
label: "系統管理",
icon: <Settings className="h-5 w-5" />,
permission: ["users.view", "roles.view"],
children: [
{
id: "user-management",
label: "使用者管理",
icon: <Users className="h-4 w-4" />,
route: "/admin/users",
permission: "users.view",
},
{
id: "role-management",
label: "角色與權限",
icon: <Shield className="h-4 w-4" />,
route: "/admin/roles",
permission: "roles.view",
},
{
id: "activity-log",
label: "操作紀錄",
icon: <FileText className="h-4 w-4" />,
route: "/admin/activity-logs",
permission: "system.view_logs",
},
],
},
];
// 檢查單一項目是否有權限
const hasPermissionForItem = (item: MenuItem): boolean => {
if (!item.permission) return true; // 無指定權限則預設有權限
if (Array.isArray(item.permission)) {
return canAny(item.permission);
}
return can(item.permission);
};
// 過濾菜單:移除無權限的項目,若父層所有子項目都無權限則隱藏父層
const menuItems = useMemo(() => {
return allMenuItems
.map((item) => {
// 如果有子項目,先過濾子項目
if (item.children && item.children.length > 0) {
const filteredChildren = item.children.filter(hasPermissionForItem);
// 若所有子項目都無權限,則隱藏整個群組
if (filteredChildren.length === 0) return null;
return { ...item, children: filteredChildren };
}
// 無子項目的單一選單,直接檢查權限
if (!hasPermissionForItem(item)) return null;
return item;
})
.filter((item): item is MenuItem => item !== null);
}, [can, canAny]);
// 初始化狀態:優先讀取 localStorage
const [expandedItems, setExpandedItems] = useState<string[]>(() => {
try {
const saved = localStorage.getItem("sidebar-expanded-items");
if (saved) return JSON.parse(saved);
} catch (e) {
console.error("Failed to parse sidebar state", e);
}
// 如果沒有存檔,則預設僅展開當前 URL 對應的群組
const activeItem = menuItems.find(item =>
item.children?.some(child => child.route && url.startsWith(child.route))
);
const defaultExpanded = ["inventory-management"];
if (activeItem && !defaultExpanded.includes(activeItem.id)) {
defaultExpanded.push(activeItem.id);
}
return defaultExpanded;
});
// 監聽 URL 變化,確保「當前」頁面所屬群組是展開的
// 但不要影響其他群組的狀態(除非使用者手動切換)
useEffect(() => {
const activeItem = menuItems.find(item =>
item.children?.some(child => child.route && url.startsWith(child.route))
);
if (activeItem && !expandedItems.includes(activeItem.id)) {
setExpandedItems(prev => {
const next = [...prev, activeItem.id];
localStorage.setItem("sidebar-expanded-items", JSON.stringify(next));
return next;
});
}
}, [url]);
useEffect(() => {
localStorage.setItem("sidebar-collapsed", String(isCollapsed));
}, [isCollapsed]);
// 全域監聽 flash 訊息並顯示 Toast
const lastFlash = useRef<any>(null);
useEffect(() => {
if (!props.flash) return;
// 檢查是否與上次顯示的訊息相同透過簡單的物件引用比對Inertia 在重導向後會產生新的 props 物件)
if (props.flash === lastFlash.current) return;
if (props.flash.success) {
toast.success(props.flash.success);
}
if (props.flash.error) {
toast.error(props.flash.error);
}
lastFlash.current = props.flash;
}, [props.flash]);
const toggleExpand = (itemId: string) => {
if (isCollapsed) {
setIsCollapsed(false);
if (!expandedItems.includes(itemId)) {
setExpandedItems(prev => [...prev, itemId]);
}
return;
}
setExpandedItems((prev) => {
const next = prev.includes(itemId)
? prev.filter((id) => id !== itemId)
: [...prev, itemId];
localStorage.setItem("sidebar-expanded-items", JSON.stringify(next));
return next;
});
};
const renderMenuItem = (item: MenuItem, level: number = 0) => {
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.includes(item.id);
const isActive = item.route
? (item.route === '/' ? url === '/' : url.startsWith(item.route))
: false;
return (
<div key={item.id} className="mb-1">
{hasChildren ? (
<button
onClick={() => toggleExpand(item.id)}
className={cn(
"w-full flex items-center transition-all rounded-lg group",
level === 0 ? "px-3 py-2.5" : "px-3 py-2 pl-10",
level === 0 && !isCollapsed && "hover:bg-slate-100",
isCollapsed && level === 0 && "justify-center px-0 h-10 w-10 mx-auto hover:bg-slate-100"
)}
title={isCollapsed ? item.label : ""}
>
{level === 0 && (
<span className={cn(
"flex-shrink-0 transition-all",
isCollapsed ? "mr-0" : "mr-3 text-slate-500 group-hover:text-slate-900"
)}>
{item.icon}
</span>
)}
{!isCollapsed && (
<>
<span className="flex-1 text-left text-base font-medium text-slate-700 group-hover:text-slate-900 truncate">
{item.label}
</span>
<span className="flex-shrink-0 transition-transform duration-200">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-slate-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-slate-400" />
)}
</span>
</>
)}
</button>
) : (
<Link
href={item.route || "#"}
onClick={() => setIsMobileOpen(false)}
className={cn(
"w-full flex items-center transition-all rounded-lg group",
level === 0 ? "px-3 py-2.5" : "px-3 py-2",
level > 0 && !isCollapsed && "pl-11",
isActive ? "bg-primary-lightest text-primary-main" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
isCollapsed && level === 0 && "justify-center px-0 h-10 w-10 mx-auto"
)}
title={isCollapsed ? item.label : ""}
>
{item.icon && (
<span className={cn(
"flex-shrink-0 transition-all",
isCollapsed ? "mr-0" : "mr-3",
isActive ? "text-primary-main" : "text-slate-500 group-hover:text-slate-900"
)}>
{item.icon}
</span>
)}
{!isCollapsed && (
<span className="text-base font-medium truncate">
{item.label}
</span>
)}
</Link>
)}
{hasChildren && isExpanded && !isCollapsed && (
<div className="mt-1 space-y-1">
{item.children?.map((child) => renderMenuItem(child, level + 1))}
</div>
)}
</div>
);
};
return (
<div className="flex min-h-screen bg-slate-50">
<Head>
<link rel="icon" type="image/png" href="/favicon.png" />
</Head>
<style>{`
:root {
--primary-main: ${branding?.primary_color || '#01ab83'};
--primary-dark: ${generateDarkColor(branding?.primary_color || '#01ab83')};
--primary-light: ${generateLightColor(branding?.primary_color || '#01ab83')};
--primary-lightest: ${generateLightestColor(branding?.primary_color || '#01ab83')};
--button-main-active: ${generateActiveColor(branding?.primary_color || '#01ab83')};
}
`}</style>
{/* 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">
<button
onClick={() => setIsMobileOpen(!isMobileOpen)}
className="p-2 -ml-2 text-slate-600 hover:bg-slate-100 rounded-lg lg:hidden"
>
{isMobileOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
<Link href="/" className="flex items-center gap-2">
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
<span className="font-bold text-slate-900">{branding?.short_name || '小小冰室'} ERP</span>
</Link>
</div>
{/* User Menu */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
<div className="hidden md:flex flex-col items-end mr-1">
<span className="text-sm font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
{user.name} ({user.username})
</span>
<span className="text-xs text-slate-500">
{user.role_labels?.[0] || user.roles?.[0] || '一般用戶'}
</span>
</div>
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center text-slate-600 group-hover:bg-primary-lightest group-hover:text-primary-main transition-all">
<User className="h-5 w-5" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 z-[100]" sideOffset={8}>
<DropdownMenuLabel>{user.name} ({user.username})</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('profile.edit')}
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
>
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
<span>使</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={route('logout')}
method="post"
as="button"
className="w-full flex items-center cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
>
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
{/* Sidebar Desktop */}
<aside className={cn(
"fixed left-0 top-0 bottom-0 bg-white border-r border-slate-200 z-50 transition-all duration-300 ease-in-out hidden lg:flex flex-col pt-16",
isCollapsed ? "w-20" : "w-64"
)}>
<div className="hidden h-16 items-center justify-between px-6 border-b border-slate-100">
{!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-primary-main text-lg tracking-tight">{branding?.short_name || '小小冰室'} ERP</span>
</Link>
)}
{isCollapsed && (
<Link href="/" className="w-8 h-8 mx-auto">
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
</Link>
)}
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6">
<nav className="space-y-1">
{menuItems.map((item) => renderMenuItem(item))}
</nav>
</div>
<div className="p-4 border-t border-slate-100 flex items-center justify-between">
{!isCollapsed && <p className="text-[10px] font-medium text-slate-400 uppercase tracking-wider px-2">Version 1.0.0</p>}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
"p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors",
isCollapsed && "mx-auto"
)}
title={isCollapsed ? "展開側邊欄" : "縮合側邊欄"}
>
{isCollapsed ? <PanelLeftOpen className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</button>
</div>
</aside >
{/* Mobile Sidebar Overlay */}
{
isMobileOpen && (
<div
className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[70] lg:hidden"
onClick={() => setIsMobileOpen(false)}
/>
)
}
{/* Mobile Sidebar Drawer */}
<aside className={cn(
"fixed left-0 top-0 bottom-0 w-72 bg-white z-[80] transition-transform duration-300 ease-in-out lg:hidden flex flex-col shadow-2xl",
isMobileOpen ? "translate-x-0" : "-translate-x-full"
)}>
<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-primary-main text-lg">{branding?.short_name || '小小冰室'} ERP</span>
</Link>
<button onClick={() => setIsMobileOpen(false)} className="p-2 text-slate-400">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<nav className="space-y-1">
{menuItems.map((item) => renderMenuItem(item))}
</nav>
</div>
</aside>
{/* Main Content */}
<main className={cn(
"flex-1 flex flex-col transition-all duration-300 min-h-screen overflow-auto",
"lg:ml-64",
isCollapsed && "lg:ml-20",
"pt-16" // 始終為頁首保留空間
)}>
<div className="relative">
<div className="container mx-auto px-6 pt-6 max-w-7xl">
{breadcrumbs && breadcrumbs.length > 1 && (
<BreadcrumbNav items={breadcrumbs} className="mb-2" />
)}
</div>
{children}
</div>
<footer className="mt-auto py-6 text-center text-sm text-slate-400">
Copyright &copy; {new Date().getFullYear()} {branding?.name || '小小冰室'}. All rights reserved. Design by
</footer>
<Toaster richColors closeButton position="top-center" />
</main>
</div >
);
}