mosaicmap/app/admin/common/panel.tsx
2025-08-17 20:28:13 +08:00

259 lines
9.1 KiB
TypeScript

import { AdminPanelConfig, TabConfig } from "@/types/admin-panel";
import { cn } from "@/lib/utils";
import { SiteHeader } from "../site-header";
import { Button } from "@/components/ui/button";
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent
} from "@/components/ui/tabs";
import { useAdminPanel } from "@/hooks/use-admin-panel";
import { AdminSection } from "@/components/admin/admin-section";
import { Form } from "@/components/ui/form";
import { useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
interface AdminPanelProps {
config: AdminPanelConfig;
initialValues?: Record<string, any>;
onSubmit?: (values: Record<string, any>) => Promise<void>;
className?: string;
// Permission checker function
hasPermission?: (permission: string) => boolean;
}
export function AdminPanel({
config,
initialValues,
onSubmit,
className,
hasPermission = () => true
}: AdminPanelProps) {
const { state, actions, helpers, form } = useAdminPanel({
config,
initialValues,
onSubmit
});
const visibleTabs = config.tabs.filter(tab =>
!tab.permissions || tab.permissions.some(p => hasPermission(p))
);
const [activeTab, setActiveTab] = useState(() => {
// Find first accessible tab
const firstAccessibleTab = config.tabs.find(tab =>
!tab.disabled && (!tab.permissions || tab.permissions.some(p => hasPermission(p)))
);
return firstAccessibleTab?.id || config.tabs[0]?.id || "";
});
const renderHeaderActions = () => {
const actions = config.header.actions || [];
return actions
.filter(action => !action.permissions || action.permissions.some(p => hasPermission(p)))
.map(action => (
<Button
key={action.id}
variant={action.variant || "default"}
size={action.size || "default"}
disabled={action.disabled || state.loading}
onClick={action.onClick}
className="gap-2"
>
{action.loading && <Loader2 className="h-4 w-4 animate-spin" />}
{action.icon}
{action.label}
</Button>
));
};
// Render breadcrumbs
const renderBreadcrumbs = () => {
if (!config.header.breadcrumbs) return null;
return <SiteHeader breadcrumbs={config.header.breadcrumbs.map(crumb => ({
label: crumb.label,
href: crumb.href || ""
}))} />
};
// Render status indicators
const renderStatusIndicators = () => {
const indicators = [];
// Loading indicator
if (state.loading) {
indicators.push(
<Badge key="loading" variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</Badge>
);
}
// Saving indicator
if (state.saving) {
indicators.push(
<Badge key="saving" variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</Badge>
);
}
// Dirty state indicator
if (helpers.isDirty() && !state.saving) {
indicators.push(
<Badge key="dirty" variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
</Badge>
);
}
// Valid state indicator
if (!helpers.isDirty() && helpers.isValid()) {
indicators.push(
<Badge key="saved" variant="default" className="gap-1">
<CheckCircle className="h-3 w-3" />
</Badge>
);
}
// Validation errors indicator
if (Object.keys(state.errors).length > 0) {
indicators.push(
<Badge key="errors" variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
{Object.keys(state.errors).length}
</Badge>
);
}
return indicators;
};
const gridColsMap: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
};
const gridColsClass = gridColsMap[Math.min(visibleTabs.length, 6)] || "grid-cols-6";
const getVisibleSections = (tab: TabConfig) => {
return tab.sections.filter(section =>
!section.permissions || section.permissions.some(p => hasPermission(p))
);
};
return (
<div className={cn("bg-background h-full flex flex-col", className)}>
{renderBreadcrumbs()}
<ScrollArea className="flex-1 min-h-0">
<div className="flex items-center justify-between mt-8 px-8">
<div>
<h1 className="text-2xl font-bold text-foreground">
{config.header.title}
</h1>
{config.header.description && (
<p className="text-sm text-muted-foreground mt-1">
{config.header.description}
</p>
)}
</div>
<div className="flex items-center gap-3">
{/* Status indicators */}
{renderStatusIndicators()}
{/* Header actions */}
{renderHeaderActions()}
{/* Save button */}
{onSubmit && (
<Button
onClick={async () => await actions.save()}
disabled={state.saving || (!helpers.isDirty() && helpers.isValid())}
className="gap-2"
>
{state.saving && <Loader2 className="h-4 w-4 animate-spin" />}
</Button>
)}
</div>
</div>
<div className="container mx-auto px-6 py-6">
<Form {...form}>
<form>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{/* Tab Navigation */}
<TabsList className={cn(
"grid w-full gap-2",
gridColsClass
)}>
{visibleTabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
disabled={tab.disabled}
className="flex items-center gap-2 text-xs lg:text-sm w-full justify-center"
>
{tab.icon}
<span className="hidden sm:inline truncate">{tab.title}</span>
{tab.badge && (
<Badge variant="secondary" className="ml-1 text-xs">
{tab.badge}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{visibleTabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-6">
{getVisibleSections(tab).map((section) => (
<AdminSection
key={section.id}
section={section}
disabled={state.loading}
onChange={actions.setValue}
onBlur={() => { }} // Could implement field-level validation
form={form}
/>
))}
</TabsContent>
))}
</Tabs>
</form>
</Form>
</div>
</ScrollArea>
</div>
);
}