Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
U
upgrade-data-crawler-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
upgrade-data-crawler-fe
Commits
21546737
Commit
21546737
authored
Sep 08, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(system-config): add system configs and feature flags management UI
parent
cf38fc59
Changes
14
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
2084 additions
and
4 deletions
+2084
-4
page.tsx
src/app/(auth)/register/page.tsx
+24
-3
page.tsx
src/app/settings/developer/system-configs/page.tsx
+16
-0
page.tsx
src/app/system-configs/page.tsx
+18
-0
developer-shell.tsx
src/components/developer/developer-shell.tsx
+32
-0
system-config-card.tsx
src/components/system-config/system-config-card.tsx
+192
-0
system-config-delete-dialog.tsx
src/components/system-config/system-config-delete-dialog.tsx
+96
-0
system-config-form-modal.tsx
src/components/system-config/system-config-form-modal.tsx
+485
-0
system-config-table.tsx
src/components/system-config/system-config-table.tsx
+321
-0
system-configs-management-view.tsx
...mponents/system-config/system-configs-management-view.tsx
+452
-0
use-system-config.ts
src/hooks/use-system-config.ts
+138
-0
translations.ts
src/lib/i18n/translations.ts
+153
-1
middleware.ts
src/middleware.ts
+2
-0
system-config.service.ts
src/services/system-config.service.ts
+99
-0
system-config.ts
src/types/system-config.ts
+56
-0
No files found.
src/app/(auth)/register/page.tsx
View file @
21546737
...
...
@@ -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
)
}
...
...
src/app/settings/developer/system-configs/page.tsx
0 → 100644
View file @
21546737
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
>
);
}
src/app/system-configs/page.tsx
0 → 100644
View file @
21546737
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
>
);
}
src/components/developer/developer-shell.tsx
View file @
21546737
...
...
@@ -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 */
}
...
...
src/components/system-config/system-config-card.tsx
0 → 100644
View file @
21546737
"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
>
);
}
src/components/system-config/system-config-delete-dialog.tsx
0 → 100644
View file @
21546737
"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
>
);
}
src/components/system-config/system-config-form-modal.tsx
0 → 100644
View file @
21546737
"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
>
);
}
src/components/system-config/system-config-table.tsx
0 → 100644
View file @
21546737
"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
>
);
}
src/components/system-config/system-configs-management-view.tsx
0 → 100644
View file @
21546737
"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
>
);
}
src/hooks/use-system-config.ts
0 → 100644
View file @
21546737
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
,
});
},
});
}
src/lib/i18n/translations.ts
View file @
21546737
...
...
@@ -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"
,
},
},
},
};
...
...
src/middleware.ts
View file @
21546737
...
...
@@ -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)
...
...
src/services/system-config.service.ts
0 → 100644
View file @
21546737
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
)}
`
);
},
};
src/types/system-config.ts
0 → 100644
View file @
21546737
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
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment