Commit 21546737 authored by ThinhNC's avatar ThinhNC

feat(system-config): add system configs and feature flags management UI

parent cf38fc59
......@@ -20,6 +20,7 @@ import {
import { registerSchema, RegisterInput } from "@/schemas/auth.schema";
import { useAuth } from "@/hooks/use-auth";
import { useLanguage } from "@/providers/language-provider";
import { usePublicConfigs } from "@/hooks/use-system-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
......@@ -27,6 +28,11 @@ import { Label } from "@/components/ui/label";
export default function RegisterPage() {
const { t } = useLanguage();
const { register: registerUser } = useAuth();
const { isFeatureEnabled } = usePublicConfigs();
const isRegistrationEnabled = isFeatureEnabled(
"feature.registration.enabled",
true,
);
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
......@@ -135,7 +141,20 @@ export default function RegisterPage() {
</div>
)}
{/* Form */}
{/* Registration Disabled Feature Flag Alert */}
{!isRegistrationEnabled && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3.5 text-xs text-amber-800 dark:text-amber-300 space-y-1">
<div className="font-bold flex items-center gap-1.5">
<XCircle className="h-4 w-4 text-amber-500" />
<span>Đăng Ký Tài Khoản Đang Tạm Khóa</span>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
Quản trị viên hệ thống đã tạm thời khóa tính năng đăng ký người dùng mới. Vui lòng liên hệ quản trị viên hoặc quay lại sau.
</p>
</div>
)}
{/* Register Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Full Name */}
<div className="space-y-1.5">
......@@ -294,14 +313,16 @@ export default function RegisterPage() {
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 transition-all duration-200 cursor-pointer hover:scale-[1.01]"
disabled={isSubmitting || !isRegistrationEnabled}
className="w-full rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2.5 shadow-md shadow-emerald-600/20 transition-all duration-200 cursor-pointer hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.auth.register.submitting}
</>
) : !isRegistrationEnabled ? (
"Đăng Ký Đang Tạm Khóa"
) : (
t.auth.register.submit
)}
......
import type { Metadata } from "next";
import { SystemConfigsManagementView } from "@/components/system-config/system-configs-management-view";
export const metadata: Metadata = {
title: "Cấu Hình & Cờ Tính Năng | Nhà Phát Triển",
description:
"Quản trị tham số hệ thống, cờ tính năng Feature Flags đa tầng và phân quyền truy cập tập trung.",
};
export default function DeveloperSystemConfigsPage() {
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<SystemConfigsManagementView showHeader={false} />
</div>
);
}
import type { Metadata } from "next";
import { SystemConfigsManagementView } from "@/components/system-config/system-configs-management-view";
export const metadata: Metadata = {
title: "Cấu Hình Hệ Thống",
description:
"Quản trị tham số hệ thống, cờ tính năng Feature Flags đa tầng và phân quyền truy cập tập trung.",
};
export default function SystemConfigsPage() {
return (
<div className="min-h-screen bg-background pb-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8 animate-in fade-in-50 duration-200">
<SystemConfigsManagementView showHeader={true} />
</div>
</div>
);
}
......@@ -11,12 +11,14 @@ import {
Users,
Shield,
History,
Sliders,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { useApiKeysList, useWebhookConfigsList } from "@/hooks/use-developer";
import { useUsersList } from "@/hooks/use-users";
import { useRolesList } from "@/hooks/use-roles";
import { useSystemConfigsList } from "@/hooks/use-system-config";
interface DeveloperShellProps {
children: React.ReactNode;
......@@ -29,19 +31,29 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
const canManageUsers = hasPermission("users.read") || role === "ADMIN";
const canManageRoles = hasPermission("roles.read") || role === "ADMIN";
const canReadAuditLogs = hasPermission("audit_logs.read") || role === "ADMIN";
const canManageConfigs =
hasPermission("system_configs.read") || role === "ADMIN";
// Counts for badges
const { data: apiKeys = [] } = useApiKeysList();
const { data: webhooks = [] } = useWebhookConfigsList();
const { data: usersData } = useUsersList({ limit: 1 }, { enabled: canManageUsers });
const { data: rolesData } = useRolesList({ limit: 1 }, { enabled: canManageRoles });
const { data: configsData } = useSystemConfigsList(
{ limit: 1 },
{ enabled: canManageConfigs },
);
const totalUsers = usersData?.meta?.total || 0;
const totalRoles = rolesData?.meta?.total ?? rolesData?.items?.length ?? 0;
const totalConfigs = configsData?.total || 0;
const isUsersTab = pathname.startsWith("/settings/developer/users");
const isRolesTab =
pathname.startsWith("/settings/developer/roles") ||
pathname.startsWith("/roles");
const isSystemConfigsTab =
pathname.startsWith("/settings/developer/system-configs") ||
pathname.startsWith("/system-configs");
const isApiKeysTab =
pathname === "/settings/developer/api-keys" ||
pathname === "/settings/developer";
......@@ -171,6 +183,26 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
<span>{t.nav.auditLogs}</span>
</Link>
)}
{/* 6. Tab Cấu Hình Hệ Thống & Feature Flags */}
{canManageConfigs && (
<Link
href="/settings/developer/system-configs"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isSystemConfigsTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<Sliders className="h-4 w-4" />
<span>{t.developer.tabs.systemConfigs}</span>
{totalConfigs > 0 && (
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[10px] px-2 py-0.5 border border-emerald-500/20 font-semibold">
{totalConfigs}
</span>
)}
</Link>
)}
</div>
{/* Tab Page Content */}
......
"use client";
import React from "react";
import {
Edit2,
Trash2,
Globe,
Lock,
Calendar,
ToggleLeft,
Sparkles,
ShieldAlert,
Share2,
Sliders,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { SystemConfigItem } from "@/types/system-config";
import { CopyButton } from "@/components/common/copy-button";
import { formatDate } from "@/lib/utils";
interface SystemConfigCardProps {
config: SystemConfigItem;
canManage: boolean;
onEdit: (config: SystemConfigItem) => void;
onDelete: (config: SystemConfigItem) => void;
onToggle: (key: string) => Promise<void>;
isToggling: boolean;
}
export function SystemConfigCard({
config,
canManage,
onEdit,
onDelete,
onToggle,
isToggling,
}: SystemConfigCardProps) {
const { t, locale } = useLanguage();
const isBoolean = typeof config.value === "boolean";
const boolVal = Boolean(config.value);
const getCategoryBadge = () => {
switch (config.category) {
case "FEATURE_FLAG":
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[10px] font-bold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Sparkles className="h-3 w-3" />
{t.systemConfigs.tabs.featureFlag}
</span>
);
case "SECURITY":
return (
<span className="inline-flex items-center gap-1 rounded-full bg-rose-500/10 px-2.5 py-0.5 text-[10px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/20">
<ShieldAlert className="h-3 w-3" />
{t.systemConfigs.tabs.security}
</span>
);
case "INTEGRATION":
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-0.5 text-[10px] font-bold text-amber-600 dark:text-amber-400 border border-amber-500/20">
<Share2 className="h-3 w-3" />
{t.systemConfigs.tabs.integration}
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[10px] font-bold text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Sliders className="h-3 w-3" />
{t.systemConfigs.tabs.general}
</span>
);
}
};
return (
<div className="group relative flex flex-col justify-between rounded-3xl border border-emerald-500/15 bg-card/70 p-5 shadow-sm backdrop-blur-sm transition-all duration-300 hover:border-emerald-500/30 hover:shadow-md hover:shadow-emerald-950/10 hover:scale-[1.01]">
<div className="space-y-3.5">
{/* Top Badges */}
<div className="flex items-center justify-between gap-2">
{getCategoryBadge()}
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold border ${
config.isPublic
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted/70 text-muted-foreground border-border/80"
}`}
>
{config.isPublic ? (
<Globe className="h-2.5 w-2.5" />
) : (
<Lock className="h-2.5 w-2.5" />
)}
<span>
{config.isPublic
? t.systemConfigs.publicBadge
: t.systemConfigs.privateBadge}
</span>
</span>
</div>
{/* Key & Copy */}
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5">
<h3 className="font-mono text-sm font-bold tracking-tight text-foreground break-all">
{config.key}
</h3>
{config.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{config.description}
</p>
)}
</div>
<CopyButton text={config.key} />
</div>
{/* Value Section */}
<div className="rounded-2xl border border-border/60 bg-muted/30 p-3">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1.5">
{t.systemConfigs.table.value}
</div>
{isBoolean ? (
<div className="flex items-center justify-between">
<span
className={`text-xs font-bold ${
boolVal
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground"
}`}
>
{boolVal
? t.systemConfigs.booleanActive
: t.systemConfigs.booleanInactive}
</span>
<button
type="button"
disabled={!canManage || isToggling}
onClick={() => onToggle(config.key)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
boolVal ? "bg-emerald-500" : "bg-muted-foreground/30"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out ${
boolVal ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
) : (
<div className="overflow-x-auto max-h-24 font-mono text-xs text-foreground font-medium">
{typeof config.value === "object" ? (
<pre className="text-[11px] leading-tight">
{JSON.stringify(config.value, null, 2)}
</pre>
) : (
<span>{String(config.value)}</span>
)}
</div>
)}
</div>
</div>
{/* Card Footer: Timestamp & Actions */}
<div className="mt-4 pt-3 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-[11px]">
<Calendar className="h-3 w-3 text-emerald-500" />
<span>{formatDate(config.updatedAt, locale)}</span>
</div>
{canManage && (
<div className="flex items-center gap-1">
<button
onClick={() => onEdit(config)}
className="flex h-7 w-7 items-center justify-center rounded-xl text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors cursor-pointer"
title={t.systemConfigs.editBtn}
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onDelete(config)}
className="flex h-7 w-7 items-center justify-center rounded-xl text-muted-foreground hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer"
title={t.systemConfigs.deleteBtn}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
);
}
"use client";
import React from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { SystemConfigItem } from "@/types/system-config";
interface SystemConfigDeleteDialogProps {
config: SystemConfigItem | null;
isOpen: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
isDeleting: boolean;
}
export function SystemConfigDeleteDialog({
config,
isOpen,
onClose,
onConfirm,
isDeleting,
}: SystemConfigDeleteDialogProps) {
const { t } = useLanguage();
if (!config) return null;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[440px] rounded-3xl border border-rose-500/20 bg-card/95 backdrop-blur-xl p-6 shadow-2xl shadow-rose-950/20">
<DialogHeader className="space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/20">
<AlertTriangle className="h-6 w-6" />
</div>
<DialogTitle className="text-center text-lg font-bold text-foreground">
{t.systemConfigs.deleteDialog.title}
</DialogTitle>
<DialogDescription className="text-center text-xs text-muted-foreground leading-relaxed">
{t.systemConfigs.deleteDialog.description}
</DialogDescription>
</DialogHeader>
{/* Config Summary Card */}
<div className="my-2 rounded-2xl border border-border/70 bg-muted/40 p-3.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t.systemConfigs.table.key}:
</span>
<code className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{config.key}
</code>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t.systemConfigs.table.category}:
</span>
<span className="text-xs font-medium text-foreground">
{config.category}
</span>
</div>
</div>
<DialogFooter className="flex flex-col sm:flex-row gap-2 mt-4">
<Button
variant="outline"
onClick={onClose}
disabled={isDeleting}
className="w-full sm:w-1/2 rounded-2xl border-border/80 text-xs font-semibold hover:bg-muted/70 cursor-pointer"
>
{t.systemConfigs.deleteDialog.cancelBtn}
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isDeleting}
className="w-full sm:w-1/2 rounded-2xl bg-rose-600 hover:bg-rose-700 text-white text-xs font-bold shadow-md shadow-rose-600/20 cursor-pointer"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t.systemConfigs.deleteDialog.confirmBtn
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Sliders,
Check,
ChevronDown,
Loader2,
Globe,
Lock,
Sparkles,
ToggleLeft,
Type,
Binary,
FileCode,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import {
CreateSystemConfigDto,
SystemConfigCategory,
SystemConfigItem,
UpdateSystemConfigDto,
} from "@/types/system-config";
interface SystemConfigFormModalProps {
isOpen: boolean;
onClose: () => void;
config: SystemConfigItem | null;
onSubmitCreate: (dto: CreateSystemConfigDto) => Promise<void>;
onSubmitUpdate: (key: string, dto: UpdateSystemConfigDto) => Promise<void>;
isSubmitting: boolean;
}
type DataType = "BOOLEAN" | "STRING" | "NUMBER" | "JSON";
export function SystemConfigFormModal({
isOpen,
onClose,
config,
onSubmitCreate,
onSubmitUpdate,
isSubmitting,
}: SystemConfigFormModalProps) {
const { t } = useLanguage();
const isEditing = Boolean(config);
// Form states
const [key, setKey] = useState("");
const [dataType, setDataType] = useState<DataType>("BOOLEAN");
const [boolValue, setBoolValue] = useState(true);
const [stringValue, setStringValue] = useState("");
const [numberValue, setNumberValue] = useState<number>(0);
const [jsonValue, setJsonValue] = useState("{}");
const [description, setDescription] = useState("");
const [category, setCategory] = useState<SystemConfigCategory>("GENERAL");
const [isPublic, setIsPublic] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
// Category dropdown state
const [isCategoryOpen, setIsCategoryOpen] = useState(false);
const categoryRef = useRef<HTMLDivElement>(null);
// Detect and initialize values when modal opens or config changes
useEffect(() => {
if (config) {
setKey(config.key);
setDescription(config.description || "");
setCategory(config.category);
setIsPublic(config.isPublic);
const val = config.value;
if (typeof val === "boolean") {
setDataType("BOOLEAN");
setBoolValue(val);
} else if (typeof val === "number") {
setDataType("NUMBER");
setNumberValue(val);
} else if (typeof val === "string") {
setDataType("STRING");
setStringValue(val);
} else {
setDataType("JSON");
setJsonValue(JSON.stringify(val, null, 2));
}
} else {
// Default for Create
setKey("");
setDataType("BOOLEAN");
setBoolValue(true);
setStringValue("");
setNumberValue(0);
setJsonValue("{}");
setDescription("");
setCategory("GENERAL");
setIsPublic(false);
}
setValidationError(null);
}, [config, isOpen]);
// Click outside category dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
categoryRef.current &&
!categoryRef.current.contains(event.target as Node)
) {
setIsCategoryOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const categories: Array<{
key: SystemConfigCategory;
label: string;
desc: string;
}> = [
{
key: "GENERAL",
label: t.systemConfigs.tabs.general,
desc: "Cấu hình chung của ứng dụng",
},
{
key: "FEATURE_FLAG",
label: t.systemConfigs.tabs.featureFlag,
desc: "Cờ bật tắt tính năng hệ thống",
},
{
key: "INTEGRATION",
label: t.systemConfigs.tabs.integration,
desc: "Thông số tích hợp dịch vụ ngoài",
},
{
key: "SECURITY",
label: t.systemConfigs.tabs.security,
desc: "Bảo mật, giới hạn truy cập và quota",
},
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setValidationError(null);
let parsedValue: unknown;
if (dataType === "BOOLEAN") {
parsedValue = boolValue;
} else if (dataType === "NUMBER") {
parsedValue = Number(numberValue);
if (isNaN(parsedValue as number)) {
setValidationError("Giá trị số không hợp lệ.");
return;
}
} else if (dataType === "STRING") {
parsedValue = stringValue;
} else if (dataType === "JSON") {
try {
parsedValue = JSON.parse(jsonValue);
} catch {
setValidationError("Định dạng JSON không hợp lệ. Vui lòng kiểm tra lại cú pháp.");
return;
}
}
if (!isEditing) {
const trimmedKey = key.trim();
if (!trimmedKey) {
setValidationError("Khóa cấu hình không được để trống.");
return;
}
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmedKey)) {
setValidationError(
"Khóa chỉ được chứa chữ cái, số, dấu chấm (.), gạch dưới (_) và gạch ngang (-).",
);
return;
}
await onSubmitCreate({
key: trimmedKey,
value: parsedValue,
description: description.trim() || undefined,
category,
isPublic,
});
} else {
await onSubmitUpdate(config!.key, {
value: parsedValue,
description: description.trim() || undefined,
category,
isPublic,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[560px] max-h-[90vh] overflow-y-auto rounded-3xl border border-emerald-500/20 bg-card/95 backdrop-blur-xl p-6 shadow-2xl shadow-emerald-950/15">
<DialogHeader className="space-y-1.5">
<div className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-emerald-500/10 border border-emerald-500/20">
<Sliders className="h-5 w-5" />
</div>
<DialogTitle className="text-lg font-extrabold text-foreground">
{isEditing
? t.systemConfigs.modal.editTitle
: t.systemConfigs.modal.createTitle}
</DialogTitle>
</div>
<DialogDescription className="text-xs text-muted-foreground leading-relaxed">
{isEditing
? t.systemConfigs.modal.editDesc
: t.systemConfigs.modal.createDesc}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-2">
{validationError && (
<div className="rounded-2xl border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-600 dark:text-rose-400 font-medium animate-in fade-in-50">
{validationError}
</div>
)}
{/* Key Field */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground flex items-center justify-between">
<span>{t.systemConfigs.modal.keyLabel}</span>
{isEditing && (
<span className="text-[11px] text-muted-foreground">
(Khóa không thể thay đổi)
</span>
)}
</Label>
<Input
value={key}
onChange={(e) => setKey(e.target.value)}
disabled={isEditing}
placeholder={t.systemConfigs.modal.keyPlaceholder}
className="rounded-2xl border-border/80 bg-background/50 font-mono text-xs focus:ring-2 focus:ring-emerald-500/30"
/>
</div>
{/* Category Dropdown */}
<div className="space-y-1.5 relative" ref={categoryRef}>
<Label className="text-xs font-semibold text-foreground">
{t.systemConfigs.modal.categoryLabel}
</Label>
<button
type="button"
onClick={() => setIsCategoryOpen(!isCategoryOpen)}
className="w-full flex items-center justify-between rounded-2xl border border-border/80 bg-background/50 px-3.5 py-2.5 text-xs text-foreground hover:bg-muted/50 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-bold border border-emerald-500/20">
{category}
</span>
<span className="font-semibold text-xs">
{categories.find((c) => c.key === category)?.label}
</span>
</div>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isCategoryOpen ? "rotate-180" : ""
}`}
/>
</button>
{isCategoryOpen && (
<div className="absolute left-0 right-0 top-full mt-1.5 rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150">
{categories.map((c) => {
const isSelected = category === c.key;
return (
<button
key={c.key}
type="button"
onClick={() => {
setCategory(c.key);
setIsCategoryOpen(false);
}}
className={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-xs text-left transition-colors cursor-pointer ${
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
}`}
>
<div>
<div className="font-semibold text-xs text-foreground">
{c.label}
</div>
<div className="text-[10px] text-muted-foreground">
{c.desc}
</div>
</div>
{isSelected && <Check className="h-4 w-4 text-emerald-500" />}
</button>
);
})}
</div>
)}
</div>
{/* Data Type Selector */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground">
{t.systemConfigs.modal.typeLabel}
</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ type: "BOOLEAN" as DataType, icon: ToggleLeft, label: "Boolean" },
{ type: "STRING" as DataType, icon: Type, label: "String" },
{ type: "NUMBER" as DataType, icon: Binary, label: "Number" },
{ type: "JSON" as DataType, icon: FileCode, label: "JSON" },
].map((item) => {
const isSelected = dataType === item.type;
const Icon = item.icon;
return (
<button
key={item.type}
type="button"
onClick={() => setDataType(item.type)}
className={`flex items-center justify-center gap-1.5 rounded-2xl py-2 px-2.5 text-xs font-semibold border transition-all cursor-pointer ${
isSelected
? "border-emerald-500 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 shadow-sm"
: "border-border/70 bg-background/50 text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Icon className="h-3.5 w-3.5" />
<span>{item.label}</span>
</button>
);
})}
</div>
</div>
{/* Dynamic Value Input according to Data Type */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground">
{t.systemConfigs.modal.valueLabel}
</Label>
{dataType === "BOOLEAN" && (
<div className="flex items-center justify-between rounded-2xl border border-border/80 bg-background/50 p-3">
<div className="space-y-0.5">
<div className="text-xs font-bold text-foreground">
{boolValue
? t.systemConfigs.modalBoolActive
: t.systemConfigs.modalBoolInactive}
</div>
<div className="text-[10px] text-muted-foreground">
{t.systemConfigs.modalBoolHelp} {boolValue ? "true" : "false"}
</div>
</div>
<button
type="button"
onClick={() => setBoolValue(!boolValue)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
boolValue ? "bg-emerald-500" : "bg-muted-foreground/30"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out ${
boolValue ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
)}
{dataType === "STRING" && (
<Input
value={stringValue}
onChange={(e) => setStringValue(e.target.value)}
placeholder="Nhập chuỗi văn bản..."
className="rounded-2xl border-border/80 bg-background/50 text-xs focus:ring-2 focus:ring-emerald-500/30"
/>
)}
{dataType === "NUMBER" && (
<Input
type="number"
value={numberValue}
onChange={(e) => setNumberValue(parseFloat(e.target.value) || 0)}
placeholder="Nhập số..."
className="rounded-2xl border-border/80 bg-background/50 font-mono text-xs focus:ring-2 focus:ring-emerald-500/30"
/>
)}
{dataType === "JSON" && (
<textarea
rows={5}
value={jsonValue}
onChange={(e) => setJsonValue(e.target.value)}
placeholder='{"key": "value"}'
className="w-full rounded-2xl border border-border/80 bg-background/50 p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
/>
)}
</div>
{/* Description */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-foreground">
{t.systemConfigs.modal.descriptionLabel}
</Label>
<textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.systemConfigs.modal.descriptionPlaceholder}
className="w-full rounded-2xl border border-border/80 bg-background/50 p-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-emerald-500/30"
/>
</div>
{/* Public Access Switch */}
<div className="flex items-center justify-between rounded-2xl border border-border/80 bg-background/50 p-3">
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex h-7 w-7 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
{isPublic ? (
<Globe className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</div>
<div>
<div className="text-xs font-bold text-foreground">
{t.systemConfigs.modal.isPublicLabel}
</div>
<div className="text-[11px] text-muted-foreground leading-tight">
{t.systemConfigs.modal.isPublicDesc}
</div>
</div>
</div>
<button
type="button"
onClick={() => setIsPublic(!isPublic)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
isPublic ? "bg-emerald-500" : "bg-muted-foreground/30"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out ${
isPublic ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
<DialogFooter className="flex flex-col sm:flex-row gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSubmitting}
className="w-full sm:w-auto rounded-2xl border-border/80 text-xs font-semibold hover:bg-muted/70 cursor-pointer"
>
{t.systemConfigs.modal.cancelBtn}
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.01]"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t.systemConfigs.modal.saveBtn
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
"use client";
import React from "react";
import {
Edit2,
Trash2,
Globe,
Lock,
Sparkles,
ShieldAlert,
Share2,
Sliders,
Code,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useLanguage } from "@/providers/language-provider";
import { SystemConfigItem } from "@/types/system-config";
import { CopyButton } from "@/components/common/copy-button";
import { TablePagination } from "@/components/common/table-pagination";
import { TableSortHeader } from "@/components/common/table-sort-header";
import { formatDate } from "@/lib/utils";
interface SystemConfigTableProps {
configs: SystemConfigItem[];
total: number;
page: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
sortBy: string;
sortOrder: "asc" | "desc";
onToggleSort: (column: string) => void;
canManage: boolean;
onEdit: (config: SystemConfigItem) => void;
onDelete: (config: SystemConfigItem) => void;
onToggle: (key: string) => Promise<void>;
isToggling: boolean;
}
export function SystemConfigTable({
configs,
total,
page,
limit,
onPageChange,
onLimitChange,
sortBy,
sortOrder,
onToggleSort,
canManage,
onEdit,
onDelete,
onToggle,
isToggling,
}: SystemConfigTableProps) {
const { t, locale } = useLanguage();
const getCategoryBadge = (category: string) => {
switch (category) {
case "FEATURE_FLAG":
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[10px] font-bold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 whitespace-nowrap">
<Sparkles className="h-3 w-3" />
{t.systemConfigs.tabs.featureFlag}
</span>
);
case "SECURITY":
return (
<span className="inline-flex items-center gap-1 rounded-full bg-rose-500/10 px-2.5 py-0.5 text-[10px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/20 whitespace-nowrap">
<ShieldAlert className="h-3 w-3" />
{t.systemConfigs.tabs.security}
</span>
);
case "INTEGRATION":
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-0.5 text-[10px] font-bold text-amber-600 dark:text-amber-400 border border-amber-500/20 whitespace-nowrap">
<Share2 className="h-3 w-3" />
{t.systemConfigs.tabs.integration}
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[10px] font-bold text-blue-600 dark:text-blue-400 border border-blue-500/20 whitespace-nowrap">
<Sliders className="h-3 w-3" />
{t.systemConfigs.tabs.general}
</span>
);
}
};
const renderValueCell = (config: SystemConfigItem) => {
if (typeof config.value === "boolean") {
const boolVal = Boolean(config.value);
return (
<div className="flex items-center gap-2.5">
<button
type="button"
disabled={!canManage || isToggling}
onClick={() => onToggle(config.key)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${
boolVal ? "bg-emerald-500" : "bg-muted-foreground/30"
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out ${
boolVal ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
<span
className={`text-xs font-semibold ${
boolVal
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground"
}`}
>
{boolVal
? t.systemConfigs.booleanOn
: t.systemConfigs.booleanOff}
</span>
</div>
);
}
if (typeof config.value === "number") {
return (
<span className="font-mono text-xs font-bold text-foreground bg-muted/60 px-2 py-0.5 rounded-lg border border-border">
{config.value}
</span>
);
}
if (typeof config.value === "string") {
return (
<span className="text-xs text-foreground font-medium max-w-[200px] truncate block" title={config.value}>
{config.value}
</span>
);
}
return (
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground bg-muted/50 px-2 py-0.5 rounded-lg border border-border">
<Code className="h-3 w-3 text-emerald-500" />
<span>JSON Data</span>
</span>
);
};
return (
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm transition-colors">
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-b border-border/70 hover:bg-transparent">
{/* Key Column */}
<TableHead className="py-3 px-4">
<TableSortHeader
field="key"
label={t.systemConfigs.table.key}
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={onToggleSort}
/>
</TableHead>
{/* Value Column */}
<TableHead className="py-3 px-4 text-xs font-semibold text-muted-foreground">
{t.systemConfigs.table.value}
</TableHead>
{/* Category Column */}
<TableHead className="py-3 px-4">
<TableSortHeader
field="category"
label={t.systemConfigs.table.category}
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={onToggleSort}
/>
</TableHead>
{/* Description Column */}
<TableHead className="py-3 px-4 text-xs font-semibold text-muted-foreground min-w-[180px]">
{t.systemConfigs.table.description}
</TableHead>
{/* Public Access Column */}
<TableHead className="py-3 px-4">
<TableSortHeader
field="isPublic"
label={t.systemConfigs.table.isPublic}
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={onToggleSort}
/>
</TableHead>
{/* Updated At Column */}
<TableHead className="py-3 px-4">
<TableSortHeader
field="updatedAt"
label={t.systemConfigs.table.updatedAt}
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={onToggleSort}
/>
</TableHead>
{/* Actions Column */}
{canManage && (
<TableHead className="py-3 px-4 text-right text-xs font-semibold text-muted-foreground">
{t.systemConfigs.table.actions}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{configs.map((config) => (
<TableRow
key={config.id}
className="border-b border-border/50 hover:bg-muted/40 transition-colors"
>
{/* Key with copy button */}
<TableCell className="py-3 px-4">
<div className="flex items-center gap-2">
<code className="font-mono text-xs font-bold text-foreground">
{config.key}
</code>
<CopyButton text={config.key} />
</div>
</TableCell>
{/* Value Cell */}
<TableCell className="py-3 px-4">
{renderValueCell(config)}
</TableCell>
{/* Category */}
<TableCell className="py-3 px-4">
{getCategoryBadge(config.category)}
</TableCell>
{/* Description */}
<TableCell className="py-3 px-4 text-xs text-muted-foreground max-w-xs truncate">
{config.description || "—"}
</TableCell>
{/* Public Access Badge */}
<TableCell className="py-3 px-4">
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-bold border ${
config.isPublic
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-muted/70 text-muted-foreground border-border/80"
}`}
>
{config.isPublic ? (
<Globe className="h-3 w-3" />
) : (
<Lock className="h-3 w-3" />
)}
<span>
{config.isPublic
? t.systemConfigs.publicBadge
: t.systemConfigs.privateBadge}
</span>
</span>
</TableCell>
{/* Updated At */}
<TableCell className="py-3 px-4 text-xs text-muted-foreground whitespace-nowrap">
{formatDate(config.updatedAt, locale)}
</TableCell>
{/* Action Buttons */}
{canManage && (
<TableCell className="py-3 px-4 text-right whitespace-nowrap">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => onEdit(config)}
className="flex h-7 w-7 items-center justify-center rounded-xl text-muted-foreground hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors cursor-pointer"
title={t.systemConfigs.editBtn}
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onDelete(config)}
className="flex h-7 w-7 items-center justify-center rounded-xl text-muted-foreground hover:bg-rose-500/10 hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer"
title={t.systemConfigs.deleteBtn}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Unified Table Pagination at the bottom of the card */}
<TablePagination
page={page}
totalPages={Math.ceil(total / limit) || 1}
total={total}
limit={limit}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
/>
</div>
);
}
"use client";
import React, { useState, useEffect, useMemo } from "react";
import {
Sliders,
Sparkles,
Globe,
Plus,
Search,
RotateCcw,
LayoutGrid,
Table as TableIcon,
AlertCircle,
RefreshCw,
Lock,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import {
useSystemConfigsList,
useCreateSystemConfig,
useUpdateSystemConfig,
useToggleFeatureFlag,
useDeleteSystemConfig,
} from "@/hooks/use-system-config";
import {
CreateSystemConfigDto,
SystemConfigCategory,
SystemConfigItem,
UpdateSystemConfigDto,
} from "@/types/system-config";
import { SystemConfigCard } from "./system-config-card";
import { SystemConfigTable } from "./system-config-table";
import { SystemConfigFormModal } from "./system-config-form-modal";
import { SystemConfigDeleteDialog } from "./system-config-delete-dialog";
import { StatCard } from "@/components/common/stat-card";
interface SystemConfigsManagementViewProps {
showHeader?: boolean;
}
export function SystemConfigsManagementView({
showHeader = true,
}: SystemConfigsManagementViewProps) {
const { t } = useLanguage();
const { role, hasPermission } = useAuth();
const canManage = hasPermission("system_configs.manage") || role === "ADMIN";
// Search & Filter states
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedCategory, setSelectedCategory] = useState<
"ALL" | SystemConfigCategory
>("ALL");
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [sortBy, setSortBy] = useState<string>("category");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// Modal dialog states
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<SystemConfigItem | null>(
null,
);
const [deletingConfig, setDeletingConfig] = useState<SystemConfigItem | null>(
null,
);
const [togglingKey, setTogglingKey] = useState<string | null>(null);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search.trim());
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// Query Data
const queryParams = useMemo(
() => ({
category: selectedCategory === "ALL" ? undefined : selectedCategory,
search: debouncedSearch || undefined,
page,
limit,
}),
[selectedCategory, debouncedSearch, page, limit],
);
const { data, isLoading, isError, error, refetch, isFetching } =
useSystemConfigsList(queryParams);
// Mutations
const createMutation = useCreateSystemConfig();
const updateMutation = useUpdateSystemConfig();
const toggleMutation = useToggleFeatureFlag();
const deleteMutation = useDeleteSystemConfig();
// Sort toggle handler
const handleToggleSort = (column: string) => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("asc");
}
};
// Sorted items
const sortedItems = useMemo(() => {
if (!data?.items) return [];
return [...data.items].sort((a, b) => {
let aVal: any = a[sortBy as keyof SystemConfigItem];
let bVal: any = b[sortBy as keyof SystemConfigItem];
if (typeof aVal === "string") {
return sortOrder === "asc"
? aVal.localeCompare(String(bVal))
: String(bVal).localeCompare(aVal);
}
if (typeof aVal === "boolean") {
return sortOrder === "asc"
? Number(aVal) - Number(bVal)
: Number(bVal) - Number(aVal);
}
return 0;
});
}, [data?.items, sortBy, sortOrder]);
// Metric counts
const totalConfigs = data?.total || 0;
const activeFlagsCount = useMemo(() => {
if (!data?.items) return 0;
return data.items.filter(
(item) =>
item.category === "FEATURE_FLAG" &&
(item.value === true || item.value === "true"),
).length;
}, [data?.items]);
const publicConfigsCount = useMemo(() => {
if (!data?.items) return 0;
return data.items.filter((item) => item.isPublic).length;
}, [data?.items]);
// Handlers
const handleOpenCreate = () => {
setEditingConfig(null);
setIsFormModalOpen(true);
};
const handleOpenEdit = (item: SystemConfigItem) => {
setEditingConfig(item);
setIsFormModalOpen(true);
};
const handleOpenDelete = (item: SystemConfigItem) => {
setDeletingConfig(item);
};
const handleToggle = async (key: string) => {
try {
setTogglingKey(key);
await toggleMutation.mutateAsync(key);
} catch {
// Error handled by mutation
} finally {
setTogglingKey(null);
}
};
const handleSubmitCreate = async (dto: CreateSystemConfigDto) => {
await createMutation.mutateAsync(dto);
setIsFormModalOpen(false);
};
const handleSubmitUpdate = async (
key: string,
dto: UpdateSystemConfigDto,
) => {
await updateMutation.mutateAsync({ key, dto });
setIsFormModalOpen(false);
};
const handleConfirmDelete = async () => {
if (!deletingConfig) return;
await deleteMutation.mutateAsync(deletingConfig.key);
setDeletingConfig(null);
};
return (
<div className="space-y-6">
{/* Header Section */}
{showHeader && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Sliders className="h-3.5 w-3.5" />
<span>{t.systemConfigs.title}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.systemConfigs.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.systemConfigs.subtitle}
</p>
</div>
{canManage && (
<Button
onClick={handleOpenCreate}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold px-4 py-2.5 shadow-md shadow-emerald-600/20 cursor-pointer transition-all hover:scale-[1.02] flex items-center gap-2"
>
<Plus className="h-4 w-4" />
<span>{t.systemConfigs.createBtn}</span>
</Button>
)}
</div>
)}
{/* KPI Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard
title={t.systemConfigs.stats.total}
value={totalConfigs}
icon={Sliders}
colorClassName="from-blue-500/20 to-indigo-500/5 text-blue-600 dark:text-blue-400 border-blue-500/30"
/>
<StatCard
title={t.systemConfigs.stats.activeFlags}
value={activeFlagsCount}
icon={Sparkles}
colorClassName="from-emerald-500/20 to-teal-500/5 text-emerald-600 dark:text-emerald-400 border-emerald-500/30"
/>
<StatCard
title={t.systemConfigs.stats.publicConfigs}
value={publicConfigsCount}
icon={Globe}
colorClassName="from-amber-500/20 to-orange-500/5 text-amber-600 dark:text-amber-400 border-amber-500/30"
/>
</div>
{/* Filter & Toolbar (relative z-30 to prevent dropdown clipping) */}
<div className="relative z-30 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 rounded-3xl border border-emerald-500/15 bg-card/60 p-3 shadow-sm backdrop-blur-sm">
{/* Search Bar */}
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t.systemConfigs.searchPlaceholder}
className="pl-9 pr-4 rounded-2xl border-border/80 bg-background/50 text-xs focus:ring-2 focus:ring-emerald-500/30"
/>
</div>
{/* Category Pills */}
<div className="flex items-center gap-1 overflow-x-auto pb-1 md:pb-0">
{[
{ key: "ALL", label: t.systemConfigs.tabs.all },
{ key: "GENERAL", label: t.systemConfigs.tabs.general },
{ key: "FEATURE_FLAG", label: t.systemConfigs.tabs.featureFlag },
{ key: "INTEGRATION", label: t.systemConfigs.tabs.integration },
{ key: "SECURITY", label: t.systemConfigs.tabs.security },
].map((tab) => {
const isSelected = selectedCategory === tab.key;
return (
<button
key={tab.key}
onClick={() => {
setSelectedCategory(tab.key as any);
setPage(1);
}}
className={`rounded-2xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all cursor-pointer ${
isSelected
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 font-bold shadow-sm"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground border border-transparent"
}`}
>
{tab.label}
</button>
);
})}
</div>
{/* View Mode Switcher & Refresh Button */}
<div className="flex items-center gap-2 justify-end">
<div className="flex items-center rounded-2xl border border-border/80 bg-muted/30 p-1">
<button
onClick={() => setViewMode("grid")}
className={`rounded-xl p-1.5 transition-colors cursor-pointer ${
viewMode === "grid"
? "bg-card text-emerald-600 dark:text-emerald-400 shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title="Chế độ thẻ"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
onClick={() => setViewMode("table")}
className={`rounded-xl p-1.5 transition-colors cursor-pointer ${
viewMode === "table"
? "bg-card text-emerald-600 dark:text-emerald-400 shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title="Chế độ bảng"
>
<TableIcon className="h-4 w-4" />
</button>
</div>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex h-9 w-9 items-center justify-center rounded-2xl border border-border/80 bg-background/50 text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors cursor-pointer"
title={t.systemConfigs.refreshBtn}
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
</div>
</div>
{/* Main Content Area (relative z-10) */}
<div className="relative z-10">
{/* Loading Skeleton */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((n) => (
<div
key={n}
className="h-48 rounded-3xl border border-border/60 bg-muted/30 animate-pulse"
/>
))}
</div>
)}
{/* Error State */}
{!isLoading && isError && (
<div className="rounded-3xl border border-rose-500/20 bg-rose-500/5 p-8 text-center space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-500/10 text-rose-600 dark:text-rose-400">
<AlertCircle className="h-6 w-6" />
</div>
<h3 className="text-base font-bold text-foreground">
{t.systemConfigs.toast.error}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
{error instanceof Error ? error.message : "Lỗi kết nối máy chủ"}
</p>
<Button
onClick={() => refetch()}
variant="outline"
size="sm"
className="rounded-2xl border-rose-500/30 text-rose-600 hover:bg-rose-500/10 cursor-pointer"
>
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
<span>{t.systemConfigs.refreshBtn}</span>
</Button>
</div>
)}
{/* Empty State */}
{!isLoading && !isError && sortedItems.length === 0 && (
<div className="rounded-3xl border border-emerald-500/15 bg-card/60 p-12 text-center space-y-4 backdrop-blur-sm">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-3xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<Sliders className="h-7 w-7" />
</div>
<div className="space-y-1">
<h3 className="text-base font-bold text-foreground">
Không tìm thấy cấu hình nào
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto">
Không có cấu hình nào phù hợp với bộ lọc hiện tại. Bạn có thể xóa bộ lọc tìm kiếm hoặc tạo cấu hình mới.
</p>
</div>
{canManage && (
<Button
onClick={handleOpenCreate}
size="sm"
className="rounded-2xl bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold cursor-pointer"
>
<Plus className="h-4 w-4 mr-1.5" />
<span>{t.systemConfigs.createBtn}</span>
</Button>
)}
</div>
)}
{/* Success Data View */}
{!isLoading && !isError && sortedItems.length > 0 && (
<>
{viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedItems.map((item) => (
<SystemConfigCard
key={item.id}
config={item}
canManage={canManage}
onEdit={handleOpenEdit}
onDelete={handleOpenDelete}
onToggle={handleToggle}
isToggling={togglingKey === item.key}
/>
))}
</div>
) : (
<SystemConfigTable
configs={sortedItems}
total={totalConfigs}
page={page}
limit={limit}
onPageChange={setPage}
onLimitChange={setLimit}
sortBy={sortBy}
sortOrder={sortOrder}
onToggleSort={handleToggleSort}
canManage={canManage}
onEdit={handleOpenEdit}
onDelete={handleOpenDelete}
onToggle={handleToggle}
isToggling={Boolean(togglingKey)}
/>
)}
</>
)}
</div>
{/* Create / Edit Form Modal */}
<SystemConfigFormModal
isOpen={isFormModalOpen}
onClose={() => setIsFormModalOpen(false)}
config={editingConfig}
onSubmitCreate={handleSubmitCreate}
onSubmitUpdate={handleSubmitUpdate}
isSubmitting={createMutation.isPending || updateMutation.isPending}
/>
{/* Delete Confirmation Dialog */}
<SystemConfigDeleteDialog
config={deletingConfig}
isOpen={Boolean(deletingConfig)}
onClose={() => setDeletingConfig(null)}
onConfirm={handleConfirmDelete}
isDeleting={deleteMutation.isPending}
/>
</div>
);
}
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { systemConfigService } from "@/services/system-config.service";
import {
CreateSystemConfigDto,
SystemConfigQueryParams,
UpdateSystemConfigDto,
} from "@/types/system-config";
export const SYSTEM_CONFIG_QUERY_KEYS = {
all: ["system-configs"] as const,
public: ["system-configs", "public"] as const,
list: (params?: SystemConfigQueryParams) =>
["system-configs", "list", params] as const,
detail: (key: string) => ["system-configs", "detail", key] as const,
};
/**
* Hook truy xuất cấu hình công khai và kiểm tra Feature Flags trên toàn client
*/
export function usePublicConfigs() {
const query = useQuery({
queryKey: SYSTEM_CONFIG_QUERY_KEYS.public,
queryFn: () => systemConfigService.getPublicConfigs(),
staleTime: 60 * 1000, // 1 phút
refetchOnWindowFocus: false,
});
const isFeatureEnabled = (
featureKey: string,
defaultValue: boolean = false,
): boolean => {
if (!query.data?.map) return defaultValue;
const val = query.data.map[featureKey];
if (typeof val === "boolean") return val;
if (val === "true" || val === 1 || val === "1") return true;
if (val === "false" || val === 0 || val === "0") return false;
return Boolean(val);
};
return {
...query,
isFeatureEnabled,
};
}
/**
* Hook lấy danh sách cấu hình quản trị (Admin)
*/
export function useSystemConfigsList(
params?: SystemConfigQueryParams,
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: SYSTEM_CONFIG_QUERY_KEYS.list(params),
queryFn: () => systemConfigService.getConfigs(params),
enabled: options?.enabled ?? true,
});
}
/**
* Hook lấy chi tiết 1 cấu hình theo key
*/
export function useSystemConfigItem(
key: string,
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: SYSTEM_CONFIG_QUERY_KEYS.detail(key),
queryFn: () => systemConfigService.getConfigByKey(key),
enabled: Boolean(key) && (options?.enabled ?? true),
});
}
/**
* Hook tạo cấu hình mới
*/
export function useCreateSystemConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateSystemConfigDto) =>
systemConfigService.createConfig(dto),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: SYSTEM_CONFIG_QUERY_KEYS.all,
});
},
});
}
/**
* Hook cập nhật cấu hình
*/
export function useUpdateSystemConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, dto }: { key: string; dto: UpdateSystemConfigDto }) =>
systemConfigService.updateConfig(key, dto),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: SYSTEM_CONFIG_QUERY_KEYS.all,
});
},
});
}
/**
* Hook bật/tắt nhanh boolean Feature Flag
*/
export function useToggleFeatureFlag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (key: string) => systemConfigService.toggleFeature(key),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: SYSTEM_CONFIG_QUERY_KEYS.all,
});
},
});
}
/**
* Hook xóa cấu hình
*/
export function useDeleteSystemConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (key: string) => systemConfigService.deleteConfig(key),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: SYSTEM_CONFIG_QUERY_KEYS.all,
});
},
});
}
......@@ -16,6 +16,7 @@ export const translations = {
users: "Quản Lý Người Dùng",
roles: "Vai Trò & Phân Quyền",
auditLogs: "Nhật Ký Hệ Thống",
systemConfigs: "Cấu Hình Hệ Thống",
statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -656,6 +657,7 @@ export const translations = {
apiKeys: "Khóa API",
webhooks: "Webhooks",
docs: "Tích Hợp Nhanh",
systemConfigs: "Cấu Hình & Cờ Tính Năng",
},
apiKeys: {
title: "Quản Lý Khóa API",
......@@ -1271,6 +1273,80 @@ export const translations = {
},
},
},
systemConfigs: {
title: "Cấu Hình & Cờ Tính Năng",
subtitle: "Quản trị tham số hệ thống, cờ tính năng Feature Flags đa tầng và phân quyền truy cập tập trung",
tabs: {
all: "Tất cả",
general: "Cấu hình chung",
featureFlag: "Cờ tính năng",
integration: "Tích hợp",
security: "Bảo mật",
},
stats: {
total: "Tổng cấu hình",
activeFlags: "Cờ đang kích hoạt",
publicConfigs: "Cấu hình công khai",
},
searchPlaceholder: "Tìm theo khóa cấu hình hoặc nội dung mô tả...",
createBtn: "Thêm cấu hình",
editBtn: "Chỉnh sửa",
deleteBtn: "Xóa cấu hình",
refreshBtn: "Làm mới",
table: {
key: "Khóa cấu hình",
value: "Giá trị",
category: "Phân loại",
description: "Mô tả",
isPublic: "Quyền truy cập",
updatedAt: "Cập nhật lúc",
actions: "Thao tác",
},
booleanActive: "Đang bật (true)",
booleanInactive: "Đang tắt (false)",
booleanOn: "Bật",
booleanOff: "Tắt",
modalBoolActive: "Kích hoạt (ON / True)",
modalBoolInactive: "Tắt tính năng (OFF / False)",
modalBoolHelp: "Cờ tính năng sẽ có giá trị",
publicBadge: "Công khai",
privateBadge: "Nội bộ",
modal: {
createTitle: "Thêm Cấu Hình Mới",
createDesc: "Thiết lập tham số hoặc cờ tính năng mới vào hệ thống mà không cần build lại máy chủ",
editTitle: "Chỉnh Sửa Cấu Hình",
editDesc: "Cập nhật giá trị cấu hình, hệ thống sẽ tự động đồng bộ và xóa bộ nhớ đệm tức thời",
keyLabel: "Khóa định danh",
keyPlaceholder: "Ví dụ: feature.ai.enabled hoặc app.name",
typeLabel: "Kiểu dữ liệu",
typeBoolean: "Boolean (Cờ bật tắt)",
typeString: "Chuỗi văn bản (String)",
typeNumber: "Số nguyên hoặc thập phân (Number)",
typeJson: "Dữ liệu cấu trúc (JSON)",
valueLabel: "Giá trị",
valuePlaceholder: "Nhập giá trị...",
categoryLabel: "Danh mục",
descriptionLabel: "Mô tả ý nghĩa",
descriptionPlaceholder: "Giải thích mục đích của cấu hình này cho Quản trị viên...",
isPublicLabel: "Cho phép đọc công khai",
isPublicDesc: "Cho phép giao diện khách đọc cấu hình này mà không cần đăng nhập",
saveBtn: "Lưu cấu hình",
cancelBtn: "Hủy bỏ",
},
deleteDialog: {
title: "Xác Nhận Xóa Cấu Hình",
description: "Bạn có chắc chắn muốn xóa cấu hình này không? Thao tác này sẽ xóa vĩnh viễn và đồng bộ ngay tới toàn bộ cụm máy chủ.",
confirmBtn: "Xóa vĩnh viễn",
cancelBtn: "Hủy bỏ",
},
toast: {
createSuccess: "Đã tạo cấu hình thành công",
updateSuccess: "Đã cập nhật cấu hình thành công",
toggleSuccess: "Đã thay đổi trạng thái cờ tính năng",
deleteSuccess: "Đã xóa cấu hình thành công",
error: "Đã có lỗi xảy ra, vui lòng thử lại",
},
},
},
en: {
// Navigation & Common
......@@ -1287,6 +1363,7 @@ export const translations = {
users: "User Management",
roles: "Roles & Permissions",
auditLogs: "System Audit Logs",
systemConfigs: "System Configurations",
statusActive: "Crawler Engine Active",
reload: "Reload page",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -1926,7 +2003,8 @@ export const translations = {
tabs: {
apiKeys: "API Keys",
webhooks: "Webhooks",
docs: "Quick Integration",
docs: "Integration Guide",
systemConfigs: "Configurations & Flags",
},
apiKeys: {
title: "API Keys Management",
......@@ -2542,6 +2620,80 @@ export const translations = {
},
},
},
systemConfigs: {
title: "Configurations & Flags",
subtitle: "Manage operational parameters, centralized Feature Flags, and system access policies",
tabs: {
all: "All",
general: "General",
featureFlag: "Feature Flags",
integration: "Integration",
security: "Security",
},
stats: {
total: "Total Configs",
activeFlags: "Active Flags",
publicConfigs: "Public Configs",
},
searchPlaceholder: "Search by key or description...",
createBtn: "New Configuration",
editBtn: "Edit",
deleteBtn: "Delete configuration",
refreshBtn: "Refresh",
table: {
key: "Configuration Key",
value: "Current Value",
category: "Category",
description: "Description",
isPublic: "Access Level",
updatedAt: "Updated At",
actions: "Actions",
},
booleanActive: "Enabled (true)",
booleanInactive: "Disabled (false)",
booleanOn: "On",
booleanOff: "Off",
modalBoolActive: "Enabled (ON / True)",
modalBoolInactive: "Disabled (OFF / False)",
modalBoolHelp: "Feature flag value will be",
publicBadge: "Public",
privateBadge: "Internal",
modal: {
createTitle: "Create Configuration",
createDesc: "Add a new parameter or feature flag to the system without rebuilding the server",
editTitle: "Edit Configuration",
editDesc: "Update configuration value with instant cluster-wide cache invalidation",
keyLabel: "Configuration Key",
keyPlaceholder: "e.g. feature.ai.enabled or app.name",
typeLabel: "Data Type",
typeBoolean: "Boolean (Feature Flag)",
typeString: "String",
typeNumber: "Number",
typeJson: "Structured JSON",
valueLabel: "Value",
valuePlaceholder: "Enter value...",
categoryLabel: "Category",
descriptionLabel: "Description",
descriptionPlaceholder: "Explain the purpose of this config for administrators...",
isPublicLabel: "Allow Public Read",
isPublicDesc: "Allows frontend clients to read this configuration without authentication",
saveBtn: "Save Configuration",
cancelBtn: "Cancel",
},
deleteDialog: {
title: "Confirm Delete Configuration",
description: "Are you sure you want to delete this configuration? This action is permanent and synchronizes immediately across all cluster instances.",
confirmBtn: "Delete Permanently",
cancelBtn: "Cancel",
},
toast: {
createSuccess: "Configuration created successfully",
updateSuccess: "Configuration updated successfully",
toggleSuccess: "Feature flag state toggled successfully",
deleteSuccess: "Configuration deleted successfully",
error: "An error occurred, please try again",
},
},
},
};
......
......@@ -16,9 +16,11 @@ const ADMIN_ROUTES = [
"/users",
"/audit-logs",
"/roles",
"/system-configs",
"/settings/developer/users",
"/settings/developer/roles",
"/settings/developer/audit-logs",
"/settings/developer/system-configs",
];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
......
import { apiClient } from "@/lib/api-client";
import {
CreateSystemConfigDto,
PublicConfigsResponse,
SystemConfigItem,
SystemConfigQueryParams,
SystemConfigsListResponse,
UpdateSystemConfigDto,
} from "@/types/system-config";
export const systemConfigService = {
/**
* Lấy danh sách cấu hình công khai và Feature Flags cho client/guest
* GET /api/proxy/system/public
*/
async getPublicConfigs(): Promise<PublicConfigsResponse> {
const response = await apiClient.get<{
success: boolean;
data: PublicConfigsResponse;
}>("/system/public");
return response.data.data;
},
/**
* Lấy danh sách cấu hình hệ thống (Admin)
* GET /api/proxy/system/configs
*/
async getConfigs(
params?: SystemConfigQueryParams,
): Promise<SystemConfigsListResponse> {
const response = await apiClient.get<{
success: boolean;
data: SystemConfigsListResponse;
}>("/system/configs", { params });
return response.data.data;
},
/**
* Lấy chi tiết một cấu hình theo khóa
* GET /api/proxy/system/configs/:key
*/
async getConfigByKey(key: string): Promise<SystemConfigItem> {
const response = await apiClient.get<{
success: boolean;
data: SystemConfigItem;
}>(`/system/configs/${encodeURIComponent(key)}`);
return response.data.data;
},
/**
* Tạo cấu hình mới
* POST /api/proxy/system/configs
*/
async createConfig(dto: CreateSystemConfigDto): Promise<SystemConfigItem> {
const response = await apiClient.post<{
success: boolean;
data: SystemConfigItem;
message?: string;
}>("/system/configs", dto);
return response.data.data;
},
/**
* Cập nhật cấu hình
* PUT /api/proxy/system/configs/:key
*/
async updateConfig(
key: string,
dto: UpdateSystemConfigDto,
): Promise<SystemConfigItem> {
const response = await apiClient.put<{
success: boolean;
data: SystemConfigItem;
message?: string;
}>(`/system/configs/${encodeURIComponent(key)}`, dto);
return response.data.data;
},
/**
* Bật/tắt nhanh boolean Feature Flag
* PATCH /api/proxy/system/features/:key/toggle
*/
async toggleFeature(key: string): Promise<SystemConfigItem> {
const response = await apiClient.patch<{
success: boolean;
data: SystemConfigItem;
message?: string;
}>(`/system/features/${encodeURIComponent(key)}/toggle`);
return response.data.data;
},
/**
* Xóa cấu hình
* DELETE /api/proxy/system/configs/:key
*/
async deleteConfig(key: string): Promise<void> {
await apiClient.delete(`/system/configs/${encodeURIComponent(key)}`);
},
};
export type SystemConfigCategory =
| "GENERAL"
| "FEATURE_FLAG"
| "INTEGRATION"
| "SECURITY";
export interface SystemConfigItem {
id: string;
key: string;
value: unknown;
description: string | null;
category: SystemConfigCategory;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}
export interface PublicConfigsResponse {
configs: Array<{
key: string;
value: unknown;
category: string;
description: string | null;
}>;
map: Record<string, unknown>;
}
export interface CreateSystemConfigDto {
key: string;
value: unknown;
description?: string;
category?: SystemConfigCategory;
isPublic?: boolean;
}
export interface UpdateSystemConfigDto {
value?: unknown;
description?: string;
category?: SystemConfigCategory;
isPublic?: boolean;
}
export interface SystemConfigQueryParams {
category?: SystemConfigCategory;
search?: string;
isPublic?: boolean | "true" | "false";
page?: number;
limit?: number;
}
export interface SystemConfigsListResponse {
items: SystemConfigItem[];
total: number;
page: number;
limit: number;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment