Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
V
VCCI-News
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
Văn Hoàng
VCCI-News
Commits
051d8a8b
Commit
051d8a8b
authored
May 11, 2026
by
Lê Bảo Hồng Đức
☄
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
080821e5
Changes
30
Show whitespace changes
Inline
Side-by-side
Showing
30 changed files
with
3778 additions
and
140 deletions
+3778
-140
page.tsx
src/app/admin/base-config/page.tsx
+1137
-0
page.tsx
src/app/admin/contact-management/contact-requests/page.tsx
+270
-0
page.tsx
...admin/contact-management/membership-applications/page.tsx
+229
-0
page.tsx
src/app/admin/contact-management/newsletter-emails/page.tsx
+183
-0
page.tsx
src/app/admin/contact-management/page.tsx
+5
-0
page.tsx
src/app/admin/dashboard/page.tsx
+541
-0
page.tsx
src/app/admin/header-config/[categoryId]/posts/page.tsx
+1
-1
header-category-form-dialog.tsx
.../header-config/components/header-category-form-dialog.tsx
+3
-3
layout.tsx
src/app/admin/layout.tsx
+1
-1
page.tsx
src/app/admin/media/page.tsx
+524
-0
page.tsx
src/app/admin/members/fields/page.tsx
+4
-4
page.tsx
src/app/admin/members/page.tsx
+27
-38
page.tsx
src/app/admin/members/regions/page.tsx
+4
-4
page.tsx
src/app/admin/news/page.tsx
+2
-2
page.tsx
src/app/admin/page.tsx
+1
-1
page.tsx
src/app/admin/videos/page.tsx
+2
-2
admin-table-layout.tsx
src/components/admin/admin-table-layout.tsx
+1
-1
contact-management-detail-dialog.tsx
src/components/admin/contact-management-detail-dialog.tsx
+89
-0
image-picker.tsx
src/components/admin/image-picker.tsx
+1
-1
member-form.tsx
src/components/admin/member-form.tsx
+41
-8
news-form.tsx
src/components/admin/news-form.tsx
+3
-3
admin-header.tsx
src/components/shared/admin-header.tsx
+13
-5
admin-sidebar.tsx
src/components/shared/admin-sidebar.tsx
+99
-57
alert-dialog.tsx
src/components/ui/alert-dialog.tsx
+1
-1
dialog.tsx
src/components/ui/dialog.tsx
+1
-1
admin-news.ts
src/mockdata/admin-news.ts
+1
-1
base-config.ts
src/mockdata/base-config.ts
+293
-0
contact-management.ts
src/mockdata/contact-management.ts
+247
-0
members.ts
src/mockdata/members.ts
+47
-1
_components.css
src/styles/_components.css
+7
-5
No files found.
src/app/admin/base-config/page.tsx
0 → 100644
View file @
051d8a8b
"use client"
;
import
*
as
React
from
"react"
;
import
{
ChevronLeft
,
ChevronRight
,
Edit
,
Globe
,
ImagePlus
,
Mail
,
MapPin
,
Phone
,
Plus
,
Save
,
Trash2
,
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminImagePicker
}
from
"@/components/admin/image-picker"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardHeader
,
CardTitle
}
from
"@/components/ui/card"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogFooter
,
DialogHeader
,
DialogTitle
,
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Checkbox
}
from
"@/components/ui/checkbox"
;
import
{
Switch
}
from
"@/components/ui/switch"
;
import
{
Tabs
,
TabsContent
,
TabsList
,
TabsTrigger
}
from
"@/components/ui/tabs"
;
import
{
Textarea
}
from
"@/components/ui/textarea"
;
import
type
{
AdminMediaItem
}
from
"@/mockdata/admin-news"
;
import
{
readAdminMediaItems
}
from
"@/mockdata/admin-news"
;
import
{
type
BaseConfigBannerItem
,
type
BaseConfigBranchItem
,
type
BaseConfigData
,
type
BaseConfigLogoItem
,
type
BaseConfigSocialItem
,
EMPTY_BASE_CONFIG_BRANCH
,
cloneBaseConfigData
,
createBaseConfigItemId
,
getMediaMap
,
persistBaseConfig
,
readBaseConfig
,
sortBaseConfigBanners
,
sortBaseConfigSocials
,
}
from
"@/mockdata/base-config"
;
const
fieldClassName
=
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
type
ConfigItemMode
=
"logo"
|
"banner"
;
type
ConfigItemForm
=
{
name
:
string
;
imageId
:
string
;
isActive
:
boolean
;
displayTimeSeconds
:
number
;
sortOrder
:
number
;
};
function
emptyItemForm
():
ConfigItemForm
{
return
{
name
:
""
,
imageId
:
""
,
isActive
:
true
,
displayTimeSeconds
:
5
,
sortOrder
:
1
,
};
}
function
resolveMediaItem
(
mediaMap
:
Map
<
string
,
AdminMediaItem
>
,
imageId
:
string
)
{
return
mediaMap
.
get
(
imageId
)
??
null
;
}
function
ConfigItemPreview
({
title
,
item
,
media
,
current
,
onSelect
,
}:
{
title
:
string
;
item
:
BaseConfigBannerItem
;
media
:
AdminMediaItem
|
null
;
current
:
boolean
;
onSelect
:
()
=>
void
;
})
{
return
(
<
button
type=
"button"
onClick=
{
onSelect
}
className=
{
`overflow-hidden rounded-3xl border text-left transition-all ${
current
? "border-[#063e8e]/35 bg-[#edf4ff] shadow-[0_10px_24px_rgba(6,62,142,0.12)]"
: "border-[#063e8e]/10 bg-white hover:border-[#063e8e]/25 hover:shadow-sm"
}`
}
>
<
div
className=
"relative aspect-[16/10] overflow-hidden bg-[#eef4ff]"
>
{
media
?
(
<
SafeNextImage
src=
{
media
.
url
}
alt=
{
media
.
alt
||
media
.
name
}
fill
className=
"object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center text-sm text-gray-500"
>
Chưa chọn hình ảnh
</
div
>
)
}
</
div
>
<
div
className=
"space-y-2 px-4 py-3"
>
<
div
className=
"line-clamp-1 text-sm font-semibold text-[#163b73]"
>
{
title
}
</
div
>
<
div
className=
"line-clamp-2 text-sm text-gray-600"
>
{
item
.
name
}
</
div
>
</
div
>
</
button
>
);
}
function
ConfigItemDialog
({
open
,
mode
,
form
,
previewMedia
,
saving
,
title
,
description
,
onOpenChange
,
onChange
,
onPickImage
,
onSubmit
,
}:
{
open
:
boolean
;
mode
:
ConfigItemMode
;
form
:
ConfigItemForm
;
previewMedia
:
AdminMediaItem
|
null
;
saving
:
boolean
;
title
:
string
;
description
:
string
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onChange
:
<
K
extends
keyof
ConfigItemForm
>
(key: K, value: ConfigItemForm[K]) =
>
void;
onPickImage: () =
>
void;
onSubmit: () =
>
void;
})
{
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"flex max-h-[88vh] max-w-xl flex-col overflow-hidden rounded-3xl border-[#063e8e]/15 bg-white p-0"
>
<
DialogHeader
>
<
div
className=
"border-b border-[#063e8e]/10 px-6 py-5"
>
<
DialogTitle
className=
"text-xl text-[#063e8e]"
>
{
title
}
</
DialogTitle
>
<
DialogDescription
className=
"mt-2 text-sm text-gray-600"
>
{
description
}
</
DialogDescription
>
</
div
>
</
DialogHeader
>
<
div
className=
"scrollbar min-h-0 flex-1 overflow-y-auto px-6 py-5"
>
<
div
className=
"space-y-5"
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Tên hiển thị
</
Label
>
<
Input
value=
{
form
.
name
}
onChange=
{
(
event
)
=>
onChange
(
"name"
,
event
.
target
.
value
)
}
placeholder=
{
mode
===
"logo"
?
"Nhập tên logo..."
:
"Nhập tên banner..."
}
className=
{
fieldClassName
}
/>
</
div
>
{
mode
===
"banner"
?
(
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Thời gian hiển thị (giây)
</
Label
>
<
Input
type=
"number"
min=
{
1
}
max=
{
60
}
value=
{
form
.
displayTimeSeconds
}
onChange=
{
(
event
)
=>
onChange
(
"displayTimeSeconds"
,
Number
(
event
.
target
.
value
||
1
))
}
className=
{
fieldClassName
}
/>
</
div
>
)
:
null
}
{
mode
===
"banner"
?
(
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Thứ tự hiển thị
</
Label
>
<
Input
type=
"number"
min=
{
1
}
value=
{
form
.
sortOrder
}
onChange=
{
(
event
)
=>
onChange
(
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
}
className=
{
fieldClassName
}
/>
</
div
>
)
:
null
}
<
div
className=
"space-y-3"
>
<
Label
className=
"text-gray-700"
>
Hình ảnh
</
Label
>
<
div
className=
"overflow-hidden rounded-3xl border border-dashed border-[#063e8e]/20 bg-[#eef4ff]/60"
>
<
div
className=
"relative aspect-[16/9]"
>
{
previewMedia
?
(
<
SafeNextImage
src=
{
previewMedia
.
url
}
alt=
{
previewMedia
.
alt
||
previewMedia
.
name
}
fill
className=
"object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center text-sm text-gray-500"
>
Chưa chọn hình ảnh
</
div
>
)
}
</
div
>
</
div
>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
onPickImage
}
className=
"rounded-xl border-[#063e8e]/15 text-gray-700 hover:bg-[#edf4ff]"
>
<
ImagePlus
className=
"mr-2 h-4 w-4"
/>
Chọn từ thư viện
</
Button
>
</
div
>
{
mode
===
"banner"
?
(
<
div
className=
"flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-[#f7faff] px-4 py-3"
>
<
div
>
<
div
className=
"text-sm font-medium text-[#163b73]"
>
Trạng thái hiển thị
</
div
>
<
div
className=
"text-xs text-gray-500"
>
{
form
.
isActive
?
"Đang bật hiển thị"
:
"Đang tắt hiển thị"
}
</
div
>
</
div
>
<
Switch
checked=
{
form
.
isActive
}
onCheckedChange=
{
(
value
)
=>
onChange
(
"isActive"
,
value
)
}
/>
</
div
>
)
:
null
}
</
div
>
</
div
>
<
DialogFooter
className=
"border-t border-[#063e8e]/10 px-6 py-4"
>
<
div
className=
"flex w-full justify-end gap-3"
>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
()
=>
onOpenChange
(
false
)
}
className=
"rounded-xl border-[#063e8e]/15 text-gray-700"
>
Hủy
</
Button
>
<
Button
type=
"button"
onClick=
{
onSubmit
}
disabled=
{
saving
}
className=
"rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<
Save
className=
"mr-2 h-4 w-4"
/>
{
saving
?
"Đang lưu..."
:
"Lưu cấu hình"
}
</
Button
>
</
div
>
</
DialogFooter
>
</
DialogContent
>
</
Dialog
>
);
}
function BranchCard(
{
branch
,
current
,
onSelect
,
onDelete
,
}
:
{
branch
:
BaseConfigBranchItem
;
current
:
boolean
;
onSelect
:
()
=>
void
;
onDelete
:
()
=>
void
;
}
)
{
return
(
<
div
className=
{
`rounded-3xl border p-4 transition-all ${
current
? "border-[#063e8e]/30 bg-[#eef5ff] shadow-[0_10px_24px_rgba(6,62,142,0.1)]"
: "border-[#063e8e]/10 bg-white"
}`
}
>
<
button
type=
"button"
onClick=
{
onSelect
}
className=
"w-full text-left"
>
<
div
className=
"text-sm font-semibold text-[#163b73]"
>
{
branch
.
branchName
||
"Chi nhánh mới"
}
</
div
>
<
div
className=
"mt-2 line-clamp-2 text-sm leading-6 text-slate-600"
>
{
branch
.
address
||
"Chưa cập nhật địa chỉ"
}
</
div
>
</
button
>
<
div
className=
"mt-4 flex items-center justify-between"
>
<
div
className=
"text-xs text-slate-500"
>
{
branch
.
hotline
||
"Chưa có hotline"
}
</
div
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
onClick=
{
onDelete
}
className=
"h-8 w-8 text-red-600 hover:bg-red-50"
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
div
>
);
}
export default function AdminBaseConfigPage()
{
const
[
config
,
setConfig
]
=
React
.
useState
<
BaseConfigData
|
null
>
(
null
);
const
[
mediaItems
,
setMediaItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
currentBannerIndex
,
setCurrentBannerIndex
]
=
React
.
useState
(
0
);
const
[
currentBranchIndex
,
setCurrentBranchIndex
]
=
React
.
useState
(
0
);
const
[
activeTab
,
setActiveTab
]
=
React
.
useState
(
"branding"
);
const
[
itemDialogOpen
,
setItemDialogOpen
]
=
React
.
useState
(
false
);
const
[
itemDialogMode
,
setItemDialogMode
]
=
React
.
useState
<
ConfigItemMode
>
(
"logo"
);
const
[
editingItemId
,
setEditingItemId
]
=
React
.
useState
<
string
|
null
>
(
null
);
const
[
itemForm
,
setItemForm
]
=
React
.
useState
<
ConfigItemForm
>
(
emptyItemForm
());
const
[
imagePickerOpen
,
setImagePickerOpen
]
=
React
.
useState
(
false
);
const
[
savingItem
,
setSavingItem
]
=
React
.
useState
(
false
);
const
[
savingContact
,
setSavingContact
]
=
React
.
useState
(
false
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
{
mode
:
ConfigItemMode
;
id
:
string
;
name
:
string
;
}
|
null
>
(
null
);
React
.
useEffect
(()
=>
{
setConfig
(
readBaseConfig
());
setMediaItems
(
readAdminMediaItems
());
},
[]);
const
mediaMap
=
React
.
useMemo
(()
=>
getMediaMap
(
mediaItems
),
[
mediaItems
]);
const
sortedBanners
=
React
.
useMemo
(
()
=>
(
config
?
sortBaseConfigBanners
(
config
.
banners
)
:
[]),
[
config
],
);
const
sortedSocials
=
React
.
useMemo
(
()
=>
(
config
?
sortBaseConfigSocials
(
config
.
socials
)
:
[]),
[
config
],
);
const
currentLogo
=
config
?.
logo
??
null
;
const
currentBanner
=
sortedBanners
[
currentBannerIndex
]
??
null
;
const
currentBranch
=
config
?.
branches
[
currentBranchIndex
]
??
null
;
const
currentLogoMedia
=
currentLogo
?
resolveMediaItem
(
mediaMap
,
currentLogo
.
imageId
)
:
null
;
const
currentBannerMedia
=
currentBanner
?
resolveMediaItem
(
mediaMap
,
currentBanner
.
imageId
)
:
null
;
const
previewMedia
=
resolveMediaItem
(
mediaMap
,
itemForm
.
imageId
);
const
saveConfig
=
React
.
useCallback
((
nextConfig
:
BaseConfigData
)
=>
{
setConfig
(
nextConfig
);
persistBaseConfig
(
nextConfig
);
},
[]);
const
openCreateDialog
=
(
mode
:
ConfigItemMode
)
=>
{
setItemDialogMode
(
mode
);
setEditingItemId
(
null
);
setItemForm
({
...
emptyItemForm
(),
sortOrder
:
mode
===
"banner"
?
(
config
?
config
.
banners
.
length
+
1
:
1
)
:
1
,
});
setItemDialogOpen
(
true
);
};
const
openEditDialog
=
(
mode
:
ConfigItemMode
,
item
:
BaseConfigLogoItem
|
BaseConfigBannerItem
)
=>
{
setItemDialogMode
(
mode
);
setEditingItemId
(
item
.
id
);
setItemForm
({
name
:
item
.
name
,
imageId
:
item
.
imageId
,
isActive
:
item
.
isActive
,
displayTimeSeconds
:
"displayTimeSeconds"
in
item
?
item
.
displayTimeSeconds
:
5
,
sortOrder
:
"sortOrder"
in
item
?
item
.
sortOrder
:
1
,
});
setItemDialogOpen
(
true
);
};
const
handleSubmitItem
=
()
=>
{
if
(
!
config
)
return
;
const
trimmedName
=
itemForm
.
name
.
trim
();
if
(
!
trimmedName
)
{
toast
.
error
(
"Vui lòng nhập tên hiển thị"
);
return
;
}
if
(
!
itemForm
.
imageId
)
{
toast
.
error
(
"Vui lòng chọn hình ảnh"
);
return
;
}
setSavingItem
(
true
);
const
nextConfig
=
cloneBaseConfigData
(
config
);
if
(
itemDialogMode
===
"logo"
)
{
nextConfig
.
logo
=
{
id
:
editingItemId
||
currentLogo
?.
id
||
createBaseConfigItemId
(
"logo"
),
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
isActive
:
true
,
};
}
else
{
if
(
editingItemId
)
{
nextConfig
.
banners
=
nextConfig
.
banners
.
map
((
item
)
=>
item
.
id
===
editingItemId
?
{
...
item
,
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
}
:
item
,
);
}
else
{
nextConfig
.
banners
.
push
({
id
:
createBaseConfigItemId
(
"banner"
),
name
:
trimmedName
,
imageId
:
itemForm
.
imageId
,
isActive
:
itemForm
.
isActive
,
displayTimeSeconds
:
itemForm
.
displayTimeSeconds
,
sortOrder
:
itemForm
.
sortOrder
,
});
setCurrentBannerIndex
(
Math
.
max
(
nextConfig
.
banners
.
length
-
1
,
0
));
}
}
saveConfig
(
nextConfig
);
setSavingItem
(
false
);
setItemDialogOpen
(
false
);
toast
.
success
(
itemDialogMode
===
"logo"
?
"Đã lưu cấu hình logo"
:
"Đã lưu cấu hình banner"
);
};
const
handleDeleteItem
=
()
=>
{
if
(
!
config
||
!
deleteTarget
)
return
;
const
nextConfig
=
cloneBaseConfigData
(
config
);
if
(
deleteTarget
.
mode
===
"logo"
)
{
nextConfig
.
logo
=
null
;
}
else
{
nextConfig
.
banners
=
nextConfig
.
banners
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setCurrentBannerIndex
((
previous
)
=>
Math
.
max
(
0
,
Math
.
min
(
previous
,
nextConfig
.
banners
.
length
-
1
)),
);
}
saveConfig
(
nextConfig
);
toast
.
success
(
"Đã xóa cấu hình"
);
setDeleteTarget
(
null
);
};
const
handleBranchChange
=
<
K
extends
keyof
BaseConfigBranchItem
>
(
key: K,
value: BaseConfigBranchItem[K],
) =
>
{
if
(
!
config
||
!
currentBranch
)
return
;
setConfig
((
previous
)
=>
previous
?
{
...
previous
,
branches
:
previous
.
branches
.
map
((
branch
,
index
)
=>
index
===
currentBranchIndex
?
{
...
branch
,
[
key
]:
value
}
:
branch
,
),
}
:
previous
,
);
}
;
const handleAddBranch = () =
>
{
if
(
!
config
)
return
;
const
nextBranch
:
BaseConfigBranchItem
=
{
...
EMPTY_BASE_CONFIG_BRANCH
,
id
:
createBaseConfigItemId
(
"branch"
),
branchName
:
`Chi nhánh ${config.branches.length + 1}`
,
};
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
branches
.
push
(
nextBranch
);
saveConfig
(
nextConfig
);
setCurrentBranchIndex
(
nextConfig
.
branches
.
length
-
1
);
toast
.
success
(
"Đã thêm chi nhánh mới"
);
}
;
const handleDeleteBranch = (branchId: string) =
>
{
if
(
!
config
)
return
;
const
nextConfig
=
cloneBaseConfigData
(
config
);
nextConfig
.
branches
=
nextConfig
.
branches
.
filter
((
branch
)
=>
branch
.
id
!==
branchId
);
saveConfig
(
nextConfig
);
setCurrentBranchIndex
((
previous
)
=>
Math
.
max
(
0
,
Math
.
min
(
previous
,
Math
.
max
(
nextConfig
.
branches
.
length
-
1
,
0
))),
);
toast
.
success
(
"Đã xóa chi nhánh"
);
}
;
const handleSaveBranches = () =
>
{
if
(
!
config
)
return
;
setSavingContact
(
true
);
persistBaseConfig
(
config
);
setSavingContact
(
false
);
toast
.
success
(
"Đã lưu danh sách chi nhánh liên hệ"
);
}
;
const handleWebsiteInfoChange = (key: "websiteName" | "websiteLink", value: string) =
>
{
setConfig
((
previous
)
=>
(
previous
?
{
...
previous
,
[
key
]:
value
}
:
previous
));
}
;
const handleSaveWebsiteInfo = () =
>
{
if
(
!
config
)
return
;
saveConfig
(
config
);
toast
.
success
(
"Đã lưu thông tin website"
);
}
;
const handleSocialChange =
<
K
extends
keyof
BaseConfigSocialItem
>
(
socialId: string,
key: K,
value: BaseConfigSocialItem[K],
) =
>
{
setConfig
((
previous
)
=>
previous
?
{
...
previous
,
socials
:
previous
.
socials
.
map
((
item
)
=>
item
.
id
===
socialId
?
{
...
item
,
[
key
]:
value
}
:
item
,
),
}
:
previous
,
);
}
;
const handleSaveSocials = () =
>
{
if
(
!
config
)
return
;
saveConfig
(
config
);
toast
.
success
(
"Đã lưu cấu hình mạng xã hội"
);
}
;
if (!config)
{
return
(
<
div
className=
"rounded-3xl border border-[#063e8e]/10 bg-white p-10 text-center text-gray-500"
>
Đang tải cấu hình chung...
</
div
>
);
}
return (
<
div
className=
"space-y-8"
>
<
Tabs
value=
{
activeTab
}
onValueChange=
{
setActiveTab
}
className=
"space-y-5"
>
<
TabsList
className=
"h-auto rounded-2xl bg-[#eaf2ff] p-1.5"
>
<
TabsTrigger
value=
"branding"
className=
"rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Nhận diện thương hiệu
</
TabsTrigger
>
<
TabsTrigger
value=
"banner"
className=
"rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Banner trang chủ
</
TabsTrigger
>
<
TabsTrigger
value=
"contact"
className=
"rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Thông tin liên hệ
</
TabsTrigger
>
<
TabsTrigger
value=
"social"
className=
"rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Mạng xã hội
</
TabsTrigger
>
</
TabsList
>
<
TabsContent
value=
"branding"
className=
"mt-0"
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-5"
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
div
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
Nhận diện thương hiệu
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Quản lý logo hiển thị trên website.
</
CardDescription
>
</
div
>
<
div
className=
"flex flex-wrap items-center gap-3"
>
{
currentLogo
?
(
<>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
()
=>
openEditDialog
(
"logo"
,
currentLogo
)
}
className=
"rounded-xl border-[#063e8e]/15 text-gray-700"
>
<
Edit
className=
"mr-2 h-4 w-4"
/>
Cập nhật logo
</
Button
>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
()
=>
setDeleteTarget
({
mode
:
"logo"
,
id
:
currentLogo
.
id
,
name
:
currentLogo
.
name
,
})
}
className=
"rounded-xl border-red-200 text-red-600 hover:bg-red-50"
>
<
Trash2
className=
"mr-2 h-4 w-4"
/>
Xóa
</
Button
>
</>
)
:
(
<
Button
type=
"button"
onClick=
{
()
=>
openCreateDialog
(
"logo"
)
}
className=
"rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<
Plus
className=
"mr-2 h-4 w-4"
/>
Thiết lập logo
</
Button
>
)
}
</
div
>
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-6"
>
<
div
className=
"grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_360px]"
>
<
div
className=
"rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#f8fbff] to-white p-5"
>
<
div
className=
"relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-[24px] border border-dashed border-[#063e8e]/18 bg-[#eef4ff]"
>
{
currentLogoMedia
?
(
<
div
className=
"relative h-[220px] w-[220px]"
>
<
SafeNextImage
src=
{
currentLogoMedia
.
url
}
alt=
{
currentLogoMedia
.
alt
||
currentLogoMedia
.
name
}
fill
className=
"object-contain"
/>
</
div
>
)
:
(
<
div
className=
"text-center text-gray-500"
>
<
ImagePlus
className=
"mx-auto mb-3 h-10 w-10 text-[#4b74b8]"
/>
Chưa có logo nào được cấu hình
</
div
>
)
}
</
div
>
</
div
>
<
div
className=
"space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
{
currentLogo
?
(
<
div
className=
"space-y-4 rounded-3xl border border-[#063e8e]/12 bg-white p-5 text-sm text-slate-600 shadow-sm"
>
<
div
>
<
div
className=
"text-xs font-semibold uppercase tracking-[0.14em] text-[#4b74b8]"
>
Logo website
</
div
>
<
div
className=
"mt-3 font-semibold text-[#163b73]"
>
{
currentLogo
.
name
}
</
div
>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Tên website
</
Label
>
<
Input
value=
{
config
.
websiteName
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteName"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Link website
</
Label
>
<
Input
value=
{
config
.
websiteLink
}
onChange=
{
(
event
)
=>
handleWebsiteInfoChange
(
"websiteLink"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"hidden rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Trạng thái
</
div
>
<
div
className=
"mt-2 flex items-center gap-2"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/20 text-[#063e8e]"
>
{
currentLogo
.
isActive
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
Badge
>
</
div
>
</
div
>
<
Button
type=
"button"
onClick=
{
handleSaveWebsiteInfo
}
className=
"w-full rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu thông tin website
</
Button
>
</
div
>
)
:
(
<
div
className=
"rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500"
>
Chưa có logo nào. Hãy thiết lập logo cho website.
</
div
>
)
}
</
div
>
</
div
>
</
CardContent
>
</
Card
>
</
TabsContent
>
<
TabsContent
value=
"banner"
className=
"mt-0"
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-5"
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
div
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
Banner trang chủ
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Quản lý hình ảnh slider chỉ dùng cho khu vực banner trang chủ của website.
</
CardDescription
>
</
div
>
<
div
className=
"flex flex-wrap items-center gap-3"
>
<
Button
type=
"button"
onClick=
{
()
=>
openCreateDialog
(
"banner"
)
}
className=
"rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<
Plus
className=
"mr-2 h-4 w-4"
/>
Thêm banner
</
Button
>
{
currentBanner
?
(
<>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
()
=>
openEditDialog
(
"banner"
,
currentBanner
)
}
className=
"rounded-xl border-[#063e8e]/15 text-gray-700"
>
<
Edit
className=
"mr-2 h-4 w-4"
/>
Sửa
</
Button
>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
()
=>
setDeleteTarget
({
mode
:
"banner"
,
id
:
currentBanner
.
id
,
name
:
currentBanner
.
name
,
})
}
className=
"rounded-xl border-red-200 text-red-600 hover:bg-red-50"
>
<
Trash2
className=
"mr-2 h-4 w-4"
/>
Xóa
</
Button
>
</>
)
:
null
}
</
div
>
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-6"
>
<
div
className=
"rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<
div
className=
"relative aspect-[16/6] overflow-hidden rounded-[24px] border border-[#063e8e]/12 bg-[#eef4ff]"
>
{
currentBannerMedia
?
(
<
SafeNextImage
src=
{
currentBannerMedia
.
url
}
alt=
{
currentBannerMedia
.
alt
||
currentBannerMedia
.
name
}
fill
className=
"object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center text-gray-500"
>
Chưa có banner được chọn
</
div
>
)
}
</
div
>
</
div
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]"
>
Danh sách banner trang chủ
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
Button
type=
"button"
variant=
"outline"
size=
"icon"
className=
"rounded-xl border-[#063e8e]/15"
onClick=
{
()
=>
setCurrentBannerIndex
((
previous
)
=>
previous
<=
0
?
Math
.
max
(
sortedBanners
.
length
-
1
,
0
)
:
previous
-
1
,
)
}
disabled=
{
sortedBanners
.
length
<=
1
}
>
<
ChevronLeft
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"outline"
size=
"icon"
className=
"rounded-xl border-[#063e8e]/15"
onClick=
{
()
=>
setCurrentBannerIndex
((
previous
)
=>
sortedBanners
.
length
===
0
?
0
:
(
previous
+
1
)
%
sortedBanners
.
length
,
)
}
disabled=
{
sortedBanners
.
length
<=
1
}
>
<
ChevronRight
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
{
sortedBanners
.
map
((
item
,
index
)
=>
(
<
ConfigItemPreview
key=
{
item
.
id
}
title=
{
`Banner ${index + 1}`
}
item=
{
item
}
media=
{
resolveMediaItem
(
mediaMap
,
item
.
imageId
)
}
current=
{
index
===
currentBannerIndex
}
onSelect=
{
()
=>
setCurrentBannerIndex
(
index
)
}
/>
))
}
</
div
>
{
currentBanner
?
(
<
div
className=
"grid gap-4 md:grid-cols-4"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Tên banner
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
name
}
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Thứ tự hiển thị
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
sortOrder
}
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Thời gian hiển thị
</
div
>
<
div
className=
"mt-2 font-semibold text-[#163b73]"
>
{
currentBanner
.
displayTimeSeconds
}
giây
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-gray-500"
>
Trạng thái
</
div
>
<
div
className=
"mt-2"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/20 text-[#063e8e]"
>
{
currentBanner
.
isActive
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
Badge
>
</
div
>
</
div
>
</
div
>
)
:
null
}
</
CardContent
>
</
Card
>
</
TabsContent
>
<
TabsContent
value=
"contact"
className=
"mt-0"
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
div
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
Thông tin liên hệ website
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Quản lý nhiều địa chỉ chi nhánh để hiển thị trên website.
</
CardDescription
>
</
div
>
<
div
className=
"flex flex-wrap gap-3"
>
<
Button
type=
"button"
onClick=
{
handleAddBranch
}
className=
"rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<
Plus
className=
"mr-2 h-4 w-4"
/>
Thêm chi nhánh
</
Button
>
<
Button
type=
"button"
onClick=
{
handleSaveBranches
}
disabled=
{
savingContact
}
className=
"rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<
Save
className=
"mr-2 h-4 w-4"
/>
{
savingContact
?
"Đang lưu..."
:
"Lưu danh sách chi nhánh"
}
</
Button
>
</
div
>
</
div
>
</
CardHeader
>
<
CardContent
className=
"grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)]"
>
<
div
className=
"space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<
div
className=
"text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]"
>
Danh sách chi nhánh
</
div
>
<
div
className=
"space-y-3"
>
{
config
.
branches
.
map
((
branch
,
index
)
=>
(
<
BranchCard
key=
{
branch
.
id
}
branch=
{
branch
}
current=
{
index
===
currentBranchIndex
}
onSelect=
{
()
=>
setCurrentBranchIndex
(
index
)
}
onDelete=
{
()
=>
handleDeleteBranch
(
branch
.
id
)
}
/>
))
}
</
div
>
</
div
>
<
div
className=
"space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
{
currentBranch
?
(
<>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Tên chi nhánh
</
Label
>
<
Input
value=
{
currentBranch
.
branchName
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"branchName"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Địa chỉ
</
Label
>
<
Textarea
value=
{
currentBranch
.
address
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"address"
,
event
.
target
.
value
)
}
className=
{
`${fieldClassName} min-h-[110px]`
}
/>
</
div
>
<
div
className=
"grid gap-5 md:grid-cols-2"
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Hotline
</
Label
>
<
Input
value=
{
currentBranch
.
hotline
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"hotline"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Email
</
Label
>
<
Input
value=
{
currentBranch
.
email
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"email"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
</
div
>
<
div
className=
"grid gap-5 md:grid-cols-2"
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Fax
</
Label
>
<
Input
value=
{
currentBranch
.
fax
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"fax"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Google Maps
</
Label
>
<
Input
value=
{
currentBranch
.
mapsEmbedUrl
}
onChange=
{
(
event
)
=>
handleBranchChange
(
"mapsEmbedUrl"
,
event
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
</
div
>
</>
)
:
(
<
div
className=
"rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500"
>
Chưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu hình.
</
div
>
)
}
</
div
>
<
div
className=
"hidden rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#063e8e] to-[#0f4a9f] p-6 text-white shadow-[0_16px_30px_rgba(6,62,142,0.18)]"
>
<
div
className=
"text-xs font-semibold uppercase tracking-[0.18em] text-white/75"
>
Preview chi nhánh
</
div
>
{
currentBranch
?
(
<>
<
div
className=
"mt-4 text-2xl font-semibold"
>
{
currentBranch
.
branchName
}
</
div
>
<
div
className=
"mt-6 space-y-4 text-sm leading-6"
>
<
div
className=
"flex gap-3"
>
<
MapPin
className=
"mt-1 h-4 w-4 shrink-0 text-white/80"
/>
<
span
>
{
currentBranch
.
address
||
"Chưa cập nhật địa chỉ"
}
</
span
>
</
div
>
<
div
className=
"flex gap-3"
>
<
Phone
className=
"mt-1 h-4 w-4 shrink-0 text-white/80"
/>
<
span
>
{
currentBranch
.
hotline
||
"Chưa cập nhật hotline"
}
</
span
>
</
div
>
<
div
className=
"flex gap-3"
>
<
Mail
className=
"mt-1 h-4 w-4 shrink-0 text-white/80"
/>
<
span
>
{
currentBranch
.
email
||
"Chưa cập nhật email"
}
</
span
>
</
div
>
<
div
className=
"flex gap-3"
>
<
Globe
className=
"mt-1 h-4 w-4 shrink-0 text-white/80"
/>
<
span
>
{
currentBranch
.
fax
||
"Chưa cập nhật fax"
}
</
span
>
</
div
>
</
div
>
</>
)
:
(
<
div
className=
"mt-6 text-sm text-white/80"
>
Không có dữ liệu chi nhánh để preview.
</
div
>
)
}
</
div
>
</
CardContent
>
</
Card
>
</
TabsContent
>
<
TabsContent
value=
"social"
className=
"mt-0"
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
>
<
div
className=
"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
>
<
div
>
<
CardTitle
className=
"text-2xl text-[#163b73]"
>
Mạng xã hội
</
CardTitle
>
<
CardDescription
className=
"mt-2 text-sm text-slate-600"
>
Quản lý link mạng xã hội và thứ tự hiển thị trên website.
</
CardDescription
>
</
div
>
<
Button
type=
"button"
onClick=
{
handleSaveSocials
}
className=
"rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu mạng xã hội
</
Button
>
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-4"
>
{
sortedSocials
.
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<
div
className=
"grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end"
>
<
div
className=
"flex items-center gap-3 rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4"
>
<
Checkbox
checked=
{
item
.
isVisible
}
onCheckedChange=
{
(
checked
)
=>
handleSocialChange
(
item
.
id
,
"isVisible"
,
checked
===
true
)
}
/>
<
div
>
<
div
className=
"font-semibold text-[#163b73]"
>
{
item
.
label
}
</
div
>
<
div
className=
"text-sm text-slate-500"
>
{
item
.
isVisible
?
"Đang hiển thị"
:
"Đang ẩn"
}
</
div
>
</
div
>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Link URL
</
Label
>
<
Input
value=
{
item
.
url
}
onChange=
{
(
event
)
=>
handleSocialChange
(
item
.
id
,
"url"
,
event
.
target
.
value
)
}
placeholder=
{
`Nhập link ${item.label}
...
`
}
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-gray-700"
>
Thứ tự hiển thị
</
Label
>
<
Input
type=
"number"
min=
{
1
}
value=
{
item
.
sortOrder
}
onChange=
{
(
event
)
=>
handleSocialChange
(
item
.
id
,
"sortOrder"
,
Number
(
event
.
target
.
value
||
1
))
}
className=
{
fieldClassName
}
/>
</
div
>
</
div
>
</
div
>
))
}
</
CardContent
>
</
Card
>
</
TabsContent
>
</
Tabs
>
<
ConfigItemDialog
open=
{
itemDialogOpen
}
mode=
{
itemDialogMode
}
form=
{
itemForm
}
previewMedia=
{
previewMedia
}
saving=
{
savingItem
}
title=
{
editingItemId
?
itemDialogMode
===
"logo"
?
"Cập nhật logo"
:
"Chỉnh sửa banner"
:
itemDialogMode
===
"logo"
?
"Thiết lập logo"
:
"Thêm banner mới"
}
description=
{
itemDialogMode
===
"logo"
?
"Thiết lập logo hiển thị cho website."
:
"Thiết lập banner hiển thị cho trang chủ."
}
onOpenChange=
{
setItemDialogOpen
}
onChange=
{
(
key
,
value
)
=>
setItemForm
((
previous
)
=>
({
...
previous
,
[
key
]:
value
}))
}
onPickImage=
{
()
=>
setImagePickerOpen
(
true
)
}
onSubmit=
{
handleSubmitItem
}
/>
<
AdminImagePicker
open=
{
imagePickerOpen
}
selectedId=
{
itemForm
.
imageId
}
onOpenChange=
{
setImagePickerOpen
}
onSelect=
{
(
item
)
=>
setItemForm
((
previous
)
=>
({
...
previous
,
imageId
:
item
.
id
}))
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa cấu hình"
description=
{
<>
Bạn có chắc muốn xóa
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành
động này không thể hoàn tác.
</>
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDeleteItem
}
/>
</
div
>
);
}
src/app/admin/contact-management/contact-requests/page.tsx
0 → 100644
View file @
051d8a8b
"use client"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
{
Eye
,
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Select
,
SelectContent
,
SelectItem
,
SelectTrigger
,
SelectValue
,
}
from
"@/components/ui/select"
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
CONTACT_PURPOSE_OPTIONS
,
type
ContactRequestItem
,
persistContactRequests
,
readContactRequests
,
}
from
"@/mockdata/contact-management"
;
const
selectTriggerClassName
=
"w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[220px]"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
const
selectItemClassName
=
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"
;
function
formatDateTime
(
value
:
string
)
{
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
}
export
default
function
AdminContactRequestsPage
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
ContactRequestItem
[]
>
([]);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
purposeFilter
,
setPurposeFilter
]
=
React
.
useState
(
"all"
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
detailTarget
,
setDetailTarget
]
=
React
.
useState
<
ContactRequestItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
ContactRequestItem
|
null
>
(
null
);
React
.
useEffect
(()
=>
{
setItems
(
readContactRequests
());
setReady
(
true
);
},
[]);
const
filteredItems
=
React
.
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
return
items
.
filter
((
item
)
=>
{
const
matchesKeyword
=
!
keyword
||
item
.
id
.
toLowerCase
().
includes
(
keyword
)
||
item
.
contactName
.
toLowerCase
().
includes
(
keyword
)
||
item
.
contactEmail
.
toLowerCase
().
includes
(
keyword
)
||
item
.
contactPhone
.
toLowerCase
().
includes
(
keyword
)
||
item
.
organizationName
.
toLowerCase
().
includes
(
keyword
)
||
item
.
email
.
toLowerCase
().
includes
(
keyword
)
||
item
.
message
.
toLowerCase
().
includes
(
keyword
);
const
matchesPurpose
=
purposeFilter
===
"all"
||
item
.
purpose
===
purposeFilter
;
return
matchesKeyword
&&
matchesPurpose
;
});
},
[
items
,
purposeFilter
,
search
]);
const
handleDelete
=
()
=>
{
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
nextItems
);
persistContactRequests
(
nextItems
);
toast
.
success
(
"Đã xóa đơn liên hệ"
);
setDeleteTarget
(
null
);
};
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm đơn liên hệ..."
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
Tổng bản ghi:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
filteredItems
.
length
}
</
span
>
</
div
>
}
filters=
{
<
Select
value=
{
purposeFilter
}
onValueChange=
{
setPurposeFilter
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectValue
placeholder=
"Mục đích liên hệ"
/>
</
SelectTrigger
>
<
SelectContent
className=
{
selectContentClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
Tất cả mục đích
</
SelectItem
>
{
CONTACT_PURPOSE_OPTIONS
.
map
((
option
)
=>
(
<
SelectItem
key=
{
option
}
value=
{
option
}
className=
{
selectItemClassName
}
>
{
option
}
</
SelectItem
>
))
}
</
SelectContent
>
</
Select
>
}
onSearchChange=
{
setSearch
}
>
<
div
className=
"scrollbar overflow-x-auto"
>
<
Table
className=
"min-w-[1100px]"
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-16 py-4 text-center text-white"
>
STT
</
TableHead
>
<
TableHead
className=
"w-[220px] py-4 text-center text-white"
>
Mục đích liên hệ
</
TableHead
>
<
TableHead
className=
"py-4 text-center text-white"
>
Người liên hệ
</
TableHead
>
<
TableHead
className=
"py-4 text-center text-white"
>
Tên công ty / tổ chức
</
TableHead
>
<
TableHead
className=
"w-[220px] py-4 text-center text-white"
>
Email
</
TableHead
>
<
TableHead
className=
"w-[170px] py-4 text-center text-white"
>
Ngày gửi
</
TableHead
>
<
TableHead
className=
"w-[130px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
Array
.
from
({
length
:
4
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
<
div
className=
"h-10 animate-pulse rounded-xl bg-[#063e8e]/10"
/>
</
TableCell
>
</
TableRow
>
))
)
:
filteredItems
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
7
}
className=
"py-16 text-center text-gray-400"
>
Không có đơn liên hệ nào
</
TableCell
>
</
TableRow
>
)
:
(
filteredItems
.
map
((
item
,
index
)
=>
(
<
TableRow
key=
{
item
.
id
}
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/3"
}
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-500"
>
{
index
+
1
}
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
item
.
purpose
}
</
Badge
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-semibold"
>
{
item
.
contactName
}
</
div
>
<
div
className=
"text-gray-600"
>
{
item
.
contactPhone
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-medium text-gray-800"
>
{
item
.
organizationName
}
</
div
>
<
div
>
{
item
.
businessField
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
div
className=
"space-y-1"
>
<
div
>
{
item
.
email
}
</
div
>
<
div
className=
"text-gray-500"
>
{
item
.
contactEmail
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
formatDateTime
(
item
.
submittedAt
)
}
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick=
{
()
=>
setDetailTarget
(
item
)
}
>
<
Eye
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableRow
>
))
)
}
</
TableBody
>
</
Table
>
</
div
>
</
AdminTableLayout
>
<
ContactManagementDetailDialog
open=
{
!!
detailTarget
}
title=
"Chi tiết đơn liên hệ"
description=
"Thông tin đầy đủ của đơn liên hệ được gửi từ website VCCI News."
badge=
{
detailTarget
?
(
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
detailTarget
.
purpose
}
</
Badge
>
)
:
null
}
sections=
{
detailTarget
?
[
{
title
:
"Thông tin chung"
,
fields
:
[
{
label
:
"Mục đích liên hệ"
,
value
:
detailTarget
.
purpose
},
{
label
:
"Ngày gửi"
,
value
:
formatDateTime
(
detailTarget
.
submittedAt
)
},
],
},
{
title
:
"Người liên hệ"
,
fields
:
[
{
label
:
"Họ tên người liên hệ"
,
value
:
detailTarget
.
contactName
},
{
label
:
"Chức vụ"
,
value
:
detailTarget
.
contactPosition
},
{
label
:
"Email người liên hệ"
,
value
:
detailTarget
.
contactEmail
},
{
label
:
"Điện thoại người liên hệ"
,
value
:
detailTarget
.
contactPhone
},
{
label
:
"Nội dung liên hệ"
,
value
:
detailTarget
.
message
,
fullWidth
:
true
},
],
},
{
title
:
"Thông tin công ty / tổ chức"
,
fields
:
[
{
label
:
"Tên công ty / tổ chức"
,
value
:
detailTarget
.
organizationName
},
{
label
:
"Lĩnh vực hoạt động"
,
value
:
detailTarget
.
businessField
},
{
label
:
"Email"
,
value
:
detailTarget
.
email
},
{
label
:
"Website"
,
value
:
detailTarget
.
website
},
],
},
]
:
[]
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDetailTarget
(
null
)
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa đơn liên hệ"
description=
{
<>
Bạn có chắc muốn xóa đơn liên hệ của
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
contactName
}
</
span
>
? Hành động này không thể
hoàn tác.
</>
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
/>
</
div
>
);
}
src/app/admin/contact-management/membership-applications/page.tsx
0 → 100644
View file @
051d8a8b
"use client"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
{
Eye
,
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
MembershipApplicationItem
,
persistMembershipApplications
,
readMembershipApplications
,
}
from
"@/mockdata/contact-management"
;
function
formatDateTime
(
value
:
string
)
{
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
}
export
default
function
AdminMembershipApplicationsPage
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
MembershipApplicationItem
[]
>
([]);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
detailTarget
,
setDetailTarget
]
=
React
.
useState
<
MembershipApplicationItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
MembershipApplicationItem
|
null
>
(
null
);
React
.
useEffect
(()
=>
{
setItems
(
readMembershipApplications
());
setReady
(
true
);
},
[]);
const
filteredItems
=
React
.
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
if
(
!
keyword
)
return
items
;
return
items
.
filter
(
(
item
)
=>
item
.
id
.
toLowerCase
().
includes
(
keyword
)
||
item
.
organizationName
.
toLowerCase
().
includes
(
keyword
)
||
item
.
contactName
.
toLowerCase
().
includes
(
keyword
)
||
item
.
contactEmail
.
toLowerCase
().
includes
(
keyword
)
||
item
.
businessField
.
toLowerCase
().
includes
(
keyword
)
||
item
.
membershipType
.
toLowerCase
().
includes
(
keyword
),
);
},
[
items
,
search
]);
const
handleDelete
=
()
=>
{
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
nextItems
);
persistMembershipApplications
(
nextItems
);
toast
.
success
(
"Đã xóa đơn đăng ký hội viên"
);
setDeleteTarget
(
null
);
};
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm đơn đăng ký hội viên..."
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
Tổng bản ghi:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
filteredItems
.
length
}
</
span
>
</
div
>
}
onSearchChange=
{
setSearch
}
>
<
div
className=
"scrollbar overflow-x-auto"
>
<
Table
className=
"min-w-[1080px]"
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-16 py-4 text-center text-white"
>
STT
</
TableHead
>
<
TableHead
className=
"py-4 text-center text-white"
>
Tên công ty / tổ chức
</
TableHead
>
<
TableHead
className=
"w-[210px] py-4 text-center text-white"
>
Người liên hệ
</
TableHead
>
<
TableHead
className=
"w-[180px] py-4 text-center text-white"
>
Loại hội viên
</
TableHead
>
<
TableHead
className=
"w-[220px] py-4 text-center text-white"
>
Email
</
TableHead
>
<
TableHead
className=
"w-[170px] py-4 text-center text-white"
>
Ngày gửi
</
TableHead
>
<
TableHead
className=
"w-[130px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
<
div
className=
"h-10 animate-pulse rounded-xl bg-[#063e8e]/10"
/>
</
TableCell
>
</
TableRow
>
))
)
:
filteredItems
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
7
}
className=
"py-16 text-center text-gray-400"
>
Không có đơn đăng ký hội viên nào
</
TableCell
>
</
TableRow
>
)
:
(
filteredItems
.
map
((
item
,
index
)
=>
(
<
TableRow
key=
{
item
.
id
}
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/3"
}
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-500"
>
{
index
+
1
}
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-semibold"
>
{
item
.
organizationName
}
</
div
>
<
div
className=
"text-gray-600"
>
{
item
.
businessField
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-medium text-gray-800"
>
{
item
.
contactName
}
</
div
>
<
div
>
{
item
.
contactPhone
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
item
.
membershipType
}
</
Badge
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
{
item
.
contactEmail
}
</
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
formatDateTime
(
item
.
submittedAt
)
}
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick=
{
()
=>
setDetailTarget
(
item
)
}
>
<
Eye
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableRow
>
))
)
}
</
TableBody
>
</
Table
>
</
div
>
</
AdminTableLayout
>
<
ContactManagementDetailDialog
open=
{
!!
detailTarget
}
title=
"Chi tiết đơn đăng ký hội viên"
description=
"Biểu mẫu mẫu phục vụ duyệt giao diện quản trị đơn đăng ký hội viên."
badge=
{
detailTarget
?
(
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
detailTarget
.
membershipType
}
</
Badge
>
)
:
null
}
sections=
{
detailTarget
?
[
{
title
:
"Thông tin chung"
,
fields
:
[
{
label
:
"Loại hội viên"
,
value
:
detailTarget
.
membershipType
},
{
label
:
"Ngày gửi"
,
value
:
formatDateTime
(
detailTarget
.
submittedAt
)
},
],
},
{
title
:
"Thông tin doanh nghiệp"
,
fields
:
[
{
label
:
"Tên công ty / tổ chức"
,
value
:
detailTarget
.
organizationName
},
{
label
:
"Lĩnh vực hoạt động"
,
value
:
detailTarget
.
businessField
},
{
label
:
"Địa chỉ"
,
value
:
detailTarget
.
address
,
fullWidth
:
true
},
{
label
:
"Website"
,
value
:
detailTarget
.
website
},
],
},
{
title
:
"Thông tin người liên hệ"
,
fields
:
[
{
label
:
"Họ tên người liên hệ"
,
value
:
detailTarget
.
contactName
},
{
label
:
"Chức vụ"
,
value
:
detailTarget
.
contactPosition
},
{
label
:
"Email người liên hệ"
,
value
:
detailTarget
.
contactEmail
},
{
label
:
"Điện thoại người liên hệ"
,
value
:
detailTarget
.
contactPhone
},
{
label
:
"Ghi chú"
,
value
:
detailTarget
.
note
,
fullWidth
:
true
},
],
},
]
:
[]
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDetailTarget
(
null
)
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa đơn đăng ký hội viên"
description=
{
<>
Bạn có chắc muốn xóa đơn đăng ký của
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
organizationName
}
</
span
>
? Hành động này không
thể hoàn tác.
</>
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
/>
</
div
>
);
}
src/app/admin/contact-management/newsletter-emails/page.tsx
0 → 100644
View file @
051d8a8b
"use client"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
{
Eye
,
Mail
,
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
ContactManagementDetailDialog
}
from
"@/components/admin/contact-management-detail-dialog"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Table
,
TableBody
,
TableCell
,
TableHead
,
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
NewsletterSubscriptionItem
,
persistNewsletterSubscriptions
,
readNewsletterSubscriptions
,
}
from
"@/mockdata/contact-management"
;
function
formatDateTime
(
value
:
string
)
{
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
}
export
default
function
AdminNewsletterEmailsPage
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
NewsletterSubscriptionItem
[]
>
([]);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
detailTarget
,
setDetailTarget
]
=
React
.
useState
<
NewsletterSubscriptionItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
NewsletterSubscriptionItem
|
null
>
(
null
);
React
.
useEffect
(()
=>
{
setItems
(
readNewsletterSubscriptions
());
setReady
(
true
);
},
[]);
const
filteredItems
=
React
.
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
if
(
!
keyword
)
return
items
;
return
items
.
filter
(
(
item
)
=>
item
.
email
.
toLowerCase
().
includes
(
keyword
)
||
item
.
id
.
toLowerCase
().
includes
(
keyword
)
||
formatDateTime
(
item
.
submittedAt
).
toLowerCase
().
includes
(
keyword
),
);
},
[
items
,
search
]);
const
handleDelete
=
()
=>
{
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
nextItems
);
persistNewsletterSubscriptions
(
nextItems
);
toast
.
success
(
"Đã xóa email đăng ký nhận thông tin"
);
setDeleteTarget
(
null
);
};
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm email đăng ký..."
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
Tổng bản ghi:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
filteredItems
.
length
}
</
span
>
</
div
>
}
onSearchChange=
{
setSearch
}
>
<
Table
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-16 py-4 text-center text-white"
>
STT
</
TableHead
>
<
TableHead
className=
"py-4 text-center text-white"
>
Email
</
TableHead
>
<
TableHead
className=
"w-[190px] py-4 text-center text-white"
>
Ngày gửi
</
TableHead
>
<
TableHead
className=
"w-[130px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
Array
.
from
({
length
:
4
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
4
}
className=
"px-4 py-4"
>
<
div
className=
"h-10 animate-pulse rounded-xl bg-[#063e8e]/10"
/>
</
TableCell
>
</
TableRow
>
))
)
:
filteredItems
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
4
}
className=
"py-16 text-center text-gray-400"
>
Không có email đăng ký nào
</
TableCell
>
</
TableRow
>
)
:
(
filteredItems
.
map
((
item
,
index
)
=>
(
<
TableRow
key=
{
item
.
id
}
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/3"
}
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-500"
>
{
index
+
1
}
</
TableCell
>
<
TableCell
className=
"py-3 text-sm font-medium text-gray-800"
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#063e8e]/10 text-[#063e8e]"
>
<
Mail
className=
"h-4 w-4"
/>
</
div
>
<
span
>
{
item
.
email
}
</
span
>
</
div
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
formatDateTime
(
item
.
submittedAt
)
}
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex items-center justify-center gap-1"
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick=
{
()
=>
setDetailTarget
(
item
)
}
>
<
Eye
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
variant=
"ghost"
size=
"icon"
className=
"h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
TableCell
>
</
TableRow
>
))
)
}
</
TableBody
>
</
Table
>
</
AdminTableLayout
>
<
ContactManagementDetailDialog
open=
{
!!
detailTarget
}
title=
"Chi tiết email đăng ký nhận thông tin"
description=
"Thông tin chi tiết của bản ghi email đăng ký nhận tin từ biểu mẫu website."
badge=
{
null
}
sections=
{
detailTarget
?
[
{
title
:
"Thông tin đăng ký"
,
fields
:
[
{
label
:
"Email"
,
value
:
detailTarget
.
email
},
{
label
:
"Ngày gửi"
,
value
:
formatDateTime
(
detailTarget
.
submittedAt
)
},
],
},
]
:
[]
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDetailTarget
(
null
)
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa email đăng ký"
description=
{
<>
Bạn có chắc muốn xóa email
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
email
}
</
span
>
?
Hành động này không thể hoàn tác.
</>
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
/>
</
div
>
);
}
src/app/admin/contact-management/page.tsx
0 → 100644
View file @
051d8a8b
import
{
redirect
}
from
"next/navigation"
;
export
default
function
ContactManagementPage
()
{
redirect
(
"/admin/contact-management/newsletter-emails"
);
}
src/app/admin/dashboard/page.tsx
0 → 100644
View file @
051d8a8b
"use client"
;
import
*
as
React
from
"react"
;
import
Link
from
"next/link"
;
import
dayjs
from
"dayjs"
;
import
{
ArrowRight
,
FolderTree
,
Globe
,
Image
as
ImageIcon
,
LayoutTemplate
,
Mail
,
MapPin
,
MonitorPlay
,
Newspaper
,
Sparkles
,
Users
,
}
from
"lucide-react"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Card
,
CardContent
,
CardDescription
,
CardHeader
,
CardTitle
}
from
"@/components/ui/card"
;
import
{
type
AdminMediaItem
,
type
AdminNewsItem
,
readAdminMediaItems
,
readAdminNewsItems
,
}
from
"@/mockdata/admin-news"
;
import
{
type
BaseConfigData
,
readBaseConfig
}
from
"@/mockdata/base-config"
;
import
{
type
ContactRequestItem
,
type
MembershipApplicationItem
,
type
NewsletterSubscriptionItem
,
readContactRequests
,
readMembershipApplications
,
readNewsletterSubscriptions
,
}
from
"@/mockdata/contact-management"
;
import
{
type
HeaderCategoryPostItem
,
getHeaderCategoryPostSeed
,
}
from
"@/mockdata/header-category-posts"
;
import
{
type
HeaderCategoryItem
,
getHeaderCategorySeed
}
from
"@/mockdata/header-config"
;
import
{
type
MemberField
,
type
MemberItem
,
type
MemberRegion
,
readMemberFields
,
readMemberRegions
,
readMembers
,
}
from
"@/mockdata/members"
;
import
{
type
VideoItem
,
readVideos
}
from
"@/mockdata/videos"
;
function
formatDateTime
(
value
:
string
)
{
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
}
type
DashboardMetric
=
{
title
:
string
;
value
:
string
;
description
:
string
;
icon
:
React
.
ComponentType
<
{
className
?:
string
}
>
;
href
:
string
;
};
type
DashboardShortcut
=
{
title
:
string
;
description
:
string
;
href
:
string
;
icon
:
React
.
ComponentType
<
{
className
?:
string
}
>
;
};
type
ActivityItem
=
{
id
:
string
;
title
:
string
;
description
:
string
;
time
:
string
;
badge
:
string
;
href
:
string
;
};
export
default
function
AdminDashboardPage
()
{
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
newsItems
,
setNewsItems
]
=
React
.
useState
<
AdminNewsItem
[]
>
([]);
const
[
mediaItems
,
setMediaItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
videos
,
setVideos
]
=
React
.
useState
<
VideoItem
[]
>
([]);
const
[
members
,
setMembers
]
=
React
.
useState
<
MemberItem
[]
>
([]);
const
[
memberFields
,
setMemberFields
]
=
React
.
useState
<
MemberField
[]
>
([]);
const
[
memberRegions
,
setMemberRegions
]
=
React
.
useState
<
MemberRegion
[]
>
([]);
const
[
newsletterItems
,
setNewsletterItems
]
=
React
.
useState
<
NewsletterSubscriptionItem
[]
>
([]);
const
[
contactRequests
,
setContactRequests
]
=
React
.
useState
<
ContactRequestItem
[]
>
([]);
const
[
membershipApplications
,
setMembershipApplications
]
=
React
.
useState
<
MembershipApplicationItem
[]
>
([]);
const
[
baseConfig
,
setBaseConfig
]
=
React
.
useState
<
BaseConfigData
>
(()
=>
readBaseConfig
());
React
.
useEffect
(()
=>
{
setNewsItems
(
readAdminNewsItems
());
setMediaItems
(
readAdminMediaItems
());
setVideos
(
readVideos
());
setMembers
(
readMembers
());
setMemberFields
(
readMemberFields
());
setMemberRegions
(
readMemberRegions
());
setNewsletterItems
(
readNewsletterSubscriptions
());
setContactRequests
(
readContactRequests
());
setMembershipApplications
(
readMembershipApplications
());
setBaseConfig
(
readBaseConfig
());
setReady
(
true
);
},
[]);
const
headerCategories
=
React
.
useMemo
<
HeaderCategoryItem
[]
>
(()
=>
getHeaderCategorySeed
(),
[]);
const
headerPosts
=
React
.
useMemo
<
HeaderCategoryPostItem
[]
>
(()
=>
getHeaderCategoryPostSeed
(),
[]);
const
metrics
=
React
.
useMemo
<
DashboardMetric
[]
>
(()
=>
{
const
totalContactForms
=
newsletterItems
.
length
+
contactRequests
.
length
+
membershipApplications
.
length
;
return
[
{
title
:
"Bài viết nội dung"
,
value
:
String
(
newsItems
.
length
+
headerPosts
.
length
),
description
:
`
${
newsItems
.
length
}
bài admin,
${
headerPosts
.
length
}
bài danh mục`
,
icon
:
Newspaper
,
href
:
"/admin/news"
,
},
{
title
:
"Tài nguyên media"
,
value
:
String
(
mediaItems
.
length
+
videos
.
length
),
description
:
`
${
mediaItems
.
length
}
ảnh,
${
videos
.
length
}
video`
,
icon
:
ImageIcon
,
href
:
"/admin/media"
,
},
{
title
:
"Hội viên & biểu mẫu"
,
value
:
String
(
members
.
length
+
membershipApplications
.
length
),
description
:
`
${
members
.
length
}
hội viên,
${
membershipApplications
.
length
}
đơn chờ xử lý`
,
icon
:
Users
,
href
:
"/admin/members"
,
},
{
title
:
"Liên hệ từ website"
,
value
:
String
(
totalContactForms
),
description
:
`
${
newsletterItems
.
length
}
email nhận tin,
${
contactRequests
.
length
}
đơn liên hệ`
,
icon
:
Mail
,
href
:
"/admin/contact-management/newsletter-emails"
,
},
];
},
[
contactRequests
.
length
,
headerPosts
.
length
,
mediaItems
.
length
,
members
.
length
,
membershipApplications
.
length
,
newsletterItems
.
length
,
newsItems
.
length
,
videos
.
length
,
]);
const
shortcuts
=
React
.
useMemo
<
DashboardShortcut
[]
>
(
()
=>
[
{
title
:
"Cấu hình chung"
,
description
:
"Logo, banner, chi nhánh liên hệ và mạng xã hội"
,
href
:
"/admin/base-config"
,
icon
:
Globe
,
},
{
title
:
"Cấu hình danh mục"
,
description
:
"Menu header và bài viết theo danh mục"
,
href
:
"/admin/header-config"
,
icon
:
FolderTree
,
},
{
title
:
"Quản lý bài viết"
,
description
:
"Tin tức, bài viết trang và nội dung xuất bản"
,
href
:
"/admin/news"
,
icon
:
LayoutTemplate
,
},
{
title
:
"Quản lý liên hệ"
,
description
:
"Email nhận tin, đơn liên hệ và đăng ký hội viên"
,
href
:
"/admin/contact-management/newsletter-emails"
,
icon
:
Mail
,
},
],
[],
);
const
recentActivities
=
React
.
useMemo
<
ActivityItem
[]
>
(()
=>
{
const
items
:
ActivityItem
[]
=
[
...
newsItems
.
map
((
item
)
=>
({
id
:
`news-
${
item
.
id
}
`
,
title
:
item
.
title
,
description
:
"Cập nhật trong Quản lý bài viết"
,
time
:
item
.
updated_at
||
item
.
created_at
,
badge
:
"Bài viết"
,
href
:
"/admin/news"
,
})),
...
mediaItems
.
map
((
item
)
=>
({
id
:
`media-
${
item
.
id
}
`
,
title
:
item
.
name
,
description
:
"Cập nhật trong kho ảnh website"
,
time
:
item
.
updated_at
,
badge
:
"Ảnh"
,
href
:
"/admin/media"
,
})),
...
membershipApplications
.
map
((
item
)
=>
({
id
:
`member-app-
${
item
.
id
}
`
,
title
:
item
.
organizationName
,
description
:
"Đơn đăng ký hội viên mới"
,
time
:
item
.
submittedAt
,
badge
:
"Đơn hội viên"
,
href
:
"/admin/contact-management/membership-applications"
,
})),
...
contactRequests
.
map
((
item
)
=>
({
id
:
`contact-
${
item
.
id
}
`
,
title
:
item
.
contactName
,
description
:
item
.
purpose
,
time
:
item
.
submittedAt
,
badge
:
"Liên hệ"
,
href
:
"/admin/contact-management/contact-requests"
,
})),
];
return
items
.
sort
((
left
,
right
)
=>
dayjs
(
right
.
time
).
valueOf
()
-
dayjs
(
left
.
time
).
valueOf
())
.
slice
(
0
,
6
);
},
[
contactRequests
,
mediaItems
,
membershipApplications
,
newsItems
]);
const
spotlightNews
=
React
.
useMemo
(()
=>
newsItems
.
slice
(
0
,
3
),
[
newsItems
]);
const
visibleSocials
=
React
.
useMemo
(
()
=>
baseConfig
.
socials
.
filter
((
item
)
=>
item
.
isVisible
).
sort
((
a
,
b
)
=>
a
.
sortOrder
-
b
.
sortOrder
),
[
baseConfig
.
socials
],
);
const
activeBanners
=
React
.
useMemo
(
()
=>
baseConfig
.
banners
.
filter
((
item
)
=>
item
.
isActive
).
sort
((
a
,
b
)
=>
a
.
sortOrder
-
b
.
sortOrder
),
[
baseConfig
.
banners
],
);
if
(
!
ready
)
{
return
(
<
div
className=
"grid gap-5 lg:grid-cols-4"
>
{
Array
.
from
({
length
:
8
}).
map
((
_
,
index
)
=>
(
<
div
key=
{
`dashboard-loading-${index}`
}
className=
"h-40 animate-pulse rounded-[28px] border border-[#063e8e]/10 bg-white"
/>
))
}
</
div
>
);
}
return
(
<
div
className=
"space-y-6"
>
<
section
className=
"grid gap-5 xl:grid-cols-[1.35fr_0.65fr]"
>
<
Card
className=
"overflow-hidden rounded-[30px] border-[#063e8e]/10 bg-[linear-gradient(135deg,#ffffff_0%,#f5f9ff_55%,#ebf3ff_100%)] shadow-[0_18px_55px_rgba(6,62,142,0.08)]"
>
<
CardContent
className=
"p-6 sm:p-7"
>
<
div
className=
"flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"
>
<
div
className=
"space-y-3"
>
<
div
className=
"inline-flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-white/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-[#063e8e]"
>
<
Sparkles
className=
"h-3.5 w-3.5"
/>
Tổng quan hệ thống
</
div
>
<
div
>
<
h2
className=
"text-3xl font-semibold text-[#163b73]"
>
Dashboard quản trị VCCI News
</
h2
>
<
p
className=
"mt-2 max-w-2xl text-sm leading-6 text-slate-600"
>
Theo dõi nhanh nội dung, tài nguyên media, cấu hình website và các biểu mẫu từ
người dùng ngay trên một màn hình tổng hợp.
</
p
>
</
div
>
</
div
>
<
div
className=
"grid gap-3 sm:grid-cols-2"
>
<
Link
href=
"/admin/news"
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white/90 p-4 transition hover:border-[#063e8e]/20 hover:shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.16em] text-slate-400"
>
Xuất bản
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
newsItems
.
length
}
</
div
>
<
div
className=
"mt-1 text-sm text-slate-500"
>
bài viết đang quản lý
</
div
>
</
Link
>
<
Link
href=
"/admin/contact-management/contact-requests"
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white/90 p-4 transition hover:border-[#063e8e]/20 hover:shadow-sm"
>
<
div
className=
"text-xs uppercase tracking-[0.16em] text-slate-400"
>
Phản hồi
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
contactRequests
.
length
+
membershipApplications
.
length
}
</
div
>
<
div
className=
"mt-1 text-sm text-slate-500"
>
đơn đang cần theo dõi
</
div
>
</
Link
>
</
div
>
</
div
>
</
CardContent
>
</
Card
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-3"
>
<
CardTitle
className=
"text-xl text-[#163b73]"
>
Thông tin website
</
CardTitle
>
<
CardDescription
className=
"text-slate-600"
>
Tóm tắt nhận diện và cấu hình chung đang hiển thị.
</
CardDescription
>
</
CardHeader
>
<
CardContent
className=
"space-y-4"
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Website
</
div
>
<
div
className=
"mt-2 text-lg font-semibold text-[#163b73]"
>
{
baseConfig
.
websiteName
}
</
div
>
<
div
className=
"mt-1 truncate text-sm text-slate-500"
>
{
baseConfig
.
websiteLink
}
</
div
>
</
div
>
<
div
className=
"grid gap-3 sm:grid-cols-2"
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Banner hoạt động
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
activeBanners
.
length
}
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Mạng xã hội hiển thị
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
visibleSocials
.
length
}
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Chi nhánh liên hệ
</
div
>
<
div
className=
"mt-2 text-lg font-semibold text-[#163b73]"
>
{
baseConfig
.
branches
.
length
}
địa điểm
</
div
>
<
div
className=
"mt-1 text-sm text-slate-500"
>
{
baseConfig
.
branches
[
0
]?.
branchName
||
"Chưa có chi nhánh nào được cấu hình"
}
</
div
>
</
div
>
</
CardContent
>
</
Card
>
</
section
>
<
section
className=
"grid gap-5 md:grid-cols-2 xl:grid-cols-4"
>
{
metrics
.
map
((
metric
)
=>
(
<
Link
key=
{
metric
.
title
}
href=
{
metric
.
href
}
>
<
Card
className=
"h-full rounded-[28px] border-[#063e8e]/10 shadow-sm transition hover:-translate-y-0.5 hover:border-[#063e8e]/20 hover:shadow-[0_16px_40px_rgba(6,62,142,0.1)]"
>
<
CardContent
className=
"flex h-full flex-col justify-between gap-5 p-5"
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
>
<
div
className=
"text-sm font-medium text-slate-500"
>
{
metric
.
title
}
</
div
>
<
div
className=
"mt-3 text-3xl font-semibold text-[#163b73]"
>
{
metric
.
value
}
</
div
>
</
div
>
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]"
>
<
metric
.
icon
className=
"h-5 w-5"
/>
</
div
>
</
div
>
<
div
className=
"text-sm leading-6 text-slate-500"
>
{
metric
.
description
}
</
div
>
</
CardContent
>
</
Card
>
</
Link
>
))
}
</
section
>
<
section
className=
"grid gap-5 xl:grid-cols-[1.1fr_0.9fr]"
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-3"
>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
>
<
CardTitle
className=
"text-xl text-[#163b73]"
>
Hoạt động gần đây
</
CardTitle
>
<
CardDescription
className=
"text-slate-600"
>
Các cập nhật mới nhất từ bài viết, kho ảnh và biểu mẫu liên hệ.
</
CardDescription
>
</
div
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/15 text-[#063e8e]"
>
{
recentActivities
.
length
}
mục
</
Badge
>
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-3"
>
{
recentActivities
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
href
}
className=
"flex items-start justify-between gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]"
>
<
div
className=
"min-w-0"
>
<
div
className=
"flex items-center gap-2"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/15 text-[#063e8e]"
>
{
item
.
badge
}
</
Badge
>
<
span
className=
"text-xs text-slate-400"
>
{
formatDateTime
(
item
.
time
)
}
</
span
>
</
div
>
<
div
className=
"mt-2 line-clamp-1 text-sm font-semibold text-[#163b73]"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-1 line-clamp-2 text-sm text-slate-500"
>
{
item
.
description
}
</
div
>
</
div
>
<
ArrowRight
className=
"mt-1 h-4 w-4 shrink-0 text-slate-400"
/>
</
Link
>
))
}
</
CardContent
>
</
Card
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-3"
>
<
CardTitle
className=
"text-xl text-[#163b73]"
>
Lối tắt quản trị
</
CardTitle
>
<
CardDescription
className=
"text-slate-600"
>
Truy cập nhanh vào các module quan trọng trong admin.
</
CardDescription
>
</
CardHeader
>
<
CardContent
className=
"grid gap-4 sm:grid-cols-2"
>
{
shortcuts
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
title
}
href=
{
item
.
href
}
className=
"rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 transition hover:border-[#063e8e]/20 hover:bg-white"
>
<
div
className=
"flex h-11 w-11 items-center justify-center rounded-2xl bg-white text-[#063e8e] shadow-sm"
>
<
item
.
icon
className=
"h-5 w-5"
/>
</
div
>
<
div
className=
"mt-4 text-sm font-semibold text-[#163b73]"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-1 text-sm leading-6 text-slate-500"
>
{
item
.
description
}
</
div
>
</
Link
>
))
}
</
CardContent
>
</
Card
>
</
section
>
<
section
className=
"grid gap-5 xl:grid-cols-[1fr_1fr_0.9fr]"
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-3"
>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
>
<
CardTitle
className=
"text-xl text-[#163b73]"
>
Nội dung nổi bật
</
CardTitle
>
<
CardDescription
className=
"text-slate-600"
>
Các bài viết mới nhất đang được quản lý trong admin.
</
CardDescription
>
</
div
>
<
Button
asChild
variant=
"outline"
className=
"rounded-xl border-[#063e8e]/15 text-[#063e8e]"
>
<
Link
href=
"/admin/news"
>
Xem tất cả
</
Link
>
</
Button
>
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-4"
>
{
spotlightNews
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
id
}
href=
{
`/admin/news/${item.id}`
}
className=
"flex gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]"
>
<
div
className=
"relative h-20 w-28 shrink-0 overflow-hidden rounded-2xl bg-[#eef4ff]"
>
{
item
.
thumbnail
?
(
<
SafeNextImage
src=
{
item
.
thumbnail
.
url
}
alt=
{
item
.
thumbnail
.
alt
||
item
.
thumbnail
.
name
}
fill
className=
"object-cover"
/>
)
:
(
<
div
className=
"flex h-full items-center justify-center text-[#063e8e]"
>
<
Newspaper
className=
"h-5 w-5"
/>
</
div
>
)
}
</
div
>
<
div
className=
"min-w-0"
>
<
div
className=
"line-clamp-2 text-sm font-semibold text-[#163b73]"
>
{
item
.
title
}
</
div
>
<
div
className=
"mt-2 text-xs text-slate-400"
>
{
formatDateTime
(
item
.
updated_at
||
item
.
created_at
)
}
</
div
>
</
div
>
</
Link
>
))
}
</
CardContent
>
</
Card
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-3"
>
<
CardTitle
className=
"text-xl text-[#163b73]"
>
Danh mục
&
thư viện
</
CardTitle
>
<
CardDescription
className=
"text-slate-600"
>
Tình trạng cấu trúc nội dung và dữ liệu danh mục đang dùng.
</
CardDescription
>
</
CardHeader
>
<
CardContent
className=
"space-y-4"
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Menu header
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
headerCategories
.
length
}
</
div
>
<
div
className=
"mt-1 text-sm text-slate-500"
>
mục điều hướng
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4"
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Bài trong danh mục
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
headerPosts
.
length
}
</
div
>
<
div
className=
"mt-1 text-sm text-slate-500"
>
bản ghi nội dung
</
div
>
</
div
>
</
div
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-4"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"text-sm font-semibold text-[#163b73]"
>
Kho ảnh website
</
div
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/15 text-[#063e8e]"
>
{
mediaItems
.
length
}
ảnh
</
Badge
>
</
div
>
<
div
className=
"mt-4 grid grid-cols-3 gap-3"
>
{
mediaItems
.
slice
(
0
,
3
).
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"relative aspect-square overflow-hidden rounded-2xl bg-[#eef4ff]"
>
<
SafeNextImage
src=
{
item
.
url
}
alt=
{
item
.
alt
||
item
.
name
}
fill
className=
"object-cover"
/>
</
div
>
))
}
</
div
>
</
div
>
</
CardContent
>
</
Card
>
<
Card
className=
"rounded-[30px] border-[#063e8e]/10 shadow-sm"
>
<
CardHeader
className=
"pb-3"
>
<
CardTitle
className=
"text-xl text-[#163b73]"
>
Quy mô dữ liệu
</
CardTitle
>
<
CardDescription
className=
"text-slate-600"
>
Tổng hợp nhanh các nhóm dữ liệu đang có trong hệ thống.
</
CardDescription
>
</
CardHeader
>
<
CardContent
className=
"space-y-3"
>
{
[
{
label
:
"Video"
,
value
:
videos
.
length
,
icon
:
MonitorPlay
},
{
label
:
"Lĩnh vực hội viên"
,
value
:
memberFields
.
length
,
icon
:
FolderTree
},
{
label
:
"Khu vực hội viên"
,
value
:
memberRegions
.
length
,
icon
:
MapPin
},
{
label
:
"Chi nhánh liên hệ"
,
value
:
baseConfig
.
branches
.
length
,
icon
:
Globe
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
className=
"flex items-center justify-between rounded-[22px] border border-[#063e8e]/10 bg-[#f8fbff] px-4 py-3"
>
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"flex h-10 w-10 items-center justify-center rounded-2xl bg-white text-[#063e8e] shadow-sm"
>
<
item
.
icon
className=
"h-4 w-4"
/>
</
div
>
<
span
className=
"text-sm font-medium text-slate-600"
>
{
item
.
label
}
</
span
>
</
div
>
<
span
className=
"text-lg font-semibold text-[#163b73]"
>
{
item
.
value
}
</
span
>
</
div
>
))
}
</
CardContent
>
</
Card
>
</
section
>
</
div
>
);
}
src/app/admin/header-config/[categoryId]/posts/page.tsx
View file @
051d8a8b
...
@@ -254,7 +254,7 @@ export default function HeaderCategoryPostsPage() {
...
@@ -254,7 +254,7 @@ export default function HeaderCategoryPostsPage() {
</
div
>
</
div
>
}
}
>
>
<
div
className=
"overflow-x-auto"
>
<
div
className=
"
scrollbar
overflow-x-auto"
>
<
Table
className=
"min-w-[980px] table-fixed"
>
<
Table
className=
"min-w-[980px] table-fixed"
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
...
...
src/app/admin/header-config/components/header-category-form-dialog.tsx
View file @
051d8a8b
...
@@ -57,10 +57,10 @@ const TYPE_OPTIONS: Array<{ value: HeaderCategoryType; label: string }> = [
...
@@ -57,10 +57,10 @@ const TYPE_OPTIONS: Array<{ value: HeaderCategoryType; label: string }> = [
];
];
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
const
selectTriggerClassName
=
const
selectTriggerClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
...
@@ -104,7 +104,7 @@ export function HeaderCategoryFormDialog({
...
@@ -104,7 +104,7 @@ export function HeaderCategoryFormDialog({
return (
return (
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-h-[90vh] max-w-3xl overflow-y-auto border-[#063e8e]/15 bg-white text-gray-700 shadow-xl"
>
<
DialogContent
className=
"max-h-[90vh] max-w-3xl overflow-y-auto
rounded-3xl
border-[#063e8e]/15 bg-white text-gray-700 shadow-xl"
>
<
DialogHeader
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
title
}
</
DialogTitle
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
title
}
</
DialogTitle
>
<
DialogDescription
className=
"text-gray-700"
>
<
DialogDescription
className=
"text-gray-700"
>
...
...
src/app/admin/layout.tsx
View file @
051d8a8b
...
@@ -15,7 +15,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
...
@@ -15,7 +15,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<
div
<
div
className=
{
cn
(
className=
{
cn
(
'transition-all duration-300'
,
'transition-all duration-300'
,
isOpen
?
'pl-
56'
:
'pl-20
'
,
isOpen
?
'pl-
72'
:
'pl-24
'
,
)
}
)
}
>
>
<
AdminHeader
/>
<
AdminHeader
/>
...
...
src/app/admin/media/page.tsx
0 → 100644
View file @
051d8a8b
"use client"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
Image
as
ImageIcon
,
Plus
,
Save
,
Trash2
,
Upload
,
X
,
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Dialog
,
DialogContent
,
DialogHeader
,
DialogTitle
,
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Textarea
}
from
"@/components/ui/textarea"
;
import
{
type
AdminMediaItem
,
createAdminMediaId
,
persistAdminMediaItems
,
readAdminMediaItems
,
}
from
"@/mockdata/admin-news"
;
const
inputClassName
=
"rounded-2xl border-[#063e8e]/15 bg-white text-gray-700 shadow-sm placeholder:text-gray-400 focus-visible:ring-[#063e8e]/20"
;
type
MediaFormValues
=
{
id
?:
string
;
name
:
string
;
alt
:
string
;
url
:
string
;
mime
:
string
;
size
:
number
;
source
:
"seed"
|
"upload"
;
};
const
EMPTY_MEDIA_FORM
:
MediaFormValues
=
{
name
:
""
,
alt
:
""
,
url
:
""
,
mime
:
"image/*"
,
size
:
0
,
source
:
"upload"
,
};
function
formatFileSize
(
size
:
number
)
{
if
(
!
size
)
return
"Ảnh hệ thống"
;
if
(
size
<
1024
)
return
`
${
size
}
B`
;
if
(
size
<
1024
*
1024
)
return
`
${(
size
/
1024
).
toFixed
(
1
)}
KB`
;
return
`
${(
size
/
(
1024
*
1024
)).
toFixed
(
1
)}
MB`
;
}
function
formatDate
(
value
:
string
)
{
return
new
Date
(
value
).
toLocaleString
(
"vi-VN"
,
{
day
:
"2-digit"
,
month
:
"2-digit"
,
year
:
"numeric"
,
hour
:
"2-digit"
,
minute
:
"2-digit"
,
});
}
function
MediaCardSkeleton
()
{
return
(
<
div
className=
"overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_18px_45px_rgba(6,62,142,0.08)]"
>
<
div
className=
"aspect-square animate-pulse bg-[#063e8e]/8"
/>
<
div
className=
"space-y-3 p-4"
>
<
div
className=
"h-4 w-2/3 animate-pulse rounded-full bg-[#063e8e]/10"
/>
<
div
className=
"h-3 w-full animate-pulse rounded-full bg-[#063e8e]/10"
/>
<
div
className=
"h-3 w-1/2 animate-pulse rounded-full bg-[#063e8e]/10"
/>
</
div
>
</
div
>
);
}
interface
MediaFormDialogProps
{
open
:
boolean
;
initial
:
AdminMediaItem
|
null
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onSave
:
(
data
:
MediaFormValues
)
=>
void
;
}
function
MediaFormDialog
({
open
,
initial
,
onOpenChange
,
onSave
,
}:
MediaFormDialogProps
)
{
const
inputRef
=
React
.
useRef
<
HTMLInputElement
|
null
>
(
null
);
const
[
form
,
setForm
]
=
React
.
useState
<
MediaFormValues
>
(
EMPTY_MEDIA_FORM
);
React
.
useEffect
(()
=>
{
if
(
!
open
)
return
;
setForm
(
initial
?
{
id
:
initial
.
id
,
name
:
initial
.
name
,
alt
:
initial
.
alt
,
url
:
initial
.
url
,
mime
:
initial
.
mime
,
size
:
initial
.
size
,
source
:
initial
.
source
,
}
:
EMPTY_MEDIA_FORM
,
);
},
[
initial
,
open
]);
const
handleField
=
<
K
extends
keyof
MediaFormValues
>
(
key: K,
value: MediaFormValues[K],
) =
>
{
setForm
((
previous
)
=>
({
...
previous
,
[
key
]:
value
}));
}
;
const handleUpload = (event: React.ChangeEvent
<
HTMLInputElement
>
) =
>
{
const
file
=
event
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
const
reader
=
new
FileReader
();
reader
.
onload
=
()
=>
{
const
defaultName
=
file
.
name
.
replace
(
/
\.[^
.
]
+$/
,
""
);
setForm
((
previous
)
=>
({
...
previous
,
name
:
previous
.
name
||
defaultName
,
alt
:
previous
.
alt
||
defaultName
,
url
:
typeof
reader
.
result
===
"string"
?
reader
.
result
:
previous
.
url
,
mime
:
file
.
type
||
"image/*"
,
size
:
file
.
size
,
source
:
"upload"
,
}));
};
reader
.
readAsDataURL
(
file
);
event
.
target
.
value
=
""
;
}
;
const handleSave = () =
>
{
if
(
!
form
.
name
.
trim
())
{
toast
.
error
(
"Vui lòng nhập tên ảnh"
);
return
;
}
if
(
!
form
.
url
.
trim
())
{
toast
.
error
(
"Vui lòng chọn ảnh hoặc nhập liên kết ảnh"
);
return
;
}
onSave
({
...
form
,
name
:
form
.
name
.
trim
(),
alt
:
form
.
alt
.
trim
()
||
form
.
name
.
trim
(),
url
:
form
.
url
.
trim
(),
});
}
;
return (
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"flex max-h-[calc(100dvh-32px)] w-[calc(100vw-32px)] max-w-4xl flex-col overflow-hidden rounded-[32px] border border-[#063e8e]/15 bg-white p-0 shadow-[0_26px_70px_rgba(15,23,42,0.24)]"
>
<
DialogHeader
className=
"shrink-0 border-b border-[#063e8e]/10 px-6 py-5 sm:px-7"
>
<
DialogTitle
className=
"text-xl font-semibold text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa ảnh"
:
"Tải ảnh lên"
}
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"min-h-0 flex-1 overflow-y-auto lg:grid lg:grid-cols-[1.1fr_0.9fr]"
>
<
div
className=
"border-b border-[#063e8e]/10 bg-[linear-gradient(180deg,#f5f9ff_0%,#eef5ff_100%)] p-6 lg:border-b-0 lg:border-r lg:p-7"
>
<
div
className=
"space-y-4"
>
<
div
className=
"overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]"
>
<
div
className=
"relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]"
>
{
form
.
url
?
(
<
SafeNextImage
src=
{
form
.
url
}
alt=
{
form
.
alt
||
form
.
name
}
fill
className=
"object-contain p-4"
/>
)
:
(
<
div
className=
"flex h-full flex-col items-center justify-center gap-3 text-center"
>
<
div
className=
"flex h-14 w-14 items-center justify-center rounded-2xl bg-[#063e8e]/10 text-[#063e8e]"
>
<
ImageIcon
className=
"h-7 w-7"
/>
</
div
>
<
div
className=
"space-y-1"
>
<
p
className=
"text-sm font-semibold text-slate-700"
>
Chưa có ảnh nào được chọn
</
p
>
<
p
className=
"text-xs text-slate-500"
>
Kéo thả hoặc tải ảnh từ máy tính của bạn
</
p
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
<
div
className=
"rounded-[28px] border border-dashed border-[#063e8e]/20 bg-white/90 p-5"
>
<
input
ref=
{
inputRef
}
type=
"file"
accept=
"image/*"
className=
"hidden"
onChange=
{
handleUpload
}
/>
<
div
className=
"space-y-3"
>
<
p
className=
"text-sm font-semibold text-slate-700"
>
Tải ảnh từ máy tính
</
p
>
<
p
className=
"text-sm text-slate-500"
>
Hỗ trợ ảnh JPG, PNG, WEBP. Dung lượng sẽ được lưu theo file bạn chọn.
</
p
>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
()
=>
inputRef
.
current
?.
click
()
}
className=
"rounded-2xl border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#edf4ff]"
>
<
Upload
className=
"mr-2 h-4 w-4"
/>
Chọn ảnh
</
Button
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"p-6 lg:p-7"
>
<
div
className=
"space-y-5"
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-sm font-medium text-slate-700"
>
Tiêu đề ảnh
</
Label
>
<
Input
value=
{
form
.
name
}
onChange=
{
(
event
)
=>
handleField
(
"name"
,
event
.
target
.
value
)
}
placeholder=
"Nhập tiêu đề ảnh"
className=
{
inputClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-sm font-medium text-slate-700"
>
Mô tả alt
</
Label
>
<
Input
value=
{
form
.
alt
}
onChange=
{
(
event
)
=>
handleField
(
"alt"
,
event
.
target
.
value
)
}
placeholder=
"Nhập mô tả alt"
className=
{
inputClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-sm font-medium text-slate-700"
>
Liên kết ảnh
</
Label
>
<
Input
value=
{
form
.
url
}
onChange=
{
(
event
)
=>
handleField
(
"url"
,
event
.
target
.
value
)
}
placeholder=
"https://... hoặc data:image/..."
className=
{
inputClassName
}
/>
</
div
>
<
div
className=
"space-y-2"
>
<
Label
className=
"text-sm font-medium text-slate-700"
>
Ghi chú hiển thị
</
Label
>
<
Textarea
value=
{
form
.
alt
}
onChange=
{
(
event
)
=>
handleField
(
"alt"
,
event
.
target
.
value
)
}
placeholder=
"Nhập nội dung mô tả ngắn cho ảnh"
rows=
{
4
}
className=
{
`${inputClassName} min-h-[120px] resize-none`
}
/>
</
div
>
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-4 text-sm text-slate-500"
>
<
div
className=
"flex items-center gap-2"
>
<
p
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Dung lượng
</
p
>
<
p
className=
"font-semibold text-slate-700"
>
{
formatFileSize
(
form
.
size
)
}
</
p
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"shrink-0 border-t border-[#063e8e]/10 bg-[#f8fbff] px-6 py-4 sm:px-7"
>
<
div
className=
"flex justify-end gap-3"
>
<
Button
type=
"button"
variant=
"outline"
onClick=
{
()
=>
onOpenChange
(
false
)
}
className=
"rounded-2xl border-[#063e8e]/15 bg-white text-slate-600 hover:bg-slate-50"
>
<
X
className=
"mr-2 h-4 w-4"
/>
Hủy
</
Button
>
<
Button
type=
"button"
onClick=
{
handleSave
}
className=
"rounded-2xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<
Save
className=
"mr-2 h-4 w-4"
/>
{
initial
?
"Lưu thay đổi"
:
"Tải ảnh lên"
}
</
Button
>
</
div
>
</
div
>
</
DialogContent
>
</
Dialog
>
);
}
export default function AdminMediaPage()
{
const
[
items
,
setItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
dialogOpen
,
setDialogOpen
]
=
React
.
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
React
.
useState
<
AdminMediaItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
AdminMediaItem
|
null
>
(
null
);
React
.
useEffect
(()
=>
{
setItems
(
readAdminMediaItems
());
setReady
(
true
);
},
[]);
const
filtered
=
React
.
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
if
(
!
keyword
)
return
items
;
return
items
.
filter
((
item
)
=>
{
return
[
item
.
name
,
item
.
alt
,
item
.
url
].
some
((
value
)
=>
value
.
toLowerCase
().
includes
(
keyword
),
);
});
},
[
items
,
search
]);
const
openCreate
=
()
=>
{
setEditTarget
(
null
);
setDialogOpen
(
true
);
};
const
openEdit
=
(
item
:
AdminMediaItem
)
=>
{
setEditTarget
(
item
);
setDialogOpen
(
true
);
};
const
handleSave
=
(
data
:
MediaFormValues
)
=>
{
const
now
=
new
Date
().
toISOString
();
let
nextItems
:
AdminMediaItem
[];
if
(
data
.
id
)
{
nextItems
=
items
.
map
((
item
)
=>
item
.
id
===
data
.
id
?
{
...
item
,
name
:
data
.
name
,
alt
:
data
.
alt
,
url
:
data
.
url
,
mime
:
data
.
mime
,
size
:
data
.
size
,
source
:
data
.
source
,
updated_at
:
now
,
}
:
item
,
);
toast
.
success
(
"Đã cập nhật ảnh"
);
}
else
{
nextItems
=
[
{
id
:
createAdminMediaId
(),
name
:
data
.
name
,
alt
:
data
.
alt
,
url
:
data
.
url
,
mime
:
data
.
mime
,
size
:
data
.
size
,
source
:
data
.
source
,
created_at
:
now
,
updated_at
:
now
,
},
...
items
,
];
toast
.
success
(
"Đã thêm ảnh mới"
);
}
persistAdminMediaItems
(
nextItems
);
setItems
(
readAdminMediaItems
());
setDialogOpen
(
false
);
};
const
handleDelete
=
()
=>
{
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
nextItems
);
persistAdminMediaItems
(
nextItems
);
toast
.
success
(
"Đã xóa ảnh"
);
setDeleteTarget
(
null
);
};
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm ảnh..."
actionLabel=
"Tải ảnh lên"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]"
>
Tổng số ảnh:
{
items
.
length
}
</
div
>
}
onSearchChange=
{
setSearch
}
onActionClick=
{
openCreate
}
>
<
div
className=
"bg-white p-4 sm:p-5"
>
{
!
ready
?
(
<
div
className=
"grid gap-5 sm:grid-cols-2 xl:grid-cols-4"
>
{
Array
.
from
({
length
:
8
}).
map
((
_
,
index
)
=>
(
<
MediaCardSkeleton
key=
{
`media-loading-${index}`
}
/>
))
}
</
div
>
)
:
filtered
.
length
===
0
?
(
<
div
className=
"flex min-h-[320px] flex-col items-center justify-center rounded-[28px] border border-dashed border-[#063e8e]/20 bg-[#fbfdff] text-center"
>
<
div
className=
"flex h-16 w-16 items-center justify-center rounded-[22px] bg-[#063e8e]/10 text-[#063e8e]"
>
<
ImageIcon
className=
"h-8 w-8"
/>
</
div
>
<
h2
className=
"mt-5 text-lg font-semibold text-slate-800"
>
Chưa có ảnh phù hợp
</
h2
>
<
p
className=
"mt-2 max-w-md text-sm leading-6 text-slate-500"
>
Hãy tải ảnh mới hoặc thử lại với từ khóa khác để tìm đúng hình ảnh bạn cần.
</
p
>
</
div
>
)
:
(
<
div
className=
"grid gap-5 sm:grid-cols-2 xl:grid-cols-4"
>
{
filtered
.
map
((
item
)
=>
(
<
article
key=
{
item
.
id
}
className=
"group overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_28px_60px_rgba(6,62,142,0.14)]"
>
<
div
className=
"relative aspect-square overflow-hidden bg-[radial-gradient(circle_at_top,#dce9ff_0%,#f8fbff_55%,#ffffff_100%)]"
>
<
SafeNextImage
src=
{
item
.
url
}
alt=
{
item
.
alt
||
item
.
name
}
fill
className=
"object-contain p-4 transition duration-300 group-hover:scale-[1.03]"
/>
<
div
className=
"absolute inset-0 bg-[linear-gradient(180deg,rgba(6,62,142,0)_15%,rgba(15,23,42,0.68)_100%)] opacity-0 transition duration-300 group-hover:opacity-100"
/>
<
div
className=
"absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 p-4 opacity-0 transition duration-300 group-hover:opacity-100"
>
<
div
className=
"rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 backdrop-blur"
>
{
item
.
mime
.
split
(
"/"
)[
1
]?.
toUpperCase
()
||
"IMG"
}
</
div
>
<
div
className=
"flex items-center gap-2"
>
<
Button
type=
"button"
size=
"icon"
variant=
"secondary"
onClick=
{
()
=>
openEdit
(
item
)
}
className=
"h-10 w-10 rounded-2xl bg-white text-[#063e8e] shadow-lg hover:bg-white"
>
<
Edit
className=
"h-4 w-4"
/>
</
Button
>
<
Button
type=
"button"
size=
"icon"
variant=
"secondary"
onClick=
{
()
=>
setDeleteTarget
(
item
)
}
className=
"h-10 w-10 rounded-2xl bg-white text-red-600 shadow-lg hover:bg-white"
>
<
Trash2
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
div
>
</
div
>
<
div
className=
"space-y-3 p-4"
>
<
div
className=
"space-y-1"
>
<
h3
className=
"line-clamp-1 text-sm font-semibold text-slate-900"
>
{
item
.
name
}
</
h3
>
<
p
className=
"line-clamp-2 min-h-10 text-xs leading-5 text-slate-500"
>
{
item
.
alt
||
"Chưa có mô tả alt cho ảnh này."
}
</
p
>
</
div
>
<
div
className=
"flex items-center justify-between text-xs text-slate-500"
>
<
span
>
{
formatFileSize
(
item
.
size
)
}
</
span
>
</
div
>
<
div
className=
"border-t border-[#063e8e]/8 pt-3 text-xs text-slate-500"
>
{
formatDate
(
item
.
updated_at
)
}
</
div
>
</
div
>
</
article
>
))
}
</
div
>
)
}
</
div
>
</
AdminTableLayout
>
<
MediaFormDialog
open=
{
dialogOpen
}
initial=
{
editTarget
}
onOpenChange=
{
setDialogOpen
}
onSave=
{
handleSave
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa ảnh"
description=
{
<>
Bạn có chắc muốn xóa ảnh
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
?
</>
}
onOpenChange=
{
(
open
)
=>
!
open
&&
setDeleteTarget
(
null
)
}
onConfirm=
{
handleDelete
}
/>
</
div
>
);
}
src/app/admin/members/fields/page.tsx
View file @
051d8a8b
...
@@ -30,7 +30,7 @@ import {
...
@@ -30,7 +30,7 @@ import {
}
from
"@/mockdata/members"
;
}
from
"@/mockdata/members"
;
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
FieldFormDialogProps
{
interface
FieldFormDialogProps
{
open
:
boolean
;
open
:
boolean
;
...
@@ -57,7 +57,7 @@ function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialo
...
@@ -57,7 +57,7 @@ function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialo
return
(
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogContent
className=
"max-w-md
rounded-3xl
border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa lĩnh vực"
:
"Thêm lĩnh vực mới"
}
{
initial
?
"Chỉnh sửa lĩnh vực"
:
"Thêm lĩnh vực mới"
}
...
@@ -163,8 +163,8 @@ export default function AdminMemberFieldsPage() {
...
@@ -163,8 +163,8 @@ export default function AdminMemberFieldsPage() {
actionLabel=
"Thêm lĩnh vực"
actionLabel=
"Thêm lĩnh vực"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
actionMeta=
{
<
div
className=
"
text-sm font-medium text-gray-700
"
>
<
div
className=
"
rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]
"
>
Tổng lĩnh vực:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
Tổng lĩnh vực:
{
items
.
length
}
</
div
>
</
div
>
}
}
onSearchChange=
{
setSearch
}
onSearchChange=
{
setSearch
}
...
...
src/app/admin/members/page.tsx
View file @
051d8a8b
"use client"
;
"use client"
;
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
{
Edit
,
MoreHorizontal
,
Plus
,
Trash2
,
Users
}
from
"lucide-react"
;
import
{
Edit
,
MoreHorizontal
,
Plus
,
Trash2
}
from
"lucide-react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
SafeNextImage
}
from
"@/components/admin/safe-next-image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
...
@@ -43,7 +42,7 @@ import {
...
@@ -43,7 +42,7 @@ import {
}
from
"@/mockdata/members"
;
}
from
"@/mockdata/members"
;
const
selectTriggerClassName
=
const
selectTriggerClassName
=
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[200px]"
;
"w-full
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[200px]"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
...
@@ -97,55 +96,38 @@ export default function AdminMembersPage() {
...
@@ -97,55 +96,38 @@ export default function AdminMembersPage() {
});
});
},
[
items
,
search
,
fieldFilter
,
regionFilter
]);
},
[
items
,
search
,
fieldFilter
,
regionFilter
]);
const
stats
=
React
.
useMemo
(
()
=>
[
{
label
:
"Tổng hội viên"
,
value
:
items
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
{
label
:
"Số lĩnh vực"
,
value
:
fields
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
{
label
:
"Số khu vực"
,
value
:
regions
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
],
[
items
,
fields
,
regions
],
);
const
fieldMap
=
React
.
useMemo
(
const
fieldMap
=
React
.
useMemo
(
()
=>
Object
.
fromEntries
(
fields
.
map
((
f
)
=>
[
f
.
id
,
f
.
name
])),
()
=>
Object
.
fromEntries
(
fields
.
map
((
f
ield
)
=>
[
field
.
id
,
field
.
name
])),
[
fields
],
[
fields
],
);
);
const
regionMap
=
React
.
useMemo
(
const
regionMap
=
React
.
useMemo
(
()
=>
Object
.
fromEntries
(
regions
.
map
((
r
)
=>
[
r
.
id
,
r
.
name
])),
()
=>
Object
.
fromEntries
(
regions
.
map
((
r
egion
)
=>
[
region
.
id
,
region
.
name
])),
[
regions
],
[
regions
],
);
);
const
handleDelete
=
()
=>
{
const
handleDelete
=
()
=>
{
if
(
!
deleteTarget
)
return
;
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
m
)
=>
m
.
id
!==
deleteTarget
.
id
);
setItems
(
next
);
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
persistMembers
(
next
);
setItems
(
nextItems
);
persistMembers
(
nextItems
);
toast
.
success
(
"Đã xóa hội viên"
);
toast
.
success
(
"Đã xóa hội viên"
);
setDeleteTarget
(
null
);
setDeleteTarget
(
null
);
};
};
return
(
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"space-y-8"
>
<
AdminStatsGrid
items=
{
stats
}
/>
<
AdminTableLayout
<
AdminTableLayout
searchValue=
{
search
}
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm hội viên..."
searchPlaceholder=
"Tìm kiếm hội viên..."
actionLabel=
"Thêm hội viên"
actionLabel=
"Thêm hội viên"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]"
>
Tổng số hội viên:
{
items
.
length
}
</
div
>
}
onSearchChange=
{
setSearch
}
onSearchChange=
{
setSearch
}
onActionClick=
{
()
=>
router
.
push
(
"/admin/members/new"
)
}
onActionClick=
{
()
=>
router
.
push
(
"/admin/members/new"
)
}
filters=
{
filters=
{
...
@@ -158,9 +140,9 @@ export default function AdminMembersPage() {
...
@@ -158,9 +140,9 @@ export default function AdminMembersPage() {
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
Tất cả lĩnh vực
Tất cả lĩnh vực
</
SelectItem
>
</
SelectItem
>
{
fields
.
map
((
f
)
=>
(
{
fields
.
map
((
f
ield
)
=>
(
<
SelectItem
key=
{
f
.
id
}
value=
{
f
.
id
}
className=
{
selectItemClassName
}
>
<
SelectItem
key=
{
f
ield
.
id
}
value=
{
field
.
id
}
className=
{
selectItemClassName
}
>
{
f
.
name
}
{
f
ield
.
name
}
</
SelectItem
>
</
SelectItem
>
))
}
))
}
</
SelectContent
>
</
SelectContent
>
...
@@ -174,9 +156,9 @@ export default function AdminMembersPage() {
...
@@ -174,9 +156,9 @@ export default function AdminMembersPage() {
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
Tất cả khu vực
Tất cả khu vực
</
SelectItem
>
</
SelectItem
>
{
regions
.
map
((
r
)
=>
(
{
regions
.
map
((
r
egion
)
=>
(
<
SelectItem
key=
{
r
.
id
}
value=
{
r
.
id
}
className=
{
selectItemClassName
}
>
<
SelectItem
key=
{
r
egion
.
id
}
value=
{
region
.
id
}
className=
{
selectItemClassName
}
>
{
r
.
name
}
{
r
egion
.
name
}
</
SelectItem
>
</
SelectItem
>
))
}
))
}
</
SelectContent
>
</
SelectContent
>
...
@@ -184,7 +166,7 @@ export default function AdminMembersPage() {
...
@@ -184,7 +166,7 @@ export default function AdminMembersPage() {
</
div
>
</
div
>
}
}
>
>
<
div
className=
"overflow-x-auto"
>
<
div
className=
"
scrollbar
overflow-x-auto"
>
<
Table
className=
"min-w-[900px] table-fixed"
>
<
Table
className=
"min-w-[900px] table-fixed"
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
...
@@ -197,6 +179,7 @@ export default function AdminMembersPage() {
...
@@ -197,6 +179,7 @@ export default function AdminMembersPage() {
<
TableHead
className=
"w-[100px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
<
TableHead
className=
"w-[100px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
{
!
ready
?
(
{
!
ready
?
(
<
MemberTableLoading
/>
<
MemberTableLoading
/>
...
@@ -225,6 +208,7 @@ export default function AdminMembersPage() {
...
@@ -225,6 +208,7 @@ export default function AdminMembersPage() {
)
:
null
}
)
:
null
}
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
TableCell
className=
"px-4 py-3 text-center"
>
{
item
.
image
?
(
{
item
.
image
?
(
<
div
className=
"mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15"
>
<
div
className=
"mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15"
>
...
@@ -242,21 +226,26 @@ export default function AdminMembersPage() {
...
@@ -242,21 +226,26 @@ export default function AdminMembersPage() {
</
div
>
</
div
>
)
}
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
{
regionMap
[
item
.
region_id
]
??
"—"
}
{
regionMap
[
item
.
region_id
]
??
"—"
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
{
fieldMap
[
item
.
field_id
]
??
"—"
}
{
fieldMap
[
item
.
field_id
]
??
"—"
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
{
item
.
phone
&&
<
div
>
{
item
.
phone
}
</
div
>
}
{
item
.
phone
&&
<
div
>
{
item
.
phone
}
</
div
>
}
{
item
.
email
&&
(
{
item
.
email
&&
(
<
div
className=
"truncate text-xs text-[#063e8e]"
>
{
item
.
email
}
</
div
>
<
div
className=
"truncate text-xs text-[#063e8e]"
>
{
item
.
email
}
</
div
>
)
}
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
span
className=
"line-clamp-2"
>
{
item
.
address
||
"—"
}
</
span
>
<
span
className=
"line-clamp-2"
>
{
item
.
address
||
"—"
}
</
span
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
DropdownMenu
>
<
DropdownMenu
>
<
DropdownMenuTrigger
asChild
>
<
DropdownMenuTrigger
asChild
>
...
...
src/app/admin/members/regions/page.tsx
View file @
051d8a8b
...
@@ -30,7 +30,7 @@ import {
...
@@ -30,7 +30,7 @@ import {
}
from
"@/mockdata/members"
;
}
from
"@/mockdata/members"
;
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
RegionFormDialogProps
{
interface
RegionFormDialogProps
{
open
:
boolean
;
open
:
boolean
;
...
@@ -57,7 +57,7 @@ function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDia
...
@@ -57,7 +57,7 @@ function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDia
return
(
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogContent
className=
"max-w-md
rounded-3xl
border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa khu vực"
:
"Thêm khu vực mới"
}
{
initial
?
"Chỉnh sửa khu vực"
:
"Thêm khu vực mới"
}
...
@@ -163,8 +163,8 @@ export default function AdminMemberRegionsPage() {
...
@@ -163,8 +163,8 @@ export default function AdminMemberRegionsPage() {
actionLabel=
"Thêm khu vực"
actionLabel=
"Thêm khu vực"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
actionMeta=
{
<
div
className=
"
text-sm font-medium text-gray-700
"
>
<
div
className=
"
rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]
"
>
Tổng khu vực:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
Tổng khu vực:
{
items
.
length
}
</
div
>
</
div
>
}
}
onSearchChange=
{
setSearch
}
onSearchChange=
{
setSearch
}
...
...
src/app/admin/news/page.tsx
View file @
051d8a8b
...
@@ -57,7 +57,7 @@ import {
...
@@ -57,7 +57,7 @@ import {
}
from
"@/mockdata/header-config"
;
}
from
"@/mockdata/header-config"
;
const
selectTriggerClassName
=
const
selectTriggerClassName
=
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[180px]"
;
"w-full
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[180px]"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
...
@@ -268,7 +268,7 @@ export default function AdminNewsPage() {
...
@@ -268,7 +268,7 @@ export default function AdminNewsPage() {
</
div
>
</
div
>
}
}
>
>
<
div
className=
"overflow-x-auto"
>
<
div
className=
"
scrollbar
overflow-x-auto"
>
<
Table
className=
"min-w-[1250px] table-fixed"
>
<
Table
className=
"min-w-[1250px] table-fixed"
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
...
...
src/app/admin/page.tsx
View file @
051d8a8b
import
{
redirect
}
from
'next/navigation'
;
import
{
redirect
}
from
'next/navigation'
;
export
default
function
AdminPage
()
{
export
default
function
AdminPage
()
{
redirect
(
'/admin/
dashboard
'
);
redirect
(
'/admin/
base-config
'
);
}
}
src/app/admin/videos/page.tsx
View file @
051d8a8b
...
@@ -33,7 +33,7 @@ import {
...
@@ -33,7 +33,7 @@ import {
}
from
"@/mockdata/videos"
;
}
from
"@/mockdata/videos"
;
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
VideoFormDialogProps
{
interface
VideoFormDialogProps
{
open
:
boolean
;
open
:
boolean
;
...
@@ -83,7 +83,7 @@ function VideoFormDialog({ open, initial, onOpenChange, onSave }: VideoFormDialo
...
@@ -83,7 +83,7 @@ function VideoFormDialog({ open, initial, onOpenChange, onSave }: VideoFormDialo
return (
return (
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-lg border-[#063e8e]/15 bg-white"
>
<
DialogContent
className=
"max-w-lg
rounded-3xl
border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa video"
:
"Thêm video mới"
}
{
initial
?
"Chỉnh sửa video"
:
"Thêm video mới"
}
...
...
src/components/admin/admin-table-layout.tsx
View file @
051d8a8b
...
@@ -38,7 +38,7 @@ export function AdminTableLayout({
...
@@ -38,7 +38,7 @@ export function AdminTableLayout({
value=
{
searchValue
}
value=
{
searchValue
}
placeholder=
{
searchPlaceholder
}
placeholder=
{
searchPlaceholder
}
onChange=
{
(
event
)
=>
onSearchChange
(
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
onSearchChange
(
event
.
target
.
value
)
}
className=
"max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
className=
"max-w-sm
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
/>
/>
{
filters
}
{
filters
}
</
div
>
</
div
>
...
...
src/components/admin/contact-management-detail-dialog.tsx
0 → 100644
View file @
051d8a8b
"use client"
;
import
*
as
React
from
"react"
;
import
{
Dialog
,
DialogContent
,
DialogDescription
,
DialogHeader
,
DialogTitle
,
}
from
"@/components/ui/dialog"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
DetailField
{
label
:
string
;
value
:
React
.
ReactNode
;
fullWidth
?:
boolean
;
}
interface
DetailSection
{
title
:
string
;
fields
:
DetailField
[];
}
interface
ContactManagementDetailDialogProps
{
open
:
boolean
;
title
:
string
;
description
?:
string
;
badge
?:
React
.
ReactNode
;
sections
:
DetailSection
[];
onOpenChange
:
(
open
:
boolean
)
=>
void
;
}
export
function
ContactManagementDetailDialog
({
open
,
title
,
description
,
badge
,
sections
,
onOpenChange
,
}:
ContactManagementDetailDialogProps
)
{
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-w-4xl rounded-3xl border-[#063e8e]/15 bg-white"
>
<
DialogHeader
className=
"space-y-3"
>
<
div
className=
"flex flex-col gap-3 pr-12 sm:flex-row sm:items-center sm:justify-between sm:pr-14"
>
<
DialogTitle
className=
"text-xl text-[#063e8e]"
>
{
title
}
</
DialogTitle
>
{
badge
}
</
div
>
{
description
?
(
<
DialogDescription
className=
"text-sm leading-6 text-gray-600"
>
{
description
}
</
DialogDescription
>
)
:
null
}
</
DialogHeader
>
<
div
className=
"max-h-[70vh] space-y-6 overflow-y-auto pr-1"
>
{
sections
.
map
((
section
)
=>
(
<
section
key=
{
section
.
title
}
className=
"space-y-3"
>
<
div
className=
"border-b border-[#063e8e]/12 pb-2"
>
<
h3
className=
"text-sm font-semibold uppercase tracking-[0.14em] text-[#063e8e]"
>
{
section
.
title
}
</
h3
>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
section
.
fields
.
map
((
field
)
=>
(
<
div
key=
{
`${section.title}-${field.label}`
}
className=
{
cn
(
"rounded-2xl border border-[#063e8e]/12 bg-[#063e8e]/[0.03] p-4"
,
field
.
fullWidth
&&
"md:col-span-2"
,
)
}
>
<
div
className=
"text-xs font-semibold uppercase tracking-[0.14em] text-gray-500"
>
{
field
.
label
}
</
div
>
<
div
className=
"mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-gray-800"
>
{
field
.
value
||
"—"
}
</
div
>
</
div
>
))
}
</
div
>
</
section
>
))
}
</
div
>
</
DialogContent
>
</
Dialog
>
);
}
src/components/admin/image-picker.tsx
View file @
051d8a8b
...
@@ -93,7 +93,7 @@ export function AdminImagePicker({
...
@@ -93,7 +93,7 @@ export function AdminImagePicker({
return
(
return
(
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
Dialog
open=
{
open
}
onOpenChange=
{
onOpenChange
}
>
<
DialogContent
className=
"max-h-[88vh] max-w-5xl overflow-hidden border-[#063e8e]/15 bg-white p-0"
>
<
DialogContent
className=
"max-h-[88vh] max-w-5xl overflow-hidden
rounded-3xl
border-[#063e8e]/15 bg-white p-0"
>
<
DialogHeader
className=
"border-b border-[#063e8e]/10 px-6 py-5"
>
<
DialogHeader
className=
"border-b border-[#063e8e]/10 px-6 py-5"
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
>
<
div
>
...
...
src/components/admin/member-form.tsx
View file @
051d8a8b
...
@@ -29,6 +29,7 @@ import {
...
@@ -29,6 +29,7 @@ import {
type
MemberImageRef
,
type
MemberImageRef
,
type
MemberItem
,
type
MemberItem
,
type
MemberRegion
,
type
MemberRegion
,
type
MemberSocialItem
,
EMPTY_MEMBER_FORM
,
EMPTY_MEMBER_FORM
,
cloneMemberFormValues
,
cloneMemberFormValues
,
createMemberId
,
createMemberId
,
...
@@ -43,10 +44,10 @@ interface AdminMemberFormProps {
...
@@ -43,10 +44,10 @@ interface AdminMemberFormProps {
}
}
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
const
selectTriggerClassName
=
const
selectTriggerClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
...
@@ -98,6 +99,13 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
...
@@ -98,6 +99,13 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
set
(
"introduction"
,
sections
);
set
(
"introduction"
,
sections
);
}
;
}
;
const handleSocialChange = (socialId: string, value: string) =
>
{
set
(
"socials"
,
form
.
socials
.
map
((
item
)
=>
(
item
.
id
===
socialId
?
{
...
item
,
url
:
value
}
:
item
)),
);
}
;
const handleSave = () =
>
{
const handleSave = () =
>
{
if
(
!
form
.
name
.
trim
())
{
if
(
!
form
.
name
.
trim
())
{
toast
.
error
(
"Vui lòng nhập tên hội viên"
);
toast
.
error
(
"Vui lòng nhập tên hội viên"
);
...
@@ -123,6 +131,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
...
@@ -123,6 +131,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
fax
:
form
.
fax
,
fax
:
form
.
fax
,
email
:
form
.
email
,
email
:
form
.
email
,
website
:
form
.
website
,
website
:
form
.
website
,
socials
:
form
.
socials
,
introduction
:
form
.
introduction
,
introduction
:
form
.
introduction
,
created_at
:
now
,
created_at
:
now
,
updated_at
:
now
,
updated_at
:
now
,
...
@@ -144,6 +153,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
...
@@ -144,6 +153,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
fax
:
form
.
fax
,
fax
:
form
.
fax
,
email
:
form
.
email
,
email
:
form
.
email
,
website
:
form
.
website
,
website
:
form
.
website
,
socials
:
form
.
socials
,
introduction
:
form
.
introduction
,
introduction
:
form
.
introduction
,
updated_at
:
now
,
updated_at
:
now
,
}
satisfies
MemberItem
;
}
satisfies
MemberItem
;
...
@@ -295,6 +305,29 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
...
@@ -295,6 +305,29 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"space-y-3 rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4"
>
<
div
>
<
p
className=
"text-sm font-medium text-gray-700"
>
Mạng xã hội
</
p
>
<
p
className=
"mt-1 text-sm text-gray-500"
>
Nhập link mạng xã hội cho hội viên nếu có.
</
p
>
</
div
>
<
div
className=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
{
form
.
socials
.
map
((
social
:
MemberSocialItem
)
=>
(
<
div
key=
{
social
.
id
}
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
{
social
.
label
}
</
Label
>
<
Input
value=
{
social
.
url
}
onChange=
{
(
e
)
=>
handleSocialChange
(
social
.
id
,
e
.
target
.
value
)
}
placeholder=
{
`Nhập link ${social.label}
...
`
}
className=
{
fieldClassName
}
/>
</
div
>
))
}
</
div
>
</
div
>
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] px-4 py-3"
>
<
div
className=
"rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] px-4 py-3"
>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
className=
"flex items-center justify-between gap-3"
>
<
div
>
<
div
>
...
...
src/components/admin/news-form.tsx
View file @
051d8a8b
...
@@ -53,13 +53,13 @@ interface AdminNewsFormProps {
...
@@ -53,13 +53,13 @@ interface AdminNewsFormProps {
}
}
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
const
readOnlyFieldClassName
=
const
readOnlyFieldClassName
=
"border-[#063e8e]/10 bg-[#063e8e]/[0.03] text-gray-700 placeholder:text-gray-700"
;
"
rounded-xl
border-[#063e8e]/10 bg-[#063e8e]/[0.03] text-gray-700 placeholder:text-gray-700"
;
const
selectTriggerClassName
=
const
selectTriggerClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30"
;
"
rounded-xl
border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
const
selectContentClassName
=
"border-[#063e8e]/15 bg-white text-gray-700"
;
...
...
src/components/shared/admin-header.tsx
View file @
051d8a8b
...
@@ -2,21 +2,28 @@
...
@@ -2,21 +2,28 @@
import
React
from
'react'
;
import
React
from
'react'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
Menu
}
from
'lucide-react'
;
import
{
Menu
,
ShieldCheck
}
from
'lucide-react'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
const
routeLabels
:
Record
<
string
,
string
>
=
{
const
routeLabels
:
Record
<
string
,
string
>
=
{
'/admin/
dashboard'
:
'Dashboard
'
,
'/admin/
base-config'
:
'Cấu hình chung
'
,
'/admin/header-config'
:
'Cấu hình
D
anh mục'
,
'/admin/header-config'
:
'Cấu hình
d
anh mục'
,
'/admin/news'
:
'Quản lý bài viết'
,
'/admin/news'
:
'Quản lý bài viết'
,
'/admin/media'
:
'Quản lý ảnh'
,
'/admin/videos'
:
'Quản lý video'
,
'/admin/videos'
:
'Quản lý video'
,
'/admin/contact-management'
:
'Quản lý liên hệ'
,
'/admin/contact-management/newsletter-emails'
:
'Quản lý Email đăng ký nhận thông tin'
,
'/admin/contact-management/contact-requests'
:
'Quản lý Đơn liên hệ'
,
'/admin/contact-management/membership-applications'
:
'Quản lý Đơn đăng ký hội viên'
,
'/admin/members'
:
'Quản lý Hội viên'
,
'/admin/members'
:
'Quản lý Hội viên'
,
'/admin/partners'
:
'Quản lý Đối tác'
,
'/admin/partners'
:
'Quản lý Đối tác'
,
'/admin/emails'
:
'Email nhận thông tin'
,
'/admin/emails'
:
'Email nhận thông tin'
,
'/admin/website-config'
:
'Thông tin website'
,
'/admin/website-config'
:
'Thông tin website'
,
};
};
const
currentUserRoleLabel
=
'Quản trị viên'
;
function
getTitle
(
pathname
:
string
):
string
{
function
getTitle
(
pathname
:
string
):
string
{
if
(
routeLabels
[
pathname
])
return
routeLabels
[
pathname
];
if
(
routeLabels
[
pathname
])
return
routeLabels
[
pathname
];
...
@@ -48,8 +55,9 @@ export function AdminHeader() {
...
@@ -48,8 +55,9 @@ export function AdminHeader() {
<
h1
className=
"text-xl font-bold text-[#063e8e]"
>
{
title
}
</
h1
>
<
h1
className=
"text-xl font-bold text-[#063e8e]"
>
{
title
}
</
h1
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2 text-xs text-gray-500"
>
<
div
className=
"flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-[#f8fbff] px-3 py-1.5 text-sm font-medium text-[#163b73]"
>
Cập nhật:
{
new
Date
().
toLocaleDateString
(
'vi-VN'
)
}
<
ShieldCheck
className=
"h-4 w-4 text-[#063e8e]"
/>
<
span
>
{
currentUserRoleLabel
}
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
</
header
>
</
header
>
...
...
src/components/shared/admin-sidebar.tsx
View file @
051d8a8b
...
@@ -7,8 +7,12 @@ import { usePathname } from 'next/navigation';
...
@@ -7,8 +7,12 @@ import { usePathname } from 'next/navigation';
import
{
import
{
ChevronDown
,
ChevronDown
,
Globe
,
Globe
,
ImagePlus
,
Layers
,
Layers
,
Mail
,
Newspaper
,
Newspaper
,
Settings
,
Sparkles
,
Users
,
Users
,
Video
,
Video
,
}
from
'lucide-react'
;
}
from
'lucide-react'
;
...
@@ -25,6 +29,7 @@ type NavItem = {
...
@@ -25,6 +29,7 @@ type NavItem = {
};
};
const
navigation
:
NavItem
[]
=
[
const
navigation
:
NavItem
[]
=
[
{
name
:
'Cấu hình chung'
,
href
:
'/admin/base-config'
,
icon
:
Settings
},
{
name
:
'Cấu hình danh mục'
,
href
:
'/admin/header-config'
,
icon
:
Layers
},
{
name
:
'Cấu hình danh mục'
,
href
:
'/admin/header-config'
,
icon
:
Layers
},
{
name
:
'Quản lý bài viết'
,
href
:
'/admin/news'
,
icon
:
Newspaper
},
{
name
:
'Quản lý bài viết'
,
href
:
'/admin/news'
,
icon
:
Newspaper
},
{
name
:
'Quản lý video'
,
href
:
'/admin/videos'
,
icon
:
Video
},
{
name
:
'Quản lý video'
,
href
:
'/admin/videos'
,
icon
:
Video
},
...
@@ -37,6 +42,25 @@ const navigation: NavItem[] = [
...
@@ -37,6 +42,25 @@ const navigation: NavItem[] = [
{
name
:
'Quản lý khu vực'
,
href
:
'/admin/members/regions'
},
{
name
:
'Quản lý khu vực'
,
href
:
'/admin/members/regions'
},
],
],
},
},
{
name
:
'Quản lý liên hệ'
,
icon
:
Mail
,
children
:
[
{
name
:
'Quản lý Email đăng ký nhận thông tin'
,
href
:
'/admin/contact-management/newsletter-emails'
,
},
{
name
:
'Quản lý Đơn liên hệ'
,
href
:
'/admin/contact-management/contact-requests'
,
},
{
name
:
'Quản lý Đơn đăng ký hội viên'
,
href
:
'/admin/contact-management/membership-applications'
,
},
],
},
{
name
:
'Quản lý ảnh'
,
href
:
'/admin/media'
,
icon
:
ImagePlus
},
];
];
const
membersReservedSegments
=
new
Set
([
'fields'
,
'regions'
]);
const
membersReservedSegments
=
new
Set
([
'fields'
,
'regions'
]);
...
@@ -44,9 +68,7 @@ const membersReservedSegments = new Set(['fields', 'regions']);
...
@@ -44,9 +68,7 @@ const membersReservedSegments = new Set(['fields', 'regions']);
export
function
AdminSidebar
()
{
export
function
AdminSidebar
()
{
const
pathname
=
usePathname
();
const
pathname
=
usePathname
();
const
{
isOpen
}
=
useSidebarStore
();
const
{
isOpen
}
=
useSidebarStore
();
const
[
expandedGroups
,
setExpandedGroups
]
=
React
.
useState
<
Record
<
string
,
boolean
>>
({
const
[
expandedGroups
,
setExpandedGroups
]
=
React
.
useState
<
Record
<
string
,
boolean
>>
({});
'Quản lý hội viên'
:
true
,
});
const
isItemActive
=
React
.
useCallback
(
const
isItemActive
=
React
.
useCallback
(
(
href
:
string
)
=>
{
(
href
:
string
)
=>
{
...
@@ -71,79 +93,92 @@ export function AdminSidebar() {
...
@@ -71,79 +93,92 @@ export function AdminSidebar() {
return
(
return
(
<
aside
<
aside
className=
{
cn
(
className=
{
cn
(
'fixed left-0 top-0 z-40 h-screen border-r border-[#063e8e]/1
5 bg-[#063e8e]/20 shadow-[0_10px_30
px_rgba(6,62,142,0.08)] transition-all duration-300'
,
'fixed left-0 top-0 z-40 h-screen border-r border-[#063e8e]/1
0 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45
px_rgba(6,62,142,0.08)] transition-all duration-300'
,
isOpen
?
'w-
56'
:
'w-20
'
,
isOpen
?
'w-
72'
:
'w-24
'
,
)
}
)
}
>
>
<
div
className=
"flex h-full flex-col"
>
<
div
className=
"flex h-full flex-col"
>
<
div
<
div
className=
{
cn
(
'px-4 pb-4 pt-5'
,
!
isOpen
&&
'px-3'
)
}
>
className=
{
cn
(
'flex h-16 items-center border-b border-[#063e8e]/12 bg-white/80 px-4 backdrop-blur-sm'
,
!
isOpen
&&
'justify-center px-2.5'
,
)
}
>
<
Link
<
Link
href=
"/admin/dashboard"
href=
"/admin/base-config"
className=
{
cn
(
'flex min-w-0 items-center gap-3'
,
isOpen
&&
'justify-start'
)
}
>
<
div
className=
{
cn
(
className=
{
cn
(
'flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-[#063e8e]/12 bg-white p-1.5 shadow-sm'
,
'flex items-center backdrop-blur-sm'
,
!
isOpen
&&
'h-10 w-10'
,
isOpen
?
'gap-4 rounded-[28px] border border-white/80 bg-white/95 px-4 py-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]'
:
'justify-center px-0 py-4'
,
)
}
)
}
>
>
<
Image
<
div
className=
"flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] shadow-sm"
>
src=
{
logo
}
<
Image
src=
{
logo
}
alt=
"VCCI HCM"
className=
"h-10 w-10 object-contain"
priority
/>
alt=
"VCCI HCM"
className=
"h-full w-full object-contain"
priority
/>
</
div
>
</
div
>
{
isOpen
?
(
{
isOpen
?
(
<
div
className=
"min-w-0
leading-tight
"
>
<
div
className=
"min-w-0"
>
<
div
className=
"truncate text-
sm font-semibold uppercase tracking-[0.18
em] text-[#063e8e]"
>
<
div
className=
"truncate text-
[13px] font-bold uppercase tracking-[0.22
em] text-[#063e8e]"
>
VCCI News
VCCI News
</
div
>
</
div
>
<
div
className=
"mt-1 truncate text-[11px] text-gray-700"
>
<
div
className=
"mt-1 text-sm leading-5 text-slate-600"
>
Trang quản trị website
</
div
>
Trang quản trị website
</
div
>
</
div
>
</
div
>
)
:
null
}
)
:
null
}
</
Link
>
</
Link
>
</
div
>
</
div
>
<
nav
className=
{
cn
(
'flex-1 space-y-2 overflow-y-auto px-3 py-4'
,
!
isOpen
&&
'px-2'
)
}
>
<
div
className=
"px-4 pb-2"
>
{
isOpen
?
(
<
div
className=
"flex items-center gap-2 px-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500"
>
<
Sparkles
className=
"h-3.5 w-3.5 text-[#063e8e]"
/>
Điều hướng quản trị
</
div
>
)
:
null
}
</
div
>
<
nav
className=
{
cn
(
'scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2'
,
!
isOpen
&&
'px-3'
,
)
}
>
{
navigation
.
map
((
item
)
=>
{
{
navigation
.
map
((
item
)
=>
{
if
(
item
.
children
)
{
if
(
item
.
children
)
{
const
active
=
isGroupActive
(
item
.
children
);
const
active
=
isGroupActive
(
item
.
children
);
const
expanded
=
expandedGroups
[
item
.
name
]
??
activ
e
;
const
expanded
=
expandedGroups
[
item
.
name
]
??
fals
e
;
return
(
return
(
<
div
key=
{
item
.
name
}
className=
"space-y-2"
>
<
div
key=
{
item
.
name
}
className=
{
cn
(
'rounded-[26px] border border-transparent transition-all duration-200'
,
isOpen
&&
expanded
&&
'border-[#063e8e]/10 bg-white/70 p-2 shadow-sm'
,
)
}
>
<
button
<
button
type=
"button"
type=
"button"
onClick=
{
()
=>
isOpen
&&
toggleGroup
(
item
.
name
)
}
onClick=
{
()
=>
isOpen
&&
toggleGroup
(
item
.
name
)
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
className=
{
cn
(
className=
{
cn
(
'
group flex w-full items-center rounded-2xl px-3 py-3
text-sm font-medium transition-all duration-200'
,
'
flex w-full items-center rounded-2xl
text-sm font-medium transition-all duration-200'
,
active
active
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.1
6
)]'
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.1
8
)]'
:
'text-
gray-700 hover:bg-[#063e8e]/8
hover:text-[#063e8e]'
,
:
'text-
slate-700 hover:bg-white/85
hover:text-[#063e8e]'
,
!
isOpen
&&
'justify-center px
-0'
,
isOpen
?
'gap-3 px-4 py-3.5'
:
'mx-auto h-14 w-14 justify-center p
-0'
,
)
}
)
}
>
>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
{
isOpen
?
(
{
isOpen
?
(
<>
<>
<
span
className=
"m
l-3 truncate
text-left"
>
{
item
.
name
}
</
span
>
<
span
className=
"m
in-w-0 flex-1
text-left"
>
{
item
.
name
}
</
span
>
<
ChevronDown
<
ChevronDown
className=
{
cn
(
'ml-auto h-4 w-4 transition-transform'
,
expanded
&&
'rotate-180'
)
}
className=
{
cn
(
'h-4 w-4 shrink-0 transition-transform'
,
expanded
&&
'rotate-180'
,
)
}
/>
/>
</>
</>
)
:
null
}
)
:
null
}
</
button
>
</
button
>
{
isOpen
&&
expanded
?
(
{
isOpen
&&
expanded
?
(
<
div
className=
"m
l-4 space-y-1.5 border-l border-[#063e8e]/12
pl-4"
>
<
div
className=
"m
t-2 space-y-1.5 border-l border-[#d5e1f7]
pl-4"
>
{
item
.
children
.
map
((
child
)
=>
{
{
item
.
children
.
map
((
child
)
=>
{
const
childActive
=
isItemActive
(
child
.
href
);
const
childActive
=
isItemActive
(
child
.
href
);
...
@@ -152,13 +187,13 @@ export function AdminSidebar() {
...
@@ -152,13 +187,13 @@ export function AdminSidebar() {
key=
{
child
.
name
}
key=
{
child
.
name
}
href=
{
child
.
href
}
href=
{
child
.
href
}
className=
{
cn
(
className=
{
cn
(
'
block rounded-xl px-3 py-2.5 text-sm transition-colors
'
,
'
group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all
'
,
childActive
childActive
?
'bg-[#
063e8e]/10
font-semibold text-[#063e8e]'
?
'bg-[#
dbe8ff]
font-semibold text-[#063e8e]'
:
'text-
gray-700 hover:bg-[#063e8e]/6
hover:text-[#063e8e]'
,
:
'text-
slate-600 hover:bg-[#eef4ff]
hover:text-[#063e8e]'
,
)
}
)
}
>
>
{
child
.
name
}
<
span
className=
"block"
>
{
child
.
name
}
</
span
>
</
Link
>
</
Link
>
);
);
})
}
})
}
...
@@ -176,39 +211,46 @@ export function AdminSidebar() {
...
@@ -176,39 +211,46 @@ export function AdminSidebar() {
href=
{
item
.
href
||
'#'
}
href=
{
item
.
href
||
'#'
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
title=
{
!
isOpen
?
item
.
name
:
undefined
}
className=
{
cn
(
className=
{
cn
(
'
group flex items-center rounded-2xl px-3 py-3
text-sm font-medium transition-all duration-200'
,
'
flex items-center rounded-2xl
text-sm font-medium transition-all duration-200'
,
active
active
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.1
6
)]'
?
'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.1
8
)]'
:
'text-
gray-700 hover:bg-[#063e8e]/8
hover:text-[#063e8e]'
,
:
'text-
slate-700 hover:bg-white/85
hover:text-[#063e8e]'
,
!
isOpen
&&
'justify-center px
-0'
,
isOpen
?
'gap-3 px-4 py-3.5'
:
'mx-auto h-14 w-14 justify-center p
-0'
,
)
}
)
}
>
>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
<
item
.
icon
className=
"h-5 w-5 shrink-0"
/>
{
isOpen
?
<
span
className=
"m
l-3 truncate
"
>
{
item
.
name
}
</
span
>
:
null
}
{
isOpen
?
<
span
className=
"m
in-w-0 flex-1
"
>
{
item
.
name
}
</
span
>
:
null
}
</
Link
>
</
Link
>
);
);
})
}
})
}
</
nav
>
</
nav
>
<
div
className=
"
border-t border-[#063e8e]/12 bg-white/40 px-4 py-4 backdrop-blur-sm
"
>
<
div
className=
"
px-4 pb-5 pt-3
"
>
{
isOpen
?
(
{
isOpen
?
(
<
div
className=
"rounded-
2xl border border-[#063e8e]/10 bg-white px-4 py-3 shadow-sm
"
>
<
div
className=
"rounded-
[28px] border border-white/80 bg-white/95 p-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]
"
>
<
Link
<
Link
href=
"/"
href=
"/"
className=
"flex items-center gap-
2 text-sm font-medium text-[#063e8e] hover:underline
"
className=
"flex items-center gap-
3 text-sm font-semibold text-[#063e8e] transition hover:opacity-80
"
>
>
<
div
className=
"flex h-10 w-10 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]"
>
<
Globe
className=
"h-4 w-4"
/>
<
Globe
className=
"h-4 w-4"
/>
Về trang chủ
</
div
>
<
div
>
<
div
>
Về trang chủ
</
div
>
<
div
className=
"mt-0.5 text-xs font-medium text-slate-500"
>
Website công khai
</
div
>
</
div
>
</
Link
>
</
Link
>
<
div
className=
"mt-2 text-xs leading-5 text-gray-700"
>
© 2026 VCCI HCM
</
div
>
<
div
className=
"mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500"
>
© 2026 VCCI HCM
</
div
>
</
div
>
</
div
>
)
:
(
)
:
(
<
Link
<
Link
href=
"/"
href=
"/"
title=
"Về trang chủ"
title=
"Về trang chủ"
className=
"
flex justify-center rounded-2xl border border-[#063e8e]/10 bg-white py-3 text-[#063e8e] shadow-sm
"
className=
"
mx-auto flex h-14 w-14 items-center justify-center rounded-[22px] border border-white/80 bg-white/95 text-[#063e8e] shadow-sm transition hover:bg-white
"
>
>
<
Globe
className=
"h-
4 w-4
"
/>
<
Globe
className=
"h-
5 w-5
"
/>
</
Link
>
</
Link
>
)
}
)
}
</
div
>
</
div
>
...
...
src/components/ui/alert-dialog.tsx
View file @
051d8a8b
...
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
...
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<
AlertDialogPrimitive
.
Content
<
AlertDialogPrimitive
.
Content
ref=
{
ref
}
ref=
{
ref
}
className=
{
cn
(
className=
{
cn
(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4
border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg
"
,
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4
rounded-2xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-3xl
"
,
className
className
)
}
)
}
{
...
props
}
{
...
props
}
...
...
src/components/ui/dialog.tsx
View file @
051d8a8b
...
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
...
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<
DialogPrimitive
.
Content
<
DialogPrimitive
.
Content
ref=
{
ref
}
ref=
{
ref
}
className=
{
cn
(
className=
{
cn
(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4
border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg
"
,
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4
rounded-2xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-3xl
"
,
className
className
)
}
)
}
{
...
props
}
{
...
props
}
...
...
src/mockdata/admin-news.ts
View file @
051d8a8b
...
@@ -440,7 +440,7 @@ export function readAdminMediaItems() {
...
@@ -440,7 +440,7 @@ export function readAdminMediaItems() {
try
{
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
AdminMediaItem
[];
const
parsed
=
JSON
.
parse
(
raw
)
as
AdminMediaItem
[];
if
(
!
Array
.
isArray
(
parsed
)
||
parsed
.
length
===
0
)
{
if
(
!
Array
.
isArray
(
parsed
))
{
return
getAdminMediaSeed
();
return
getAdminMediaSeed
();
}
}
...
...
src/mockdata/base-config.ts
0 → 100644
View file @
051d8a8b
"use client"
;
import
type
{
AdminMediaItem
}
from
"@/mockdata/admin-news"
;
import
{
readAdminMediaItems
}
from
"@/mockdata/admin-news"
;
export
const
BASE_CONFIG_STORAGE_KEY
=
"vcci-news.admin-base-config.data.v1"
;
export
interface
BaseConfigLogoItem
{
id
:
string
;
name
:
string
;
imageId
:
string
;
isActive
:
boolean
;
}
export
interface
BaseConfigBannerItem
{
id
:
string
;
name
:
string
;
imageId
:
string
;
isActive
:
boolean
;
displayTimeSeconds
:
number
;
sortOrder
:
number
;
}
export
interface
BaseConfigBranchItem
{
id
:
string
;
branchName
:
string
;
address
:
string
;
hotline
:
string
;
email
:
string
;
fax
:
string
;
mapsEmbedUrl
:
string
;
}
export
interface
BaseConfigSocialItem
{
id
:
string
;
label
:
string
;
url
:
string
;
isVisible
:
boolean
;
sortOrder
:
number
;
}
export
interface
BaseConfigData
{
logo
:
BaseConfigLogoItem
|
null
;
banners
:
BaseConfigBannerItem
[];
websiteName
:
string
;
websiteLink
:
string
;
socials
:
BaseConfigSocialItem
[];
branches
:
BaseConfigBranchItem
[];
}
export
const
EMPTY_BASE_CONFIG_BRANCH
:
BaseConfigBranchItem
=
{
id
:
""
,
branchName
:
""
,
address
:
""
,
hotline
:
""
,
email
:
""
,
fax
:
""
,
mapsEmbedUrl
:
""
,
};
export
const
BASE_CONFIG_SOCIAL_SEED
:
BaseConfigSocialItem
[]
=
[
{
id
:
"facebook"
,
label
:
"Facebook"
,
url
:
""
,
isVisible
:
true
,
sortOrder
:
1
},
{
id
:
"zalo"
,
label
:
"Zalo"
,
url
:
""
,
isVisible
:
true
,
sortOrder
:
2
},
{
id
:
"twitter"
,
label
:
"Twitter"
,
url
:
""
,
isVisible
:
false
,
sortOrder
:
3
},
{
id
:
"youtube"
,
label
:
"Youtube"
,
url
:
""
,
isVisible
:
true
,
sortOrder
:
4
},
{
id
:
"linkedin"
,
label
:
"Linkedin"
,
url
:
""
,
isVisible
:
false
,
sortOrder
:
5
},
];
const
BASE_CONFIG_SEED
:
BaseConfigData
=
{
logo
:
{
id
:
"base-logo-001"
,
name
:
"Logo chính VCCI News"
,
imageId
:
"media-thumbnail"
,
isActive
:
true
,
},
websiteName
:
"VCCI News"
,
websiteLink
:
"https://vccinews.vn"
,
socials
:
[
{
id
:
"facebook"
,
label
:
"Facebook"
,
url
:
"https://facebook.com/vccinews"
,
isVisible
:
true
,
sortOrder
:
1
,
},
{
id
:
"zalo"
,
label
:
"Zalo"
,
url
:
"https://zalo.me/vccinews"
,
isVisible
:
true
,
sortOrder
:
2
,
},
{
id
:
"twitter"
,
label
:
"Twitter"
,
url
:
""
,
isVisible
:
false
,
sortOrder
:
3
,
},
{
id
:
"youtube"
,
label
:
"Youtube"
,
url
:
"https://youtube.com/@vccinews"
,
isVisible
:
true
,
sortOrder
:
4
,
},
{
id
:
"linkedin"
,
label
:
"Linkedin"
,
url
:
""
,
isVisible
:
false
,
sortOrder
:
5
,
},
],
banners
:
[
{
id
:
"base-banner-001"
,
name
:
"Banner trang chủ 01"
,
imageId
:
"media-banner"
,
isActive
:
true
,
displayTimeSeconds
:
5
,
sortOrder
:
1
,
},
{
id
:
"base-banner-002"
,
name
:
"Banner hoạt động hội viên"
,
imageId
:
"media-home-01"
,
isActive
:
true
,
displayTimeSeconds
:
5
,
sortOrder
:
2
,
},
{
id
:
"base-banner-003"
,
name
:
"Banner sự kiện nổi bật"
,
imageId
:
"media-home-02"
,
isActive
:
false
,
displayTimeSeconds
:
5
,
sortOrder
:
3
,
},
],
branches
:
[
{
id
:
"base-branch-001"
,
branchName
:
"Trụ sở chính VCCI News"
,
address
:
"171 Võ Thị Sáu, Phường Võ Thị Sáu, Quận 3, TP.HCM"
,
hotline
:
"028 3932 6598"
,
email
:
"info@vccinews.vn"
,
fax
:
"028 3932 5789"
,
mapsEmbedUrl
:
"https://maps.google.com/?q=171+Vo+Thi+Sau+Quan+3+TPHCM"
,
},
{
id
:
"base-branch-002"
,
branchName
:
"Chi nhánh Hà Nội"
,
address
:
"9 Đào Duy Anh, Phường Phương Mai, Quận Đống Đa, Hà Nội"
,
hotline
:
"024 3577 0632"
,
email
:
"hanoi@vccinews.vn"
,
fax
:
"024 3574 2020"
,
mapsEmbedUrl
:
"https://maps.google.com/?q=9+Dao+Duy+Anh+Dong+Da+Ha+Noi"
,
},
],
};
export
function
createBaseConfigItemId
(
prefix
:
"logo"
|
"banner"
|
"branch"
)
{
return
`base-
${
prefix
}
-
${
Date
.
now
()}
-
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
7
)}
`
;
}
export
function
cloneBaseConfigData
(
data
:
BaseConfigData
):
BaseConfigData
{
return
{
logo
:
data
.
logo
?
{
...
data
.
logo
}
:
null
,
banners
:
data
.
banners
.
map
((
item
)
=>
({
...
item
})),
websiteName
:
data
.
websiteName
,
websiteLink
:
data
.
websiteLink
,
socials
:
data
.
socials
.
map
((
item
)
=>
({
...
item
})),
branches
:
data
.
branches
.
map
((
item
)
=>
({
...
item
})),
};
}
export
function
readBaseConfig
():
BaseConfigData
{
if
(
typeof
window
===
"undefined"
)
return
cloneBaseConfigData
(
BASE_CONFIG_SEED
);
const
raw
=
window
.
localStorage
.
getItem
(
BASE_CONFIG_STORAGE_KEY
);
if
(
!
raw
)
return
cloneBaseConfigData
(
BASE_CONFIG_SEED
);
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
BaseConfigData
&
{
logos
?:
BaseConfigLogoItem
[];
contactInfo
?:
Record
<
string
,
string
>
;
};
if
(
!
parsed
||
typeof
parsed
!==
"object"
)
{
return
cloneBaseConfigData
(
BASE_CONFIG_SEED
);
}
const
fallbackBranchFromLegacyContact
=
parsed
.
contactInfo
&&
typeof
parsed
.
contactInfo
===
"object"
?
[
{
id
:
createBaseConfigItemId
(
"branch"
),
branchName
:
parsed
.
contactInfo
.
officeName
||
"Chi nhánh mặc định"
,
address
:
parsed
.
contactInfo
.
address
||
""
,
hotline
:
parsed
.
contactInfo
.
hotline
||
""
,
email
:
parsed
.
contactInfo
.
email
||
""
,
fax
:
parsed
.
contactInfo
.
fax
||
""
,
mapsEmbedUrl
:
parsed
.
contactInfo
.
mapsEmbedUrl
||
""
,
},
]
:
BASE_CONFIG_SEED
.
branches
;
return
{
logo
:
parsed
.
logo
&&
typeof
parsed
.
logo
===
"object"
?
{
...
parsed
.
logo
}
:
Array
.
isArray
(
parsed
.
logos
)
&&
parsed
.
logos
[
0
]
?
{
...
parsed
.
logos
[
0
]
}
:
BASE_CONFIG_SEED
.
logo
?
{
...
BASE_CONFIG_SEED
.
logo
}
:
null
,
banners
:
Array
.
isArray
(
parsed
.
banners
)
?
parsed
.
banners
.
map
((
item
,
index
)
=>
({
...
item
,
sortOrder
:
typeof
(
item
as
BaseConfigBannerItem
&
{
sortOrder
?:
number
}).
sortOrder
===
"number"
?
(
item
as
BaseConfigBannerItem
&
{
sortOrder
?:
number
}).
sortOrder
??
index
+
1
:
index
+
1
,
}))
:
[],
websiteName
:
typeof
(
parsed
as
BaseConfigData
&
{
websiteName
?:
string
}).
websiteName
===
"string"
?
(
parsed
as
BaseConfigData
&
{
websiteName
?:
string
}).
websiteName
??
""
:
BASE_CONFIG_SEED
.
websiteName
,
websiteLink
:
typeof
(
parsed
as
BaseConfigData
&
{
websiteLink
?:
string
}).
websiteLink
===
"string"
?
(
parsed
as
BaseConfigData
&
{
websiteLink
?:
string
}).
websiteLink
??
""
:
BASE_CONFIG_SEED
.
websiteLink
,
socials
:
Array
.
isArray
((
parsed
as
BaseConfigData
&
{
socials
?:
BaseConfigSocialItem
[]
}).
socials
)
?
BASE_CONFIG_SOCIAL_SEED
.
map
((
seedItem
,
index
)
=>
{
const
matchedItem
=
(
(
parsed
as
BaseConfigData
&
{
socials
?:
BaseConfigSocialItem
[]
}).
socials
??
[]
).
find
((
item
)
=>
item
?.
id
===
seedItem
.
id
);
return
{
...
seedItem
,
...
matchedItem
,
url
:
typeof
matchedItem
?.
url
===
"string"
?
matchedItem
.
url
:
seedItem
.
url
,
isVisible
:
typeof
matchedItem
?.
isVisible
===
"boolean"
?
matchedItem
.
isVisible
:
seedItem
.
isVisible
,
sortOrder
:
typeof
matchedItem
?.
sortOrder
===
"number"
?
matchedItem
.
sortOrder
:
index
+
1
,
};
})
:
BASE_CONFIG_SOCIAL_SEED
.
map
((
item
)
=>
({
...
item
})),
branches
:
Array
.
isArray
(
parsed
.
branches
)
?
parsed
.
branches
.
map
((
item
)
=>
({
...
EMPTY_BASE_CONFIG_BRANCH
,
...
item
,
fax
:
typeof
(
item
as
BaseConfigBranchItem
&
{
fax
?:
string
}).
fax
===
"string"
?
(
item
as
BaseConfigBranchItem
&
{
fax
?:
string
}).
fax
??
""
:
""
,
}))
:
fallbackBranchFromLegacyContact
,
};
}
catch
{
return
cloneBaseConfigData
(
BASE_CONFIG_SEED
);
}
}
export
function
persistBaseConfig
(
data
:
BaseConfigData
):
void
{
if
(
typeof
window
===
"undefined"
)
return
;
window
.
localStorage
.
setItem
(
BASE_CONFIG_STORAGE_KEY
,
JSON
.
stringify
(
data
));
}
export
function
getMediaMap
(
items
?:
AdminMediaItem
[])
{
const
mediaItems
=
items
??
readAdminMediaItems
();
return
new
Map
(
mediaItems
.
map
((
item
)
=>
[
item
.
id
,
item
]));
}
export
function
sortBaseConfigBanners
(
items
:
BaseConfigBannerItem
[])
{
return
[...
items
].
sort
((
first
,
second
)
=>
{
if
(
first
.
sortOrder
!==
second
.
sortOrder
)
return
first
.
sortOrder
-
second
.
sortOrder
;
return
first
.
name
.
localeCompare
(
second
.
name
,
"vi"
);
});
}
export
function
sortBaseConfigSocials
(
items
:
BaseConfigSocialItem
[])
{
return
[...
items
].
sort
((
first
,
second
)
=>
{
if
(
first
.
sortOrder
!==
second
.
sortOrder
)
return
first
.
sortOrder
-
second
.
sortOrder
;
return
first
.
label
.
localeCompare
(
second
.
label
,
"vi"
);
});
}
src/mockdata/contact-management.ts
0 → 100644
View file @
051d8a8b
"use client"
;
export
const
NEWSLETTER_SUBSCRIPTIONS_STORAGE_KEY
=
"vcci-news.admin-contact-management.newsletter-subscriptions.v1"
;
export
const
CONTACT_REQUESTS_STORAGE_KEY
=
"vcci-news.admin-contact-management.contact-requests.v1"
;
export
const
MEMBERSHIP_APPLICATIONS_STORAGE_KEY
=
"vcci-news.admin-contact-management.membership-applications.v1"
;
export
const
CONTACT_PURPOSE_OPTIONS
=
[
"Hội viên VCCI"
,
"Xuất xứ hàng hóa C/O"
,
"Xúc tiến thương mại"
,
"Quảng cáo"
,
"Mục đích khác"
,
]
as
const
;
export
type
ContactPurpose
=
(
typeof
CONTACT_PURPOSE_OPTIONS
)[
number
];
export
interface
NewsletterSubscriptionItem
{
id
:
string
;
email
:
string
;
submittedAt
:
string
;
}
export
interface
ContactRequestItem
{
id
:
string
;
purpose
:
ContactPurpose
;
contactName
:
string
;
contactPosition
:
string
;
contactEmail
:
string
;
contactPhone
:
string
;
message
:
string
;
organizationName
:
string
;
businessField
:
string
;
email
:
string
;
website
:
string
;
submittedAt
:
string
;
}
export
interface
MembershipApplicationItem
{
id
:
string
;
organizationName
:
string
;
membershipType
:
string
;
contactName
:
string
;
contactPosition
:
string
;
contactEmail
:
string
;
contactPhone
:
string
;
address
:
string
;
businessField
:
string
;
website
:
string
;
note
:
string
;
submittedAt
:
string
;
}
const
NEWSLETTER_SUBSCRIPTION_SEED
:
NewsletterSubscriptionItem
[]
=
[
{
id
:
"newsletter-001"
,
email
:
"banthuongtruc@saigreen.vn"
,
submittedAt
:
"2026-05-10T08:15:00+07:00"
,
},
{
id
:
"newsletter-002"
,
email
:
"marketing@thienphuoclogistics.vn"
,
submittedAt
:
"2026-05-10T14:45:00+07:00"
,
},
{
id
:
"newsletter-003"
,
email
:
"ceo@mekongfoods.com.vn"
,
submittedAt
:
"2026-05-11T09:22:00+07:00"
,
},
{
id
:
"newsletter-004"
,
email
:
"office@vinalinktech.vn"
,
submittedAt
:
"2026-05-11T16:05:00+07:00"
,
},
];
const
CONTACT_REQUEST_SEED
:
ContactRequestItem
[]
=
[
{
id
:
"contact-001"
,
purpose
:
"Hội viên VCCI"
,
contactName
:
"Nguyễn Thị Hồng Nhung"
,
contactPosition
:
"Trưởng phòng đối ngoại"
,
contactEmail
:
"nhung.nguyen@daianholdings.vn"
,
contactPhone
:
"0903456781"
,
message
:
"Chúng tôi cần được tư vấn về điều kiện tham gia mạng lưới hội viên và quy trình cập nhật hồ sơ doanh nghiệp trên cổng thông tin."
,
organizationName
:
"Công ty Cổ phần Đại An Holdings"
,
businessField
:
"Đầu tư thương mại và dịch vụ"
,
email
:
"contact@daianholdings.vn"
,
website
:
"https://daianholdings.vn"
,
submittedAt
:
"2026-05-10T10:18:00+07:00"
,
},
{
id
:
"contact-002"
,
purpose
:
"Xuất xứ hàng hóa C/O"
,
contactName
:
"Trần Quốc Bảo"
,
contactPosition
:
"Chuyên viên xuất nhập khẩu"
,
contactEmail
:
"bao.tran@saigonexport.vn"
,
contactPhone
:
"0911223344"
,
message
:
"Doanh nghiệp cần hướng dẫn hồ sơ xin cấp C/O cho lô hàng xuất khẩu sang thị trường EU trong tháng này."
,
organizationName
:
"Công ty TNHH Saigon Export Hub"
,
businessField
:
"Xuất nhập khẩu hàng tiêu dùng"
,
email
:
"info@saigonexport.vn"
,
website
:
"https://saigonexport.vn"
,
submittedAt
:
"2026-05-10T15:42:00+07:00"
,
},
{
id
:
"contact-003"
,
purpose
:
"Xúc tiến thương mại"
,
contactName
:
"Phạm Minh Quân"
,
contactPosition
:
"Giám đốc kinh doanh"
,
contactEmail
:
"quan.pham@newhorizon.vn"
,
contactPhone
:
"0988112233"
,
message
:
"Đề nghị kết nối doanh nghiệp với các chương trình xúc tiến thương mại tại Nhật Bản và Hàn Quốc trong quý III."
,
organizationName
:
"New Horizon Manufacturing"
,
businessField
:
"Sản xuất công nghiệp hỗ trợ"
,
email
:
"sales@newhorizon.vn"
,
website
:
"https://newhorizon.vn"
,
submittedAt
:
"2026-05-11T08:35:00+07:00"
,
},
{
id
:
"contact-004"
,
purpose
:
"Quảng cáo"
,
contactName
:
"Lê Diễm My"
,
contactPosition
:
"Marketing Manager"
,
contactEmail
:
"my.le@bluepeakmedia.vn"
,
contactPhone
:
"0933778899"
,
message
:
"Chúng tôi muốn tìm hiểu gói quảng cáo banner và bài PR trên chuyên trang VCCI News trong tháng 6."
,
organizationName
:
"Blue Peak Media"
,
businessField
:
"Truyền thông và quảng cáo"
,
email
:
"hello@bluepeakmedia.vn"
,
website
:
"https://bluepeakmedia.vn"
,
submittedAt
:
"2026-05-11T11:10:00+07:00"
,
},
{
id
:
"contact-005"
,
purpose
:
"Mục đích khác"
,
contactName
:
"Đỗ Thanh Bình"
,
contactPosition
:
"Phó tổng giám đốc"
,
contactEmail
:
"binh.do@greenriver.org.vn"
,
contactPhone
:
"0977554433"
,
message
:
"Mong muốn làm việc với ban biên tập để chia sẻ thông tin về chương trình đào tạo ESG dành cho doanh nghiệp hội viên."
,
organizationName
:
"Green River Advisory"
,
businessField
:
"Tư vấn phát triển bền vững"
,
email
:
"office@greenriver.org.vn"
,
website
:
"https://greenriver.org.vn"
,
submittedAt
:
"2026-05-11T13:50:00+07:00"
,
},
];
const
MEMBERSHIP_APPLICATION_SEED
:
MembershipApplicationItem
[]
=
[
{
id
:
"member-app-001"
,
organizationName
:
"Công ty Cổ phần Công nghệ Vạn Phúc"
,
membershipType
:
"Hội viên chính thức"
,
contactName
:
"Ngô Hoàng Long"
,
contactPosition
:
"Giám đốc điều hành"
,
contactEmail
:
"long.ngo@vanphuctech.vn"
,
contactPhone
:
"0909988776"
,
address
:
"25 Nguyễn Thị Minh Khai, Quận 1, TP.HCM"
,
businessField
:
"Công nghệ thông tin và chuyển đổi số"
,
website
:
"https://vanphuctech.vn"
,
note
:
"Doanh nghiệp mong muốn tham gia để kết nối đối tác và nhận thông tin các chương trình xúc tiến thương mại."
,
submittedAt
:
"2026-05-09T16:30:00+07:00"
,
},
{
id
:
"member-app-002"
,
organizationName
:
"Công ty TNHH Thực phẩm An Khang"
,
membershipType
:
"Hội viên liên kết"
,
contactName
:
"Đặng Khánh Linh"
,
contactPosition
:
"Trưởng phòng hành chính"
,
contactEmail
:
"linh.dang@ankhangfoods.vn"
,
contactPhone
:
"0912345670"
,
address
:
"18 Võ Văn Kiệt, Quận Ninh Kiều, Cần Thơ"
,
businessField
:
"Sản xuất và phân phối thực phẩm"
,
website
:
"https://ankhangfoods.vn"
,
note
:
"Cần hỗ trợ tìm hiểu quyền lợi hội viên và các đầu mối phụ trách khu vực miền Tây."
,
submittedAt
:
"2026-05-10T09:05:00+07:00"
,
},
{
id
:
"member-app-003"
,
organizationName
:
"Công ty Cổ phần Logistics Ánh Dương"
,
membershipType
:
"Hội viên chính thức"
,
contactName
:
"Phan Gia Huy"
,
contactPosition
:
"Giám đốc phát triển thị trường"
,
contactEmail
:
"huy.phan@anhduonglogistics.vn"
,
contactPhone
:
"0987445566"
,
address
:
"99 Điện Biên Phủ, Quận Hải Châu, Đà Nẵng"
,
businessField
:
"Logistics và kho vận"
,
website
:
"https://anhduonglogistics.vn"
,
note
:
"Quan tâm đến các chương trình kết nối doanh nghiệp và hoạt động cộng đồng của VCCI."
,
submittedAt
:
"2026-05-11T10:25:00+07:00"
,
},
];
function
readStorage
<
T
>
(
storageKey
:
string
,
seed
:
T
[]):
T
[]
{
if
(
typeof
window
===
"undefined"
)
return
seed
;
const
raw
=
window
.
localStorage
.
getItem
(
storageKey
);
if
(
!
raw
)
return
seed
;
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
T
[];
return
Array
.
isArray
(
parsed
)
?
parsed
:
seed
;
}
catch
{
return
seed
;
}
}
function
persistStorage
<
T
>
(
storageKey
:
string
,
items
:
T
[]):
void
{
if
(
typeof
window
===
"undefined"
)
return
;
window
.
localStorage
.
setItem
(
storageKey
,
JSON
.
stringify
(
items
));
}
export
function
readNewsletterSubscriptions
():
NewsletterSubscriptionItem
[]
{
return
readStorage
(
NEWSLETTER_SUBSCRIPTIONS_STORAGE_KEY
,
NEWSLETTER_SUBSCRIPTION_SEED
);
}
export
function
persistNewsletterSubscriptions
(
items
:
NewsletterSubscriptionItem
[]):
void
{
persistStorage
(
NEWSLETTER_SUBSCRIPTIONS_STORAGE_KEY
,
items
);
}
export
function
readContactRequests
():
ContactRequestItem
[]
{
return
readStorage
(
CONTACT_REQUESTS_STORAGE_KEY
,
CONTACT_REQUEST_SEED
);
}
export
function
persistContactRequests
(
items
:
ContactRequestItem
[]):
void
{
persistStorage
(
CONTACT_REQUESTS_STORAGE_KEY
,
items
);
}
export
function
readMembershipApplications
():
MembershipApplicationItem
[]
{
return
readStorage
(
MEMBERSHIP_APPLICATIONS_STORAGE_KEY
,
MEMBERSHIP_APPLICATION_SEED
);
}
export
function
persistMembershipApplications
(
items
:
MembershipApplicationItem
[]):
void
{
persistStorage
(
MEMBERSHIP_APPLICATIONS_STORAGE_KEY
,
items
);
}
src/mockdata/members.ts
View file @
051d8a8b
...
@@ -29,6 +29,12 @@ export interface MemberImageRef {
...
@@ -29,6 +29,12 @@ export interface MemberImageRef {
url
:
string
;
url
:
string
;
}
}
export
interface
MemberSocialItem
{
id
:
string
;
label
:
string
;
url
:
string
;
}
import
type
{
AdminNewsContentSection
}
from
"@/mockdata/admin-news"
;
import
type
{
AdminNewsContentSection
}
from
"@/mockdata/admin-news"
;
export
interface
MemberItem
{
export
interface
MemberItem
{
...
@@ -43,6 +49,7 @@ export interface MemberItem {
...
@@ -43,6 +49,7 @@ export interface MemberItem {
fax
:
string
;
fax
:
string
;
email
:
string
;
email
:
string
;
website
:
string
;
website
:
string
;
socials
:
MemberSocialItem
[];
introduction
:
AdminNewsContentSection
[];
introduction
:
AdminNewsContentSection
[];
created_at
:
string
;
created_at
:
string
;
updated_at
:
string
;
updated_at
:
string
;
...
@@ -60,6 +67,7 @@ export interface MemberFormValues {
...
@@ -60,6 +67,7 @@ export interface MemberFormValues {
fax
:
string
;
fax
:
string
;
email
:
string
;
email
:
string
;
website
:
string
;
website
:
string
;
socials
:
MemberSocialItem
[];
introduction
:
AdminNewsContentSection
[];
introduction
:
AdminNewsContentSection
[];
}
}
...
@@ -99,6 +107,14 @@ const SEED_REGIONS: MemberRegion[] = [
...
@@ -99,6 +107,14 @@ const SEED_REGIONS: MemberRegion[] = [
{
id
:
"region-5"
,
name
:
"Bình Dương"
},
{
id
:
"region-5"
,
name
:
"Bình Dương"
},
];
];
const
MEMBER_SOCIAL_SEED
:
MemberSocialItem
[]
=
[
{
id
:
"facebook"
,
label
:
"Facebook"
,
url
:
""
},
{
id
:
"zalo"
,
label
:
"Zalo"
,
url
:
""
},
{
id
:
"twitter"
,
label
:
"Twitter"
,
url
:
""
},
{
id
:
"youtube"
,
label
:
"Youtube"
,
url
:
""
},
{
id
:
"linkedin"
,
label
:
"Linkedin"
,
url
:
""
},
];
const
SEED_MEMBERS
:
MemberItem
[]
=
[
const
SEED_MEMBERS
:
MemberItem
[]
=
[
{
{
id
:
"member-1"
,
id
:
"member-1"
,
...
@@ -112,12 +128,35 @@ const SEED_MEMBERS: MemberItem[] = [
...
@@ -112,12 +128,35 @@ const SEED_MEMBERS: MemberItem[] = [
fax
:
"028 1234 5679"
,
fax
:
"028 1234 5679"
,
email
:
"contact@abc.vn"
,
email
:
"contact@abc.vn"
,
website
:
"https://abc.vn"
,
website
:
"https://abc.vn"
,
socials
:
[
{
id
:
"facebook"
,
label
:
"Facebook"
,
url
:
"https://facebook.com/abc"
},
{
id
:
"zalo"
,
label
:
"Zalo"
,
url
:
"https://zalo.me/abc"
},
{
id
:
"twitter"
,
label
:
"Twitter"
,
url
:
""
},
{
id
:
"youtube"
,
label
:
"Youtube"
,
url
:
""
},
{
id
:
"linkedin"
,
label
:
"Linkedin"
,
url
:
"https://linkedin.com/company/abc"
},
],
introduction
:
[],
introduction
:
[],
created_at
:
new
Date
().
toISOString
(),
created_at
:
new
Date
().
toISOString
(),
updated_at
:
new
Date
().
toISOString
(),
updated_at
:
new
Date
().
toISOString
(),
},
},
];
];
export
function
getMemberSocialSeed
():
MemberSocialItem
[]
{
return
MEMBER_SOCIAL_SEED
.
map
((
item
)
=>
({
...
item
}));
}
function
normalizeMemberSocials
(
socials
?:
MemberSocialItem
[]):
MemberSocialItem
[]
{
return
MEMBER_SOCIAL_SEED
.
map
((
seedItem
)
=>
{
const
matchedItem
=
socials
?.
find
((
item
)
=>
item
?.
id
===
seedItem
.
id
);
return
{
...
seedItem
,
...
matchedItem
,
url
:
typeof
matchedItem
?.
url
===
"string"
?
matchedItem
.
url
:
seedItem
.
url
,
};
});
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Field helpers
// Field helpers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
...
@@ -182,7 +221,12 @@ export function readMembers(): MemberItem[] {
...
@@ -182,7 +221,12 @@ export function readMembers(): MemberItem[] {
if
(
!
raw
)
return
getMemberSeed
();
if
(
!
raw
)
return
getMemberSeed
();
try
{
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
MemberItem
[];
const
parsed
=
JSON
.
parse
(
raw
)
as
MemberItem
[];
return
Array
.
isArray
(
parsed
)
&&
parsed
.
length
>
0
?
parsed
:
getMemberSeed
();
return
Array
.
isArray
(
parsed
)
&&
parsed
.
length
>
0
?
parsed
.
map
((
item
)
=>
({
...
item
,
socials
:
normalizeMemberSocials
(
item
.
socials
),
}))
:
getMemberSeed
();
}
catch
{
}
catch
{
return
getMemberSeed
();
return
getMemberSeed
();
}
}
...
@@ -206,6 +250,7 @@ export function cloneMemberFormValues(item: MemberItem): MemberFormValues {
...
@@ -206,6 +250,7 @@ export function cloneMemberFormValues(item: MemberItem): MemberFormValues {
fax
:
item
.
fax
,
fax
:
item
.
fax
,
email
:
item
.
email
,
email
:
item
.
email
,
website
:
item
.
website
,
website
:
item
.
website
,
socials
:
normalizeMemberSocials
(
item
.
socials
),
introduction
:
item
.
introduction
.
map
((
section
)
=>
({
introduction
:
item
.
introduction
.
map
((
section
)
=>
({
...
section
,
...
section
,
images
:
section
.
images
.
map
((
img
)
=>
({
...
img
})),
images
:
section
.
images
.
map
((
img
)
=>
({
...
img
})),
...
@@ -224,5 +269,6 @@ export const EMPTY_MEMBER_FORM: MemberFormValues = {
...
@@ -224,5 +269,6 @@ export const EMPTY_MEMBER_FORM: MemberFormValues = {
fax
:
""
,
fax
:
""
,
email
:
""
,
email
:
""
,
website
:
""
,
website
:
""
,
socials
:
getMemberSocialSeed
(),
introduction
:
[],
introduction
:
[],
};
};
src/styles/_components.css
View file @
051d8a8b
...
@@ -15,7 +15,9 @@
...
@@ -15,7 +15,9 @@
/* Scrollbar */
/* Scrollbar */
.scrollbar
{
.scrollbar
{
@apply
[&::-webkit-scrollbar]:w-2
[&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-thumb]:bg-neutral-400/60
hover
:[
&
::
-webkit-scrollbar-thumb
]:
bg-neutral-400
[
&
::
-webkit-scrollbar-track
]:
bg-transparent
;
scrollbar-color
:
rgba
(
6
,
62
,
142
,
0.38
)
rgba
(
219
,
232
,
255
,
0.38
);
scrollbar-width
:
thin
;
@apply
[&::-webkit-scrollbar]:h-2.5
[&::-webkit-scrollbar]:w-2.5
[&::-webkit-scrollbar-corner]:bg-transparent
[&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-thumb]:border-[2px]
[&::-webkit-scrollbar-thumb]:border-transparent
[&::-webkit-scrollbar-thumb]:bg-[#063e8e]/35
[&::-webkit-scrollbar-thumb]:bg-clip-padding
hover
:[
&
::
-webkit-scrollbar-thumb
]:
bg-
[
#063e8e
]/
50
[
&
::
-webkit-scrollbar-track
]:
rounded-full
[
&
::
-webkit-scrollbar-track
]:
bg-
[
#dbe8ff
]/
55
;
}
}
.list-screen-filter-container
{
.list-screen-filter-container
{
...
...
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