Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
V
vcci-news-frontend
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-frontend
Commits
d0f45809
Commit
d0f45809
authored
Sep 29, 2026
by
Phạm Quang Bảo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
optimize
parent
bb758888
Changes
34
Show whitespace changes
Inline
Side-by-side
Showing
34 changed files
with
1798 additions
and
1272 deletions
+1798
-1272
advertisement-list.tsx
src/app/admin/advertisements/advertisement-list.tsx
+0
-2
index.tsx
src/app/admin/base-config/_components/BannerTab/index.tsx
+17
-11
index.tsx
src/app/admin/base-config/_components/ContactTab/index.tsx
+15
-10
index.tsx
src/app/admin/base-config/_components/SocialTab/index.tsx
+52
-46
page.tsx
src/app/admin/contact-management/contact-requests/page.tsx
+95
-75
page.tsx
...admin/contact-management/membership-applications/page.tsx
+108
-61
page.tsx
src/app/admin/dashboard/page.tsx
+297
-168
page.tsx
src/app/admin/header-config/[categoryId]/page.tsx
+84
-95
header-category-form-dialog.tsx
.../header-config/components/header-category-form-dialog.tsx
+5
-5
header-category-table.tsx
.../admin/header-config/components/header-category-table.tsx
+2
-4
page.tsx
src/app/admin/header-config/page.tsx
+33
-28
media-form-dialog.tsx
src/app/admin/media/_components/media-form-dialog.tsx
+2
-2
page.tsx
src/app/admin/media/page.tsx
+2
-2
member-form.tsx
src/app/admin/members/[id]/_components/member-form.tsx
+236
-214
member-filters.tsx
src/app/admin/members/_components/member-filters.tsx
+6
-7
member-row.tsx
src/app/admin/members/_components/member-row.tsx
+15
-18
field-form-dialog.tsx
...pp/admin/members/fields/_components/field-form-dialog.tsx
+11
-10
page.tsx
src/app/admin/members/fields/page.tsx
+69
-45
page.tsx
src/app/admin/members/page.tsx
+105
-69
region-form-dialog.tsx
.../admin/members/regions/_components/region-form-dialog.tsx
+11
-10
page.tsx
src/app/admin/members/regions/page.tsx
+69
-45
admin-news-form-content.tsx
...p/admin/news/[id]/_components/admin-news-form-content.tsx
+205
-187
utils.ts
src/app/admin/news/_components/utils.ts
+5
-7
page.tsx
src/app/admin/news/page.tsx
+4
-6
page.tsx
src/app/admin/password-reset-requests/page.tsx
+32
-36
UserTable.tsx
src/app/admin/users/_components/UserTable.tsx
+40
-44
image-picker.tsx
src/components/admin/image-picker.tsx
+42
-55
header.tsx
src/components/layout/main/header.tsx
+3
-5
fallback-sidebar-ad-item.tsx
...ar-advertisements/components/fallback-sidebar-ad-item.tsx
+2
-2
sidebar-ad-item.tsx
...red/sidebar-advertisements/components/sidebar-ad-item.tsx
+2
-3
admin-contact.ts
src/lib/utils/admin-contact.ts
+50
-0
admin-member.ts
src/lib/utils/admin-member.ts
+59
-0
admin-news.ts
src/lib/utils/admin-news.ts
+101
-0
header-category.ts
src/lib/utils/header-category.ts
+19
-0
No files found.
src/app/admin/advertisements/advertisement-list.tsx
View file @
d0f45809
...
...
@@ -374,7 +374,6 @@ export function AdvertisementList({
alt=
{
ad
.
alt
||
ad
.
name
}
fill
className=
"object-cover"
unoptimized=
{
isGif
}
/>
)
:
(
<
div
className=
"flex h-full w-full items-center justify-center"
>
...
...
@@ -519,7 +518,6 @@ export function AdvertisementList({
alt=
"Preview"
fill
className=
"object-cover"
unoptimized=
{
form
.
filePreviewUrl
.
toLowerCase
().
endsWith
(
".gif"
)
}
/>
)
:
(
<
div
className=
"flex h-full w-full items-center justify-center"
>
...
...
src/app/admin/base-config/_components/BannerTab/index.tsx
View file @
d0f45809
...
...
@@ -247,6 +247,11 @@ export function BannerTab() {
</
Button
>
</
div
>
</
div
>
{
banners
.
length
===
0
?
(
<
div
className=
"py-12 text-center text-sm text-gray-500"
>
Không có data banner trang chủ
</
div
>
)
:
(
<
div
className=
"grid gap-4 sm:grid-cols-2 xl:grid-cols-3"
>
{
banners
.
map
((
item
,
index
)
=>
(
<
ConfigItemPreview
...
...
@@ -258,6 +263,7 @@ export function BannerTab() {
/>
))
}
</
div
>
)
}
{
current
?
(
<
div
className=
"grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
<
div
>
...
...
src/app/admin/base-config/_components/ContactTab/index.tsx
View file @
d0f45809
...
...
@@ -66,7 +66,6 @@ export function ContactTab() {
);
useEffect
(()
=>
{
if
(
!
apiBranches
.
length
)
return
;
const
next
=
apiBranches
.
map
(
mapApiBranchToConfig
);
setBranches
(
next
);
setCurrentId
((
previous
)
=>
...
...
@@ -193,7 +192,12 @@ export function ContactTab() {
<
div
className=
"text-sm font-semibold uppercase text-[#4b74b8]"
>
Danh sách chi nhánh
</
div
>
{
branches
.
map
((
branch
)
=>
(
{
branches
.
length
===
0
?
(
<
div
className=
"py-8 text-center text-sm text-slate-500"
>
Không có data chi nhánh
</
div
>
)
:
(
branches
.
map
((
branch
)
=>
(
<
BranchCard
key=
{
branch
.
id
}
branch=
{
branch
}
...
...
@@ -201,7 +205,8 @@ export function ContactTab() {
onSelect=
{
()
=>
setCurrentId
(
branch
.
id
)
}
onDelete=
{
()
=>
remove
(
branch
.
id
)
}
/>
))
}
))
)
}
</
div
>
<
div
className=
"space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
{
current
?
(
...
...
src/app/admin/base-config/_components/SocialTab/index.tsx
View file @
d0f45809
...
...
@@ -97,7 +97,12 @@ export function SocialTab() {
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-4 px-4 sm:px-6"
>
{
socials
.
map
((
item
)
=>
(
{
socials
.
length
===
0
?
(
<
div
className=
"py-12 text-center text-sm text-gray-500"
>
Không có data mạng xã hội
</
div
>
)
:
(
socials
.
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
...
...
@@ -146,7 +151,8 @@ export function SocialTab() {
</
div
>
</
div
>
</
div
>
))
}
))
)
}
</
CardContent
>
</
Card
>
</
TabsContent
>
...
...
src/app/admin/contact-management/contact-requests/page.tsx
View file @
d0f45809
...
...
@@ -2,7 +2,6 @@
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
{
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
...
...
@@ -24,12 +23,15 @@ import {
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useGetApiV10Contact
,
useDeleteApiV10ContactId
}
from
"@/api/vcci-news/endpoints/contact"
;
import
{
CONTACT_PURPOSE_OPTIONS
,
type
ContactRequestItem
,
persistContactRequests
,
readContactRequests
,
}
from
"@/mockdata/contact-management"
;
type
AdminContactRequestRow
,
extractRows
,
mapApiRowToAdminContactRequest
,
}
from
"@/lib/utils/admin-contact"
;
import
{
useMemo
,
useState
}
from
"react"
;
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]"
;
...
...
@@ -38,23 +40,35 @@ 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
)
{
if
(
!
value
)
return
"—"
;
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
);
const
queryClient
=
useQueryClient
();
React
.
useEffect
(()
=>
{
setItems
(
readContactRequests
()
);
setReady
(
true
);
},
[]
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
purposeFilter
,
setPurposeFilter
]
=
useState
(
"all"
);
const
[
detailTarget
,
setDetailTarget
]
=
useState
<
AdminContactRequestRow
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
AdminContactRequestRow
|
null
>
(
null
);
const
filteredItems
=
React
.
useMemo
(()
=>
{
const
contactsQuery
=
useGetApiV10Contact
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
deleteContact
=
useDeleteApiV10ContactId
();
const
items
=
useMemo
(
()
=>
extractRows
((
contactsQuery
.
data
as
any
)?.
responseData
).
map
(
mapApiRowToAdminContactRequest
,
),
[
contactsQuery
.
data
],
);
const
filteredItems
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
return
items
.
filter
((
item
)
=>
{
...
...
@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() {
});
},
[
items
,
purposeFilter
,
search
]);
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
nextItems
);
persistContactRequests
(
nextItems
);
try
{
await
deleteContact
.
mutateAsync
({
id
:
deleteTarget
.
id
});
toast
.
success
(
"Đã xóa đơn liên hệ"
);
queryClient
.
invalidateQueries
({
queryKey
:
[
"getApiV10Contact"
]
});
}
catch
(
error
:
unknown
)
{
const
message
=
error
instanceof
Error
?
error
.
message
:
"Không thể xóa đơn liên hệ"
;
toast
.
error
(
message
);
}
finally
{
setDeleteTarget
(
null
);
}
};
return
(
...
...
@@ -127,7 +147,7 @@ export default function AdminContactRequestsPage() {
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
{
contactsQuery
.
isLoading
?
(
Array
.
from
({
length
:
4
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
...
...
@@ -152,25 +172,25 @@ export default function AdminContactRequestsPage() {
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
item
.
purpose
}
{
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
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
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
>
{
item
.
email
||
"—"
}
</
div
>
<
div
className=
"text-gray-500"
>
{
item
.
contactEmail
||
"—"
}
</
div
>
</
div
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
...
...
@@ -199,7 +219,7 @@ export default function AdminContactRequestsPage() {
badge=
{
detailTarget
?
(
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
detailTarget
.
purpose
}
{
detailTarget
.
purpose
||
"—"
}
</
Badge
>
)
:
null
}
...
...
@@ -209,27 +229,27 @@ export default function AdminContactRequestsPage() {
{
title
:
"Thông tin chung"
,
fields
:
[
{
label
:
"Mục đích liên hệ"
,
value
:
detailTarget
.
purpose
},
{
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
},
{
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
},
{
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
||
"—"
},
],
},
]
...
...
src/app/admin/contact-management/membership-applications/page.tsx
View file @
d0f45809
...
...
@@ -2,7 +2,6 @@
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
{
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
...
...
@@ -17,29 +16,72 @@ import {
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
MembershipApplicationItem
,
persistMembershipApplications
,
readMembershipApplications
,
}
from
"@/mockdata/contact-management"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useGetApiV10Contact
,
useDeleteApiV10ContactId
}
from
"@/api/vcci-news/endpoints/contact"
;
import
{
extractRows
}
from
"@/lib/utils/admin-contact"
;
import
{
useMemo
,
useState
}
from
"react"
;
interface
AdminMembershipApplicationRow
{
id
:
string
;
organizationName
:
string
;
businessField
:
string
;
address
:
string
;
website
:
string
;
contactName
:
string
;
contactPosition
:
string
;
contactEmail
:
string
;
contactPhone
:
string
;
membershipType
:
string
;
note
:
string
;
submittedAt
:
string
;
}
function
mapApiRowToMembershipApplication
(
item
:
any
):
AdminMembershipApplicationRow
{
return
{
id
:
String
(
item
?.
id
??
""
),
organizationName
:
item
?.
fullname
??
""
,
businessField
:
""
,
address
:
""
,
website
:
""
,
contactName
:
item
?.
fullname
??
""
,
contactPosition
:
""
,
contactEmail
:
item
?.
email
??
""
,
contactPhone
:
item
?.
phone
??
""
,
membershipType
:
item
?.
title
??
""
,
note
:
item
?.
content
??
""
,
submittedAt
:
item
?.
created_at
??
""
,
};
}
function
formatDateTime
(
value
:
string
)
{
if
(
!
value
)
return
"—"
;
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
);
const
queryClient
=
useQueryClient
();
React
.
useEffect
(()
=>
{
setItems
(
readMembershipApplications
());
setReady
(
true
);
},
[]);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
detailTarget
,
setDetailTarget
]
=
useState
<
AdminMembershipApplicationRow
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
AdminMembershipApplicationRow
|
null
>
(
null
);
const
filteredItems
=
React
.
useMemo
(()
=>
{
const
contactsQuery
=
useGetApiV10Contact
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
deleteContact
=
useDeleteApiV10ContactId
();
const
items
=
useMemo
(
()
=>
extractRows
((
contactsQuery
.
data
as
any
)?.
responseData
).
map
(
mapApiRowToMembershipApplication
,
),
[
contactsQuery
.
data
],
);
const
filteredItems
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
if
(
!
keyword
)
return
items
;
...
...
@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() {
);
},
[
items
,
search
]);
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
nextItems
);
persistMembershipApplications
(
nextItems
);
try
{
await
deleteContact
.
mutateAsync
({
id
:
deleteTarget
.
id
});
toast
.
success
(
"Đã xóa đơn đăng ký hội viên"
);
queryClient
.
invalidateQueries
({
queryKey
:
[
"getApiV10Contact"
]
});
}
catch
(
error
:
unknown
)
{
const
message
=
error
instanceof
Error
?
error
.
message
:
"Không thể xóa đơn đăng ký hội viên"
;
toast
.
error
(
message
);
}
finally
{
setDeleteTarget
(
null
);
}
};
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm đơn đăng ký hội viên..."
...
...
@@ -91,7 +138,7 @@ export default function AdminMembershipApplicationsPage() {
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
{
contactsQuery
.
isLoading
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
...
...
@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() {
</
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
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
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
}
{
item
.
membershipType
||
"—"
}
</
Badge
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
{
item
.
contactEmail
}
</
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
>
...
...
@@ -158,11 +205,11 @@ export default function AdminMembershipApplicationsPage() {
<
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
."
description=
"
Thông tin đầy đủ của đơn đăng ký hội viên được gửi từ website VCCI News
."
badge=
{
detailTarget
?
(
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
detailTarget
.
membershipType
}
{
detailTarget
.
membershipType
||
"—"
}
</
Badge
>
)
:
null
}
...
...
@@ -172,27 +219,27 @@ export default function AdminMembershipApplicationsPage() {
{
title
:
"Thông tin chung"
,
fields
:
[
{
label
:
"Loại hội viên"
,
value
:
detailTarget
.
membershipType
},
{
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
},
{
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
},
{
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
},
],
},
]
...
...
src/app/admin/dashboard/page.tsx
View file @
d0f45809
"use client"
;
import
*
as
React
from
"react"
;
import
Link
from
"next/link"
;
import
dayjs
from
"dayjs"
;
import
{
...
...
@@ -16,49 +15,44 @@ import {
Sparkles
,
Users
,
}
from
"lucide-react"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"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"
;
useGetApiV10Post
,
useGetApiV10PostId
,
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
useGetApiV10File
,
useGetApiV10FileId
}
from
"@/api/vcci-news/endpoints/file"
;
import
{
useGetApiV10Video
}
from
"@/api/vcci-news/endpoints/video"
;
import
{
useGetApiV10Member
}
from
"@/api/vcci-news/endpoints/member"
;
import
{
useGetApiV10Business
}
from
"@/api/vcci-news/endpoints/business"
;
import
{
useGetApiV10Position
}
from
"@/api/vcci-news/endpoints/position"
;
import
{
useGetApiV10Contact
}
from
"@/api/vcci-news/endpoints/contact"
;
import
{
useGetApiV10NewsletterSubscription
}
from
"@/api/vcci-news/endpoints/newsletter-subscription"
;
import
{
useGetApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
useGetApiV10Banner
}
from
"@/api/vcci-news/endpoints/banner"
;
import
{
useGetApiV10SiteInformation
}
from
"@/api/vcci-news/endpoints/site-information"
;
import
{
resolveCmsFileUrl
,
toAdminMediaItem
}
from
"@/lib/utils/file"
;
import
type
{
AdminMediaItem
}
from
"@/mockdata/admin-news"
;
import
{
ComponentType
,
useMemo
}
from
"react"
;
function
formatDateTime
(
value
:
string
)
{
if
(
!
value
)
return
"—"
;
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
}
function
extractRows
(
responseData
:
unknown
):
any
[]
{
if
(
!
responseData
||
typeof
responseData
!==
"object"
)
return
[];
const
rows
=
(
responseData
as
{
rows
?:
unknown
}).
rows
;
return
Array
.
isArray
(
rows
)
?
rows
:
[];
}
type
DashboardMetric
=
{
title
:
string
;
value
:
string
;
description
:
string
;
icon
:
React
.
ComponentType
<
{
className
?:
string
}
>
;
icon
:
ComponentType
<
{
className
?:
string
}
>
;
href
:
string
;
};
...
...
@@ -66,7 +60,7 @@ type DashboardShortcut = {
title
:
string
;
description
:
string
;
href
:
string
;
icon
:
React
.
ComponentType
<
{
className
?:
string
}
>
;
icon
:
ComponentType
<
{
className
?:
string
}
>
;
};
type
ActivityItem
=
{
...
...
@@ -78,47 +72,167 @@ type ActivityItem = {
href
:
string
;
};
interface
SpotlightPostItem
{
id
:
string
;
title
:
string
;
thumbnail_id
?:
string
|
null
;
updated_at
?:
string
|
null
;
created_at
?:
string
|
null
;
}
function
SpotlightThumbnail
({
fileId
}:
{
fileId
?:
string
|
null
})
{
const
{
data
}
=
useGetApiV10FileId
(
fileId
??
""
,
{
query
:
{
enabled
:
Boolean
(
fileId
)
},
});
const
file
=
(
data
as
any
)?.
responseData
??
(
data
as
any
)?.
data
?.
responseData
;
const
url
=
file
?.
path
?
resolveCmsFileUrl
(
file
.
path
)
:
null
;
if
(
!
url
)
{
return
(
<
div
className=
"flex h-full items-center justify-center text-[#063e8e]"
>
<
Newspaper
className=
"h-5 w-5"
/>
</
div
>
);
}
return
<
Image
src=
{
url
}
alt=
{
file
?.
original
??
"thumbnail"
}
fill
className=
"object-cover"
unoptimized
/>;
}
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
;
const
postsQuery
=
useGetApiV10Post
({
page
:
1
,
pageSize
:
200
,
sortField
:
"updated_at"
,
sortOrder
:
"desc"
,
});
const
filesQuery
=
useGetApiV10File
({
page
:
1
,
pageSize
:
200
});
const
videosQuery
=
useGetApiV10Video
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
membersQuery
=
useGetApiV10Member
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
businessesQuery
=
useGetApiV10Business
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
positionsQuery
=
useGetApiV10Position
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
contactsQuery
=
useGetApiV10Contact
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
newsletterQuery
=
useGetApiV10NewsletterSubscription
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
categoryTreeQuery
=
useGetApiV10CategoryTree
();
const
bannersQuery
=
useGetApiV10Banner
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
siteInfoQuery
=
useGetApiV10SiteInformation
();
const
newsItems
=
useMemo
(
()
=>
extractRows
((
postsQuery
.
data
as
any
)?.
responseData
),
[
postsQuery
.
data
],
);
const
mediaItems
=
useMemo
<
AdminMediaItem
[]
>
(
()
=>
extractRows
((
filesQuery
.
data
as
any
)?.
responseData
).
map
(
toAdminMediaItem
),
[
filesQuery
.
data
],
);
const
videos
=
useMemo
(
()
=>
extractRows
((
videosQuery
.
data
as
any
)?.
responseData
),
[
videosQuery
.
data
],
);
const
members
=
useMemo
(
()
=>
extractRows
((
membersQuery
.
data
as
any
)?.
responseData
),
[
membersQuery
.
data
],
);
const
businesses
=
useMemo
(
()
=>
extractRows
((
businessesQuery
.
data
as
any
)?.
responseData
),
[
businessesQuery
.
data
],
);
const
positions
=
useMemo
(
()
=>
extractRows
((
positionsQuery
.
data
as
any
)?.
responseData
),
[
positionsQuery
.
data
],
);
const
contactRequests
=
useMemo
(
()
=>
extractRows
((
contactsQuery
.
data
as
any
)?.
responseData
),
[
contactsQuery
.
data
],
);
const
newsletterItems
=
useMemo
(
()
=>
extractRows
((
newsletterQuery
.
data
as
any
)?.
responseData
),
[
newsletterQuery
.
data
],
);
const
banners
=
useMemo
(
()
=>
extractRows
((
bannersQuery
.
data
as
any
)?.
responseData
),
[
bannersQuery
.
data
],
);
const
categoryTree
=
useMemo
(
()
=>
{
const
raw
=
(
categoryTreeQuery
.
data
as
any
)?.
responseData
;
return
Array
.
isArray
(
raw
)
?
raw
:
[];
},
[
categoryTreeQuery
.
data
],
);
const
headerCategoryCount
=
useMemo
(()
=>
{
const
walk
=
(
nodes
:
any
[]):
number
=>
nodes
.
reduce
(
(
total
,
node
)
=>
total
+
1
+
walk
(
Array
.
isArray
(
node
?.
children
)
?
node
.
children
:
[]),
0
,
);
return
walk
(
categoryTree
);
},
[
categoryTree
]);
const
siteInfo
=
useMemo
(
()
=>
(
siteInfoQuery
.
data
as
any
)?.
responseData
??
(
siteInfoQuery
.
data
as
any
)?.
data
?.
responseData
,
[
siteInfoQuery
.
data
],
);
const
isLoading
=
postsQuery
.
isLoading
||
filesQuery
.
isLoading
||
videosQuery
.
isLoading
||
membersQuery
.
isLoading
||
contactsQuery
.
isLoading
;
const
metrics
=
useMemo
<
DashboardMetric
[]
>
(()
=>
{
const
totalContactForms
=
newsletterItems
.
length
+
contactRequests
.
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
`
,
value
:
String
(
newsItems
.
length
),
description
:
`
${
newsItems
.
length
}
bài
viết đang quản lý
`
,
icon
:
Newspaper
,
href
:
"/admin/news"
,
},
...
...
@@ -130,9 +244,9 @@ export default function AdminDashboardPage() {
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ý
`
,
title
:
"Hội viên"
,
value
:
String
(
members
.
length
),
description
:
`
${
members
.
length
}
hội viên,
${
businesses
.
length
}
doanh nghiệp,
${
positions
.
length
}
chức vụ
`
,
icon
:
Users
,
href
:
"/admin/members"
,
},
...
...
@@ -145,17 +259,17 @@ export default function AdminDashboardPage() {
},
];
},
[
businesses
.
length
,
contactRequests
.
length
,
headerPosts
.
length
,
mediaItems
.
length
,
members
.
length
,
membershipApplications
.
length
,
newsletterItems
.
length
,
newsItems
.
length
,
positions
.
length
,
videos
.
length
,
]);
const
shortcuts
=
React
.
useMemo
<
DashboardShortcut
[]
>
(
const
shortcuts
=
useMemo
<
DashboardShortcut
[]
>
(
()
=>
[
{
title
:
"Cấu hình chung"
,
...
...
@@ -185,13 +299,13 @@ export default function AdminDashboardPage() {
[],
);
const
recentActivities
=
React
.
useMemo
<
ActivityItem
[]
>
(()
=>
{
const
recentActivities
=
useMemo
<
ActivityItem
[]
>
(()
=>
{
const
items
:
ActivityItem
[]
=
[
...
newsItems
.
map
((
item
)
=>
({
...
newsItems
.
map
((
item
:
any
)
=>
({
id
:
`news-
${
item
.
id
}
`
,
title
:
item
.
title
,
title
:
item
.
title
??
""
,
description
:
"Cập nhật trong Quản lý bài viết"
,
time
:
item
.
updated_at
||
item
.
created_at
,
time
:
item
.
updated_at
||
item
.
created_at
||
""
,
badge
:
"Bài viết"
,
href
:
"/admin/news"
,
})),
...
...
@@ -203,40 +317,40 @@ export default function AdminDashboardPage() {
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
)
=>
({
...
contactRequests
.
map
((
item
:
any
)
=>
({
id
:
`contact-
${
item
.
id
}
`
,
title
:
item
.
contactName
,
description
:
item
.
purpose
,
time
:
item
.
submittedAt
,
title
:
item
.
fullname
??
""
,
description
:
item
.
title
??
"Đơn liên hệ từ website"
,
time
:
item
.
created_at
??
""
,
badge
:
"Liên hệ"
,
href
:
"/admin/contact-management/contact-requests"
,
})),
];
return
items
.
filter
((
item
)
=>
item
.
id
)
.
sort
((
left
,
right
)
=>
dayjs
(
right
.
time
).
valueOf
()
-
dayjs
(
left
.
time
).
valueOf
())
.
slice
(
0
,
6
);
},
[
contactRequests
,
mediaItems
,
membershipApplications
,
newsItems
]);
},
[
contactRequests
,
mediaItems
,
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
spotlightNews
=
useMemo
<
SpotlightPostItem
[]
>
(
()
=>
newsItems
.
slice
(
0
,
3
).
map
((
item
:
any
)
=>
({
id
:
String
(
item
?.
id
??
""
),
title
:
item
?.
title
??
""
,
thumbnail_id
:
item
?.
thumbnail_id
??
null
,
updated_at
:
item
?.
updated_at
??
null
,
created_at
:
item
?.
created_at
??
null
,
})),
[
newsItems
],
);
const
activeBanners
=
React
.
useMemo
(
()
=>
baseConfig
.
banners
.
filter
((
item
)
=>
item
.
isActive
).
sort
((
a
,
b
)
=>
a
.
sortOrder
-
b
.
sortOrder
),
[
baseConfig
.
banners
],
const
activeBanners
=
useMemo
(
()
=>
banners
.
filter
((
item
:
any
)
=>
item
?.
status
===
"ACTIVE"
||
item
?.
status
===
1
),
[
banners
],
);
if
(
!
ready
)
{
if
(
isLoading
)
{
return
(
<
div
className=
"grid gap-5 lg:grid-cols-4"
>
{
Array
.
from
({
length
:
8
}).
map
((
_
,
index
)
=>
(
...
...
@@ -284,7 +398,7 @@ export default function AdminDashboardPage() {
>
<
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
}
{
contactRequests
.
length
}
</
div
>
<
div
className=
"mt-1 text-sm text-slate-500"
>
đơn đang cần theo dõi
</
div
>
</
Link
>
...
...
@@ -303,8 +417,12 @@ export default function AdminDashboardPage() {
<
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
className=
"mt-2 text-lg font-semibold text-[#163b73]"
>
{
siteInfo
?.
website_name
||
"Chưa cấu hình"
}
</
div
>
<
div
className=
"mt-1 truncate text-sm text-slate-500"
>
{
siteInfo
?.
website_link
||
"—"
}
</
div
>
</
div
>
<
div
className=
"grid gap-3 sm:grid-cols-2"
>
...
...
@@ -313,16 +431,18 @@ export default function AdminDashboardPage() {
<
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
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Danh mục menu header
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
headerCategoryCount
}
</
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=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Liên hệ
</
div
>
<
div
className=
"mt-2 text-lg font-semibold text-[#163b73]"
>
{
siteInfo
?.
hotline
||
"Chưa cấu hình hotline"
}
</
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
"
}
{
siteInfo
?.
email
||
siteInfo
?.
address
||
"—
"
}
</
div
>
</
div
>
</
CardContent
>
...
...
@@ -366,7 +486,12 @@ export default function AdminDashboardPage() {
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-3"
>
{
recentActivities
.
map
((
item
)
=>
(
{
recentActivities
.
length
===
0
?
(
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-6 text-center text-sm text-slate-500"
>
Không có dữ liệu hoạt động
</
div
>
)
:
(
recentActivities
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
href
}
...
...
@@ -380,13 +505,14 @@ export default function AdminDashboardPage() {
<
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
}
{
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
>
...
...
@@ -431,34 +557,31 @@ export default function AdminDashboardPage() {
</
div
>
</
CardHeader
>
<
CardContent
className=
"space-y-4"
>
{
spotlightNews
.
map
((
item
)
=>
(
{
spotlightNews
.
length
===
0
?
(
<
div
className=
"rounded-[24px] border border-[#063e8e]/10 bg-white p-6 text-center text-sm text-slate-500"
>
Không có dữ liệu bài viết
</
div
>
)
:
(
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
?
(
<
SafeImage
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
>
)
}
<
SpotlightThumbnail
fileId=
{
item
.
thumbnail_id
}
/>
</
div
>
<
div
className=
"min-w-0"
>
<
div
className=
"line-clamp-2 text-sm font-semibold text-[#163b73]"
>
{
item
.
title
}
</
div
>
<
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
)
}
{
formatDateTime
(
item
.
updated_at
||
item
.
created_at
||
""
)
}
</
div
>
</
div
>
</
Link
>
))
}
))
)
}
</
CardContent
>
</
Card
>
...
...
@@ -473,12 +596,12 @@ export default function AdminDashboardPage() {
<
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]"
>
{
headerCategor
ies
.
length
}
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
headerCategor
yCount
}
</
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]"
>
{
headerPost
s
.
length
}
</
div
>
<
div
className=
"text-xs uppercase tracking-[0.14em] text-slate-400"
>
Bài
viết
</
div
>
<
div
className=
"mt-2 text-2xl font-semibold text-[#163b73]"
>
{
newsItem
s
.
length
}
</
div
>
<
div
className=
"mt-1 text-sm text-slate-500"
>
bản ghi nội dung
</
div
>
</
div
>
</
div
>
...
...
@@ -491,16 +614,22 @@ export default function AdminDashboardPage() {
</
Badge
>
</
div
>
<
div
className=
"mt-4 grid grid-cols-3 gap-3"
>
{
mediaItems
.
slice
(
0
,
3
).
map
((
item
)
=>
(
{
mediaItems
.
length
===
0
?
(
<
div
className=
"col-span-3 py-6 text-center text-sm text-slate-500"
>
Không có dữ liệu ảnh
</
div
>
)
:
(
mediaItems
.
slice
(
0
,
3
).
map
((
item
)
=>
(
<
div
key=
{
item
.
id
}
className=
"relative aspect-square overflow-hidden rounded-2xl bg-[#eef4ff]"
>
<
Safe
Image
<
Image
src=
{
item
.
url
}
alt=
{
item
.
alt
||
item
.
name
}
fill
className=
"object-cover"
/>
</
div
>
))
}
))
)
}
</
div
>
</
div
>
</
CardContent
>
...
...
@@ -516,9 +645,9 @@ export default function AdminDashboardPage() {
<
CardContent
className=
"space-y-3"
>
{
[
{
label
:
"Video"
,
value
:
videos
.
length
,
icon
:
MonitorPlay
},
{
label
:
"
Lĩnh vực hội viên"
,
value
:
memberField
s
.
length
,
icon
:
FolderTree
},
{
label
:
"
Khu vực hội viên"
,
value
:
memberReg
ions
.
length
,
icon
:
MapPin
},
{
label
:
"
Chi nhánh liên hệ"
,
value
:
baseConfig
.
branche
s
.
length
,
icon
:
Globe
},
{
label
:
"
Doanh nghiệp hội viên"
,
value
:
businesse
s
.
length
,
icon
:
FolderTree
},
{
label
:
"
Chức vụ hội viên"
,
value
:
posit
ions
.
length
,
icon
:
MapPin
},
{
label
:
"
Banner"
,
value
:
banner
s
.
length
,
icon
:
Globe
},
].
map
((
item
)
=>
(
<
div
key=
{
item
.
label
}
...
...
src/app/admin/header-config/[categoryId]/page.tsx
View file @
d0f45809
...
...
@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions";
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
...
...
@@ -26,10 +26,15 @@ import {
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
deleteApiV10PostId
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
getApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
ADMIN_NEWS_TYPE_LABELS
,
type
AdminNewsItem
}
from
"@/mockdata/admin-news"
;
import
{
useDeleteApiV10PostId
,
useGetApiV10Post
,
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
useGetApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
ADMIN_NEWS_TYPE_LABELS
,
type
AdminNewsItem
,
}
from
"@/api/vcci-news/types/post"
;
import
{
normalizeUser
}
from
"@/lib/utils/cms-user"
;
import
{
normalizeDateTimeInput
}
from
"@/lib/utils/datetime"
;
import
{
parsePostContent
,
parseLegacyPostContent
}
from
"@/lib/utils/post-content"
;
...
...
@@ -48,64 +53,37 @@ export default function HeaderCategoryPostsPage() {
const
pathname
=
usePathname
();
const
searchParams
=
useSearchParams
();
const
categoryId
=
String
(
params
.
categoryId
??
""
);
const
[
items
,
setItems
]
=
useState
<
AdminNewsItem
[]
>
([]
);
const
[
headerTree
,
setHeaderTree
]
=
useState
<
HeaderCategoryTreeItem
[]
>
([]
);
const
{
data
:
treeResponse
,
isLoading
:
isTreeLoading
}
=
useGetApiV10CategoryTree
(
);
const
{
mutateAsync
:
deletePost
,
isPending
:
isDeletingPost
}
=
useDeleteApiV10PostId
(
);
const
[
search
,
setSearch
]
=
useState
(()
=>
searchParams
.
get
(
"q"
)
??
""
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
AdminNewsItem
|
null
>
(
null
);
const
didMountRef
=
useRef
(
false
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(()
=>
{
const
parsedPage
=
Number
(
searchParams
.
get
(
"page"
)
??
1
);
return
Number
.
isFinite
(
parsedPage
)
&&
parsedPage
>
0
?
Math
.
floor
(
parsedPage
)
:
1
;
});
const
listQueryString
=
useMemo
(()
=>
{
const
nextParams
=
new
URLSearchParams
();
if
(
page
>
1
)
{
nextParams
.
set
(
"page"
,
String
(
page
));
}
if
(
search
.
trim
())
{
nextParams
.
set
(
"q"
,
search
.
trim
());
}
return
nextParams
.
toString
();
},
[
page
,
search
]);
const
listPath
=
useMemo
(
()
=>
(
listQueryString
?
`
${
pathname
}
?
${
listQueryString
}
`
:
pathname
),
[
listQueryString
,
pathname
],
);
useEffect
(()
=>
{
let
cancelled
=
false
;
const
load
=
async
()
=>
{
try
{
const
apiFilters
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
();
const
filters
=
[
`category.id==
${
categoryId
}
`
,
keyword
?
`title@=
${
keyword
}
|slug@=
${
keyword
}
`
:
""
,
].
filter
(
Boolean
).
join
(
","
);
return
filters
.
trim
()
||
undefined
;
},
[
categoryId
,
search
]);
const
[
newsResponse
,
treeResponse
]
=
await
Promise
.
all
([
getApiV10Post
({
const
postsQuery
=
useGetApiV10Post
({
page
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
filters
:
filters
.
trim
()
||
undefined
,
}),
getApiV10CategoryTree
(),
]);
if
(
cancelled
)
return
;
filters
:
apiFilters
,
});
const
newsResult
=
(
newsResponse
.
responseData
??
{})
as
{
rows
?:
any
[];
count
?:
number
};
const
tree
=
(
treeResponse
.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[];
setItems
((
newsResult
.
rows
??
[]).
map
((
item
:
any
):
AdminNewsItem
=>
{
const
postsResponse
=
(
postsQuery
.
data
??
{})
as
{
responseData
?:
{
rows
?:
any
[];
count
?:
number
}
};
const
items
:
AdminNewsItem
[]
=
useMemo
(
()
=>
(
postsResponse
.
responseData
?.
rows
??
[]).
map
((
item
:
any
):
AdminNewsItem
=>
{
const
structuredContent
=
parsePostContent
(
item
.
content_structure
);
const
postContent
=
structuredContent
.
length
>
0
?
structuredContent
:
parseLegacyPostContent
(
item
.
content
);
const
categories
=
Array
.
isArray
(
item
.
categories
)
?
item
.
categories
:
[];
...
...
@@ -142,23 +120,35 @@ export default function HeaderCategoryPostsPage() {
creator
:
normalizeUser
(
item
.
creator
),
editor
:
normalizeUser
(
item
.
editor
),
};
}));
setTotal
(
newsResult
.
count
??
0
);
setHeaderTree
(
tree
);
setReady
(
true
);
}
catch
(
error
)
{
if
(
cancelled
)
return
;
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải dữ liệu"
);
setReady
(
true
);
}),
[
postsResponse
.
responseData
],
);
const
total
=
postsResponse
.
responseData
?.
count
??
0
;
const
headerTree
:
HeaderCategoryTreeItem
[]
=
useMemo
(
()
=>
(
treeResponse
?.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[],
[
treeResponse
],
);
const
ready
=
!
isTreeLoading
&&
!
postsQuery
.
isLoading
;
const
listQueryString
=
useMemo
(()
=>
{
const
nextParams
=
new
URLSearchParams
();
if
(
page
>
1
)
{
nextParams
.
set
(
"page"
,
String
(
page
));
}
};
void
load
();
if
(
search
.
trim
())
{
nextParams
.
set
(
"q"
,
search
.
trim
());
}
return
()
=>
{
cancelled
=
true
;
};
},
[
categoryId
,
page
,
search
]);
return
nextParams
.
toString
();
},
[
page
,
search
]);
const
listPath
=
useMemo
(
()
=>
(
listQueryString
?
`
${
pathname
}
?
${
listQueryString
}
`
:
pathname
),
[
listQueryString
,
pathname
],
);
const
flatCategories
=
useMemo
(()
=>
{
return
flattenTree
(
headerTree
);
...
...
@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() {
},
[
items
,
total
]);
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
if
(
!
deleteTarget
||
isDeletingPost
)
return
;
try
{
await
deleteApiV10PostId
(
deleteTarget
.
id
);
setItems
((
current
)
=>
current
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
));
setTotal
((
current
)
=>
Math
.
max
(
0
,
current
-
1
));
await
deletePost
({
id
:
deleteTarget
.
id
});
toast
.
success
(
"Đã xóa bài viết"
);
setDeleteTarget
(
null
);
postsQuery
.
refetch
();
}
catch
(
error
)
{
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể xóa bài viết"
);
}
...
...
@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() {
<
TableCell
className=
"text-center"
>
<
div
className=
"relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]"
>
{
item
.
thumbnail
?
(
<
Safe
Image
<
Image
src=
{
item
.
thumbnail
.
url
}
alt=
{
item
.
thumbnail
.
alt
||
item
.
thumbnail
.
name
}
fill
...
...
src/app/admin/header-config/components/header-category-form-dialog.tsx
View file @
d0f45809
...
...
@@ -20,11 +20,11 @@ import {
SelectValue
,
}
from
"@/components/ui/select"
;
import
{
Textarea
}
from
"@/components/ui/textarea"
;
import
{
type
HeaderCategoryTreeItem
,
type
HeaderCategoryType
,
toSlug
,
}
from
"@/
mockdata
/header-config"
;
import
{
toSlug
}
from
"@/lib/utils/header-category"
;
import
type
{
HeaderCategoryTreeItem
,
HeaderCategoryType
,
}
from
"@/
api/vcci-news/types
/header-config"
;
export
type
HeaderCategoryFormMode
=
"create"
|
"edit"
;
...
...
src/app/admin/header-config/components/header-category-table.tsx
View file @
d0f45809
...
...
@@ -20,10 +20,8 @@ import {
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
HeaderCategoryTreeItem
,
getHeaderCategoryTypeLabel
,
}
from
"@/mockdata/header-config"
;
import
{
getHeaderCategoryTypeLabel
}
from
"@/lib/utils/header-category"
;
import
type
{
HeaderCategoryTreeItem
}
from
"@/api/vcci-news/types/header-config"
;
export
type
HeaderCategoryFlatRow
=
HeaderCategoryTreeItem
&
{
depth
:
number
;
...
...
src/app/admin/header-config/page.tsx
View file @
d0f45809
"use client"
;
import
React
,
{
useCallback
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useDeleteApiV10CategoryId
,
useGetApiV10CategoryTree
,
usePostApiV10Category
,
usePutApiV10CategoryId
,
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
toast
}
from
"sonner"
;
import
{
HeaderCategoryDeleteDialog
,
...
...
@@ -12,17 +18,11 @@ import {
HeaderCategoryTable
,
}
from
"./components"
;
import
{
buildStaticLink
}
from
"@/lib/utils/header-link"
;
import
{
deleteApiV10CategoryId
,
getApiV10CategoryTree
,
postApiV10Category
,
putApiV10CategoryId
,
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
import
{
toSlug
}
from
"@/lib/utils/header-category"
;
import
type
{
HeaderCategoryItem
,
HeaderCategoryTreeItem
,
toSlug
,
}
from
"@/mockdata/header-config"
;
}
from
"@/api/vcci-news/types/header-config"
;
const
EMPTY_HEADER_CATEGORY_FORM
:
HeaderCategoryFormValues
=
{
name
:
""
,
...
...
@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
}
function
useHeaderConfigModule
()
{
const
{
data
:
treeResponse
,
isLoading
,
error
,
refetch
:
refetchTree
,
}
=
useGetApiV10CategoryTree
();
const
[
tree
,
setTree
]
=
useState
<
HeaderCategoryTreeItem
[]
>
([]);
const
[
rootStaticLink
,
setRootStaticLink
]
=
useState
(
"/"
);
const
[
isReady
,
setIsReady
]
=
useState
(
false
);
const
load
=
useCallback
(
async
()
=>
{
const
response
=
await
getApiV10CategoryTree
();
const
treeData
=
(
response
.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[];
useEffect
(()
=>
{
const
treeData
=
(
treeResponse
?.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[];
setTree
(
treeData
);
setRootStaticLink
(
"/"
);
setIsReady
(
true
);
},
[]);
},
[
treeResponse
]);
useEffect
(()
=>
{
void
load
().
catch
((
error
)
=>
{
if
(
error
)
{
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải cấu hình danh mục"
);
setIsReady
(
true
);
});
},
[
load
]);
}
},
[
error
]);
return
{
tree
,
rootStaticLink
,
isReady
,
reload
:
load
,
isReady
:
!
isLoading
,
reload
:
refetchTree
,
};
}
export
default
function
HeaderConfigPage
()
{
const
{
tree
,
rootStaticLink
,
isReady
,
reload
}
=
useHeaderConfigModule
();
const
{
mutateAsync
:
createCategory
,
isPending
:
isCreatingCategory
}
=
usePostApiV10Category
();
const
{
mutateAsync
:
updateCategory
,
isPending
:
isUpdatingCategory
}
=
usePutApiV10CategoryId
();
const
{
mutateAsync
:
deleteCategory
,
isPending
:
isDeletingCategory
}
=
useDeleteApiV10CategoryId
();
const
isMutating
=
isCreatingCategory
||
isUpdatingCategory
||
isDeletingCategory
;
const
[
expanded
,
setExpanded
]
=
useState
<
Record
<
string
,
boolean
>>
({});
const
[
search
,
setSearch
]
=
useState
(
""
);
...
...
@@ -221,7 +226,7 @@ export default function HeaderConfigPage() {
};
const
handleSubmit
=
async
()
=>
{
if
(
isSubmitting
)
return
;
if
(
isSubmitting
||
isMutating
)
return
;
if
(
isProtectedHomeCategory
(
formValues
.
id
))
{
return
;
...
...
@@ -277,10 +282,10 @@ export default function HeaderConfigPage() {
};
if
(
formMode
===
"create"
)
{
await
postApiV10Category
(
payload
);
await
createCategory
({
data
:
payload
}
);
toast
.
success
(
"Tạo danh mục thành công"
);
}
else
if
(
formValues
.
id
)
{
await
putApiV10CategoryId
(
formValues
.
id
,
payload
);
await
updateCategory
({
id
:
formValues
.
id
,
data
:
payload
}
);
toast
.
success
(
"Cập nhật danh mục thành công"
);
}
...
...
@@ -295,7 +300,7 @@ export default function HeaderConfigPage() {
};
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
||
isSubmitting
)
return
;
if
(
!
deleteTarget
||
isSubmitting
||
isMutating
)
return
;
if
(
isProtectedHomeCategory
(
deleteTarget
.
id
))
{
setDeleteTarget
(
null
);
...
...
@@ -305,7 +310,7 @@ export default function HeaderConfigPage() {
setIsSubmitting
(
true
);
try
{
await
delete
ApiV10CategoryId
(
deleteTarget
.
id
);
await
delete
Category
({
id
:
deleteTarget
.
id
}
);
toast
.
success
(
"Xóa danh mục thành công"
);
setDeleteTarget
(
null
);
await
reload
();
...
...
src/app/admin/media/_components/media-form-dialog.tsx
View file @
d0f45809
...
...
@@ -8,7 +8,7 @@ import {
X
,
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Dialog
,
...
...
@@ -103,7 +103,7 @@ export function MediaFormDialog({
<
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
.
previewUrl
?
(
<
Safe
Image
<
Image
src=
{
form
.
previewUrl
}
alt=
{
form
.
name
}
fill
...
...
src/app/admin/media/page.tsx
View file @
d0f45809
...
...
@@ -9,7 +9,7 @@ import {
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
...
...
@@ -161,7 +161,7 @@ export default function AdminMediaPage() {
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%)]"
>
<
Safe
Image
<
Image
src=
{
resolveCmsFileUrl
(
item
.
path
)
}
alt=
{
item
.
original
??
""
}
fill
...
...
src/app/admin/members/[id]/_components/member-form.tsx
View file @
d0f45809
"use client"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
{
useEffect
,
use
Memo
,
use
State
}
from
"react"
;
import
{
ArrowLeft
,
Save
,
Upload
,
X
}
from
"lucide-react"
;
import
Link
from
"next/link"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminImagePicker
}
from
"@/components/admin/image-picker"
;
import
{
AdminPostContentEditor
}
from
"@/components/admin/post-content-editor"
;
import
{
SafeImage
}
from
"@/components/shared/safe-image"
;
import
Image
from
"next/image"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Switch
}
from
"@/components/ui/switch"
;
import
{
Select
,
SelectContent
,
...
...
@@ -19,24 +18,19 @@ import {
SelectTrigger
,
SelectValue
,
}
from
"@/components/ui/select"
;
import
type
{
AdminMediaItem
}
from
"@/mockdata/admin-news"
;
import
{
type
AdminMediaItem
,
type
AdminNewsContentSection
,
}
from
"@/mockdata/admin-news"
;
useGetApiV10MemberId
,
usePostApiV10Member
,
usePutApiV10MemberId
,
}
from
"@/api/vcci-news/endpoints/member"
;
import
{
useGetApiV10Business
}
from
"@/api/vcci-news/endpoints/business"
;
import
{
useGetApiV10Position
}
from
"@/api/vcci-news/endpoints/position"
;
import
{
type
MemberField
,
type
MemberFormValues
,
type
MemberImageRef
,
type
MemberItem
,
type
MemberRegion
,
type
AdminMemberFormValues
,
EMPTY_MEMBER_FORM
,
cloneMemberFormValues
,
createMemberId
,
persistMembers
,
readMemberFields
,
readMemberRegions
,
readMembers
,
}
from
"@/mockdata/members"
;
extractRows
,
}
from
"@/lib/utils/admin-member"
;
interface
AdminMemberFormProps
{
memberId
?:
string
;
...
...
@@ -53,112 +47,144 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const
selectItemClassName
=
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"
;
function
toImageRef
(
item
:
AdminMediaItem
):
MemberImageRef
{
return
{
id
:
item
.
id
,
name
:
item
.
name
,
alt
:
item
.
alt
,
url
:
item
.
url
};
}
export
function
AdminMemberForm
({
memberId
}:
AdminMemberFormProps
)
{
const
router
=
useRouter
();
const
queryClient
=
useQueryClient
();
const
isNew
=
!
memberId
||
memberId
===
"new"
;
const
[
form
,
setForm
]
=
useState
<
MemberFormValues
>
(
EMPTY_MEMBER_FORM
);
const
[
fields
,
setFields
]
=
useState
<
MemberField
[]
>
([]);
const
[
regions
,
setRegions
]
=
useState
<
MemberRegion
[]
>
([]);
const
[
form
,
setForm
]
=
useState
<
AdminMemberFormValues
>
(
EMPTY_MEMBER_FORM
);
const
[
imagePickerOpen
,
setImagePickerOpen
]
=
useState
(
false
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
[
notFound
,
setNotFound
]
=
useState
(
false
);
const
[
hydrated
,
setHydrated
]
=
useState
(
isNew
);
const
memberQuery
=
useGetApiV10MemberId
(
isNew
?
""
:
memberId
!
,
{
query
:
{
enabled
:
!
isNew
&&
!!
memberId
},
});
const
businessesQuery
=
useGetApiV10Business
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
positionsQuery
=
useGetApiV10Position
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
businesses
=
useMemo
(
()
=>
extractRows
((
businessesQuery
.
data
as
any
)?.
responseData
).
map
(
(
row
:
any
)
=>
({
id
:
String
(
row
?.
id
??
""
),
name
:
String
(
row
?.
name
??
""
),
}),
),
[
businessesQuery
.
data
],
);
const
positions
=
useMemo
(
()
=>
extractRows
((
positionsQuery
.
data
as
any
)?.
responseData
).
map
(
(
row
:
any
)
=>
({
id
:
String
(
row
?.
id
??
""
),
name
:
String
(
row
?.
name
??
""
),
}),
),
[
positionsQuery
.
data
],
);
const
createMember
=
usePostApiV10Member
();
const
updateMember
=
usePutApiV10MemberId
();
useEffect
(()
=>
{
const
allFields
=
readMemberFields
();
const
allRegions
=
readMemberRegions
();
setFields
(
allFields
);
setRegions
(
allRegions
);
if
(
!
isNew
)
{
const
all
=
readMembers
();
const
found
=
all
.
find
((
m
)
=>
m
.
id
===
memberId
);
if
(
found
)
{
setForm
(
cloneMemberFormValues
(
found
));
if
(
isNew
)
{
setForm
(
EMPTY_MEMBER_FORM
);
setHydrated
(
true
);
setNotFound
(
false
);
return
;
}
const
memberData
=
(
memberQuery
as
any
).
data
as
any
;
if
(
!
memberData
)
return
;
const
raw
=
memberData
?.
responseData
??
memberData
??
null
;
const
row
=
raw
&&
typeof
raw
===
"object"
&&
"rows"
in
raw
?
Array
.
isArray
(
raw
.
rows
)
?
raw
.
rows
[
0
]
:
null
:
raw
;
if
(
!
row
||
!
row
.
id
)
{
setNotFound
(
true
);
setHydrated
(
true
);
return
;
}
setReady
(
true
);
},
[
isNew
,
memberId
]);
const
set
=
<
K
extends
keyof
MemberFormValues
>
(key: K, value: MemberFormValues[K]) =
>
{
setForm
({
id
:
String
(
row
.
id
??
""
),
full_name
:
row
.
full_name
??
""
,
avatar_url
:
row
.
avatar_url
??
null
,
birth_date
:
row
.
birth_date
?
String
(
row
.
birth_date
).
slice
(
0
,
10
)
:
""
,
business_id
:
row
.
business_id
?
String
(
row
.
business_id
)
:
""
,
position_id
:
row
.
position_id
?
String
(
row
.
position_id
)
:
""
,
job_title
:
row
.
job_title
??
""
,
});
setNotFound
(
false
);
setHydrated
(
true
);
},
[
isNew
,
memberQuery
.
data
]);
const
set
=
<
K
extends
keyof
AdminMemberFormValues
>
(
key: K,
value: AdminMemberFormValues[K],
) =
>
{
setForm
((
previous
)
=>
({
...
previous
,
[
key
]:
value
}));
}
;
const handleImageSelect = (item: AdminMediaItem) =
>
{
set
(
"
image"
,
toImageRef
(
item
)
);
set
(
"
avatar_url"
,
item
.
url
);
setImagePickerOpen
(
false
);
}
;
const handleIntroductionChange = (sections: AdminNewsContentSection[]) =
>
{
set
(
"introduction"
,
sections
);
}
;
const handleSave = () =
>
{
if
(
!
form
.
name
.
trim
())
{
const handleSave = async () =
>
{
if
(
!
form
.
full_name
.
trim
())
{
toast
.
error
(
"Vui lòng nhập tên hội viên"
);
return
;
}
setSaving
(
true
);
try
{
const
now
=
new
Date
().
toISOString
();
const
all
=
readMembers
();
const
payload
=
{
full_name
:
form
.
full_name
.
trim
(),
avatar_url
:
form
.
avatar_url
||
null
,
birth_date
:
form
.
birth_date
?
form
.
birth_date
:
null
,
business_id
:
form
.
business_id
||
null
,
position_id
:
form
.
position_id
||
null
,
job_title
:
form
.
job_title
||
null
,
};
if
(
isNew
)
{
const
newItem
:
MemberItem
=
{
id
:
createMemberId
(),
name
:
form
.
name
.
trim
(),
is_featured
:
form
.
is_featured
,
image
:
form
.
image
,
region_id
:
form
.
region_id
,
field_id
:
form
.
field_id
,
address
:
form
.
address
,
phone
:
form
.
phone
,
fax
:
form
.
fax
,
email
:
form
.
email
,
website
:
form
.
website
,
introduction
:
form
.
introduction
,
created_at
:
now
,
updated_at
:
now
,
};
persistMembers
([...
all
,
newItem
]);
await
createMember
.
mutateAsync
({
data
:
payload
});
toast
.
success
(
"Đã thêm hội viên mới"
);
}
else
{
const
nextAll
=
all
.
map
((
m
)
=>
{
if
(
m
.
id
!==
memberId
)
return
m
;
return
{
...
m
,
name
:
form
.
name
.
trim
(),
is_featured
:
form
.
is_featured
,
image
:
form
.
image
,
region_id
:
form
.
region_id
,
field_id
:
form
.
field_id
,
address
:
form
.
address
,
phone
:
form
.
phone
,
fax
:
form
.
fax
,
email
:
form
.
email
,
website
:
form
.
website
,
introduction
:
form
.
introduction
,
updated_at
:
now
,
}
satisfies
MemberItem
;
});
persistMembers
(
nextAll
);
await
updateMember
.
mutateAsync
({
id
:
memberId
!
,
data
:
payload
});
toast
.
success
(
"Đã lưu thay đổi"
);
}
queryClient
.
invalidateQueries
({
queryKey
:
[
"getApiV10Member"
]
});
router
.
push
(
"/admin/members"
);
}
catch
(
error
:
unknown
)
{
const
message
=
error
instanceof
Error
?
error
.
message
:
"Không thể lưu hội viên"
;
toast
.
error
(
message
);
}
finally
{
setSaving
(
false
);
}
}
;
if (!
ready
)
{
if (!
hydrated || (!isNew
&&
memberQuery.isLoading)
)
{
return
(
<
div
className=
"flex h-64 items-center justify-center"
>
<
div
className=
"h-8 w-8 animate-spin rounded-full border-4 border-[#063e8e] border-t-transparent"
/>
...
...
@@ -166,12 +192,44 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
);
}
if (notFound)
{
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"flex items-center gap-4"
>
<
Link
href=
"/admin/members"
>
<
Button
type=
"button"
variant=
"outline"
size=
"icon"
className=
"border-[#063e8e]/15"
>
<
ArrowLeft
className=
"h-4 w-4"
/>
</
Button
>
</
Link
>
<
div
>
<
h1
className=
"text-2xl font-bold text-[#063e8e]"
>
Hội viên
</
h1
>
</
div
>
</
div
>
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-white px-6 py-12 text-center"
>
<
p
className=
"text-sm text-gray-700"
>
Không tìm thấy hội viên này hoặc dữ liệu không tồn tại.
</
p
>
</
div
>
</
div
>
);
}
return (
<
div
className=
"space-y-8"
>
{
/* Header */
}
<
div
className=
"flex items-center gap-4"
>
<
Link
href=
"/admin/members"
>
<
Button
type=
"button"
variant=
"outline"
size=
"icon"
className=
"border-[#063e8e]/15"
>
<
Button
type=
"button"
variant=
"outline"
size=
"icon"
className=
"border-[#063e8e]/15"
>
<
ArrowLeft
className=
"h-4 w-4"
/>
</
Button
>
</
Link
>
...
...
@@ -180,7 +238,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
{
isNew
?
"Thêm hội viên mới"
:
"Chỉnh sửa hội viên"
}
</
h1
>
<
p
className=
"text-sm text-gray-500"
>
{
isNew
?
"Điền thông tin hội viên mới"
:
`Chỉnh sửa: ${form.name}`
}
{
isNew
?
"Điền thông tin hội viên mới"
:
`Chỉnh sửa: ${form.
full_
name}`
}
</
p
>
</
div
>
</
div
>
...
...
@@ -189,7 +247,9 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
{
/* Main info */
}
<
div
className=
"space-y-6 xl:col-span-2"
>
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"
>
<
h2
className=
"mb-4 text-base font-semibold text-[#063e8e]"
>
Thông tin cơ bản
</
h2
>
<
h2
className=
"mb-4 text-base font-semibold text-[#063e8e]"
>
Thông tin cơ bản
</
h2
>
<
div
className=
"space-y-4"
>
{
/* Name */
}
<
div
className=
"space-y-1.5"
>
...
...
@@ -197,198 +257,161 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
Tên hội viên
<
span
className=
"text-red-500"
>
*
</
span
>
</
Label
>
<
Input
value=
{
form
.
name
}
onChange=
{
(
e
)
=>
set
(
"name"
,
e
.
target
.
value
)
}
value=
{
form
.
full_
name
}
onChange=
{
(
e
)
=>
set
(
"
full_
name"
,
e
.
target
.
value
)
}
placeholder=
"Nhập tên hội viên..."
className=
{
fieldClassName
}
/>
</
div
>
{
/*
Region & Field
*/
}
{
/*
Business & Position
*/
}
<
div
className=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Khu vực
</
Label
>
<
Select
value=
{
form
.
region_id
}
onValueChange=
{
(
v
)
=>
set
(
"region_id"
,
v
)
}
>
<
Label
className=
"text-gray-700"
>
Doanh nghiệp
</
Label
>
<
Select
value=
{
form
.
business_id
||
undefined
}
onValueChange=
{
(
v
)
=>
set
(
"business_id"
,
v
)
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectValue
placeholder=
"Chọn
khu vực
"
/>
<
SelectValue
placeholder=
"Chọn
doanh nghiệp
"
/>
</
SelectTrigger
>
<
SelectContent
className=
{
selectContentClassName
}
>
{
regions
.
map
((
r
)
=>
(
<
SelectItem
key=
{
r
.
id
}
value=
{
r
.
id
}
className=
{
selectItemClassName
}
>
{
r
.
name
}
{
businesses
.
length
===
0
?
(
<
div
className=
"px-3 py-6 text-center text-sm text-gray-700"
>
Không có dữ liệu doanh nghiệp
</
div
>
)
:
(
businesses
.
map
((
b
)
=>
(
<
SelectItem
key=
{
b
.
id
}
value=
{
b
.
id
}
className=
{
selectItemClassName
}
>
{
b
.
name
}
</
SelectItem
>
))
}
))
)
}
</
SelectContent
>
</
Select
>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Lĩnh vực
</
Label
>
<
Select
value=
{
form
.
field_id
}
onValueChange=
{
(
v
)
=>
set
(
"field_id"
,
v
)
}
>
<
Label
className=
"text-gray-700"
>
Chức vụ
</
Label
>
<
Select
value=
{
form
.
position_id
||
undefined
}
onValueChange=
{
(
v
)
=>
set
(
"position_id"
,
v
)
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectValue
placeholder=
"Chọn
lĩnh vực
"
/>
<
SelectValue
placeholder=
"Chọn
chức vụ
"
/>
</
SelectTrigger
>
<
SelectContent
className=
{
selectContentClassName
}
>
{
fields
.
map
((
f
)
=>
(
<
SelectItem
key=
{
f
.
id
}
value=
{
f
.
id
}
className=
{
selectItemClassName
}
>
{
f
.
name
}
{
positions
.
length
===
0
?
(
<
div
className=
"px-3 py-6 text-center text-sm text-gray-700"
>
Không có dữ liệu chức vụ
</
div
>
)
:
(
positions
.
map
((
p
)
=>
(
<
SelectItem
key=
{
p
.
id
}
value=
{
p
.
id
}
className=
{
selectItemClassName
}
>
{
p
.
name
}
</
SelectItem
>
))
}
))
)
}
</
SelectContent
>
</
Select
>
</
div
>
</
div
>
{
/* Address */
}
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Địa chỉ
</
Label
>
<
Input
value=
{
form
.
address
}
onChange=
{
(
e
)
=>
set
(
"address"
,
e
.
target
.
value
)
}
placeholder=
"Nhập địa chỉ..."
className=
{
fieldClassName
}
/>
</
div
>
{
/* Phone & Fax */
}
<
div
className=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Điện thoại
</
Label
>
<
Input
value=
{
form
.
phone
}
onChange=
{
(
e
)
=>
set
(
"phone"
,
e
.
target
.
value
)
}
placeholder=
"Nhập số điện thoại..."
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Fax
</
Label
>
<
Input
value=
{
form
.
fax
}
onChange=
{
(
e
)
=>
set
(
"fax"
,
e
.
target
.
value
)
}
placeholder=
"Nhập số fax..."
className=
{
fieldClassName
}
/>
</
div
>
</
div
>
{
/* Email & Website */
}
{
/* Job title & Birth date */
}
<
div
className=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Email
</
Label
>
<
Label
className=
"text-gray-700"
>
Chức danh
</
Label
>
<
Input
type=
"email"
value=
{
form
.
email
}
onChange=
{
(
e
)
=>
set
(
"email"
,
e
.
target
.
value
)
}
placeholder=
"Nhập email..."
value=
{
form
.
job_title
??
""
}
onChange=
{
(
e
)
=>
set
(
"job_title"
,
e
.
target
.
value
)
}
placeholder=
"Nhập chức danh..."
className=
{
fieldClassName
}
/>
</
div
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Website
</
Label
>
<
Label
className=
"text-gray-700"
>
Ngày sinh
</
Label
>
<
Input
value=
{
form
.
website
}
onChange=
{
(
e
)
=>
set
(
"website"
,
e
.
target
.
value
)
}
placeholder=
"https://..."
type=
"date"
value=
{
form
.
birth_date
??
""
}
onChange=
{
(
e
)
=>
set
(
"birth_date"
,
e
.
target
.
value
)
}
className=
{
fieldClassName
}
/>
</
div
>
</
div
>
<
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
>
<
p
className=
"text-sm font-medium text-gray-700"
>
Hội viên tiêu biểu
</
p
>
<
p
className=
"mt-1 text-sm text-gray-700"
>
Đánh dấu để ưu tiên hiển thị như một hội viên tiêu biểu.
</
p
>
</
div
>
<
Switch
checked=
{
form
.
is_featured
}
onCheckedChange=
{
(
checked
)
=>
set
(
"is_featured"
,
Boolean
(
checked
))
}
/>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* Introduction (CMS) */
}
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"
>
<
h2
className=
"mb-4 text-base font-semibold text-[#063e8e]"
>
Giới thiệu
</
h2
>
<
AdminPostContentEditor
sections=
{
form
.
introduction
}
onChange=
{
handleIntroductionChange
}
/>
</
div
>
</
div
>
{
/* Side panel */
}
<
div
className=
"space-y-6"
>
{
/* Actions */
}
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"
>
<
h2
className=
"mb-4 text-base font-semibold text-[#063e8e]"
>
Thao tác
</
h2
>
<
h2
className=
"mb-4 text-base font-semibold text-[#063e8e]"
>
Thao tác
</
h2
>
<
div
className=
"flex flex-col gap-3"
>
<
Button
type=
"button"
disabled=
{
saving
}
onClick=
{
handleSave
}
className=
"w-full bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
disabled=
{
saving
}
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<
Save
className=
"mr-2 h-4 w-4"
/>
{
saving
?
"Đang lưu..."
:
"Lưu hội viên"
}
</
Button
>
<
Link
href=
"/admin/members"
className=
"w-full"
>
<
Button
type=
"button"
variant=
"outline"
className=
"w-full border-[#063e8e]/15"
>
<
Button
type=
"button"
variant=
"outline"
className=
"w-full border-[#063e8e]/15"
disabled=
{
saving
}
>
Hủy
</
Button
>
</
Link
>
</
div
>
</
div
>
{
/* Image */
}
<
div
className=
"rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"
>
<
h2
className=
"mb-4 text-base font-semibold text-[#063e8e]"
>
Ảnh đại diện
</
h2
>
{
form
.
image
?
(
<
h2
className=
"mb-4 text-base font-semibold text-[#063e8e]"
>
Ảnh đại diện
</
h2
>
{
form
.
avatar_url
?
(
<
div
className=
"space-y-3"
>
<
div
className=
"relative aspect-
video overflow-hidden rounded-x
l border border-[#063e8e]/15"
>
<
Safe
Image
src=
{
form
.
image
.
url
}
alt=
{
form
.
image
.
alt
||
form
.
image
.
name
}
<
div
className=
"relative aspect-
square overflow-hidden rounded-ful
l border border-[#063e8e]/15"
>
<
Image
src=
{
form
.
avatar_
url
}
alt=
{
form
.
full_
name
}
fill
className=
"object-cover"
/>
</
div
>
<
div
className=
"flex gap-2"
>
<
Button
type=
"button"
variant=
"outline"
size=
"sm"
className=
"flex-1 border-[#063e8e]/15 text-xs"
onClick=
{
()
=>
setImagePickerOpen
(
true
)
}
className=
"w-full border-red-200 text-red-600 hover:bg-red-50"
onClick=
{
()
=>
set
(
"avatar_url"
,
null
)
}
>
<
Upload
className=
"mr-1.5 h-3.5 w-3.5
"
/>
Đổi
ảnh
<
X
className=
"mr-2 h-4 w-4
"
/>
Xóa
ảnh
</
Button
>
<
Button
type=
"button"
variant=
"outline"
size=
"icon"
className=
"border-red-200 text-red-500 hover:bg-red-50"
onClick=
{
()
=>
set
(
"image"
,
null
)
}
>
<
X
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
</
div
>
)
:
(
<
Button
type=
"button"
variant=
"outline"
className=
"w-full border-
dashed border-[#063e8e]/25 text-[#063e8e]/60 hover:border-[#063e8e]/50 hover:text-[#063e8e]
"
className=
"w-full border-
[#063e8e]/15
"
onClick=
{
()
=>
setImagePickerOpen
(
true
)
}
>
<
Upload
className=
"mr-2 h-4 w-4"
/>
Chọn ảnh
Chọn ảnh
đại diện
</
Button
>
)
}
</
div
>
...
...
@@ -397,7 +420,6 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
<
AdminImagePicker
open=
{
imagePickerOpen
}
selectedId=
{
form
.
image
?.
id
??
null
}
onOpenChange=
{
setImagePickerOpen
}
onSelect=
{
handleImageSelect
}
/>
...
...
src/app/admin/members/_components/member-filters.tsx
View file @
d0f45809
...
...
@@ -7,7 +7,6 @@ import {
SelectTrigger
,
SelectValue
,
}
from
"@/components/ui/select"
;
import
type
{
MemberField
,
MemberRegion
}
from
"@/api/vcci-news/types/member"
;
import
{
selectContentClassName
,
selectItemClassName
,
...
...
@@ -15,8 +14,8 @@ import {
}
from
"./constants"
;
interface
MemberFiltersProps
{
fields
:
MemberField
[];
regions
:
MemberRegion
[];
fields
:
{
id
:
string
;
name
:
string
}
[];
regions
:
{
id
:
string
;
name
:
string
}
[];
fieldFilter
:
string
;
regionFilter
:
string
;
onFieldFilterChange
:
(
value
:
string
)
=>
void
;
...
...
@@ -35,11 +34,11 @@ export function MemberFilters({
<
div
className=
"flex flex-col gap-3 lg:flex-row lg:items-center"
>
<
Select
value=
{
fieldFilter
}
onValueChange=
{
onFieldFilterChange
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectValue
placeholder=
"
Lĩnh vực
"
/>
<
SelectValue
placeholder=
"
Doanh nghiệp
"
/>
</
SelectTrigger
>
<
SelectContent
className=
{
selectContentClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
Tất cả
lĩnh vực
Tất cả
doanh nghiệp
</
SelectItem
>
{
fields
.
map
((
f
)
=>
(
<
SelectItem
key=
{
f
.
id
}
value=
{
f
.
id
}
className=
{
selectItemClassName
}
>
...
...
@@ -51,11 +50,11 @@ export function MemberFilters({
<
Select
value=
{
regionFilter
}
onValueChange=
{
onRegionFilterChange
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectValue
placeholder=
"
Khu vực
"
/>
<
SelectValue
placeholder=
"
Chức vụ
"
/>
</
SelectTrigger
>
<
SelectContent
className=
{
selectContentClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
Tất cả
khu vực
Tất cả
chức vụ
</
SelectItem
>
{
regions
.
map
((
r
)
=>
(
<
SelectItem
key=
{
r
.
id
}
value=
{
r
.
id
}
className=
{
selectItemClassName
}
>
...
...
src/app/admin/members/_components/member-row.tsx
View file @
d0f45809
"use client"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
TableCell
,
TableRow
,
}
from
"@/components/ui/table"
;
import
type
{
MemberItem
}
from
"@/api/vcci-news/types/
member"
;
import
type
{
AdminMemberRow
}
from
"@/lib/utils/admin-
member"
;
interface
MemberRowProps
{
item
:
MemberItem
;
item
:
AdminMemberRow
;
index
:
number
;
fieldName
:
string
|
undefined
;
regionName
:
string
|
undefined
;
...
...
@@ -33,30 +33,30 @@ export function MemberRow({
>
<
TableCell
className=
"px-4 py-3 text-sm font-medium text-gray-800"
>
<
div
className=
"space-y-1"
>
<
div
>
{
item
.
name
}
</
div
>
{
item
.
is_featured
?
(
<
div
>
{
item
.
full_
name
}
</
div
>
{
item
.
job_title
?
(
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
>
Hội viên tiêu biểu
{
item
.
job_title
}
</
Badge
>
)
:
null
}
</
div
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center"
>
{
item
.
image
?
(
<
div
className=
"mx-auto h-12 w-1
6 overflow-hidden rounded-lg
border border-[#063e8e]/15"
>
<
Safe
Image
src=
{
item
.
image
.
url
}
alt=
{
item
.
image
.
alt
||
item
.
name
}
width=
{
64
}
{
item
.
avatar_url
?
(
<
div
className=
"mx-auto h-12 w-1
2 overflow-hidden rounded-full
border border-[#063e8e]/15"
>
<
Image
src=
{
item
.
avatar_
url
}
alt=
{
item
.
full_
name
}
width=
{
48
}
height=
{
48
}
className=
"h-full w-full object-cover"
/>
</
div
>
)
:
(
<
div
className=
"mx-auto flex h-12 w-1
6 items-center justify-center rounded-lg
border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400"
>
<
div
className=
"mx-auto flex h-12 w-1
2 items-center justify-center rounded-full
border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400"
>
Chưa có
</
div
>
)
}
...
...
@@ -68,13 +68,10 @@ export function MemberRow({
{
fieldName
??
"—"
}
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
{
item
.
phone
&&
<
div
>
{
item
.
phone
}
</
div
>
}
{
item
.
email
&&
(
<
div
className=
"truncate text-xs text-[#063e8e]"
>
{
item
.
email
}
</
div
>
)
}
<
span
className=
"line-clamp-2"
>
{
item
.
job_title
||
"—"
}
</
span
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
span
className=
"line-clamp-2"
>
{
item
.
address
||
"—"
}
</
span
>
{
item
.
birth_date
||
"—"
}
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
AdminRowActions
...
...
src/app/admin/members/fields/_components/field-form-dialog.tsx
View file @
d0f45809
...
...
@@ -12,19 +12,18 @@ import {
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
type
{
MemberField
}
from
"@/api/vcci-news/types/member"
;
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
FieldFormDialogProps
{
open
:
boolean
;
initial
:
MemberField
|
null
;
initial
:
{
id
:
string
;
name
:
string
}
|
null
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onSave
:
(
data
:
{
id
?:
string
;
name
:
string
})
=>
void
;
saving
?:
boolean
;
}
export
function
FieldFormDialog
({
open
,
initial
,
onOpenChange
,
onSave
}:
FieldFormDialogProps
)
{
export
function
FieldFormDialog
({
open
,
initial
,
onOpenChange
,
onSave
,
saving
}:
FieldFormDialogProps
)
{
const
[
name
,
setName
]
=
useState
(
""
);
useEffect
(()
=>
{
...
...
@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
const
handleSave
=
()
=>
{
const
trimmed
=
name
.
trim
();
if
(
!
trimmed
)
{
toast
.
error
(
"Vui lòng nhập tên
lĩnh vực
"
);
toast
.
error
(
"Vui lòng nhập tên
doanh nghiệp
"
);
return
;
}
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
...
...
@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
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
doanh nghiệp"
:
"Thêm doanh nghiệp
mới"
}
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Tên
lĩnh vực
<
span
className=
"text-red-500"
>
*
</
span
>
Tên
doanh nghiệp
<
span
className=
"text-red-500"
>
*
</
span
>
</
Label
>
<
Input
value=
{
name
}
onChange=
{
(
event
)
=>
setName
(
event
.
target
.
value
)
}
placeholder=
"Nhập tên
lĩnh vực
..."
placeholder=
"Nhập tên
doanh nghiệp
..."
className=
{
fieldClassName
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
handleSave
()
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
!
saving
&&
handleSave
()
}
disabled=
{
saving
}
/>
</
div
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
...
...
@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
type=
"button"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick=
{
handleSave
}
disabled=
{
saving
}
>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu
{
saving
?
"Đang lưu..."
:
"Lưu"
}
</
Button
>
</
div
>
</
div
>
...
...
src/app/admin/members/fields/page.tsx
View file @
d0f45809
"use client"
;
import
{
use
Effect
,
use
Memo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
Plus
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useDeleteApiV10BusinessId
,
useGetApiV10Business
,
usePostApiV10Business
,
usePutApiV10BusinessId
,
}
from
"@/api/vcci-news/endpoints/business"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
...
...
@@ -14,26 +21,34 @@ import {
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
MemberField
,
createMemberFieldId
,
persistMemberFields
,
readMemberFields
,
}
from
"@/mockdata/members"
;
import
{
extractRows
}
from
"@/lib/utils/admin-member"
;
import
{
FieldFormDialog
}
from
"./_components/field-form-dialog"
;
export
default
function
AdminMemberFieldsPage
()
{
const
[
items
,
setItems
]
=
useState
<
MemberField
[]
>
([]
);
const
queryClient
=
useQueryClient
(
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
dialogOpen
,
setDialogOpen
]
=
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
MemberField
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
MemberField
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
businessesQuery
=
useGetApiV10Business
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
useEffect
(()
=>
{
setItems
(
readMemberFields
());
setReady
(
true
);
},
[]);
const
{
mutateAsync
:
createBusiness
,
isPending
:
isCreating
}
=
usePostApiV10Business
();
const
{
mutateAsync
:
updateBusiness
,
isPending
:
isUpdating
}
=
usePutApiV10BusinessId
();
const
{
mutateAsync
:
deleteBusiness
,
isPending
:
isDeleting
}
=
useDeleteApiV10BusinessId
();
const
items
=
useMemo
(()
=>
{
const
rows
=
extractRows
((
businessesQuery
.
data
as
any
)?.
responseData
);
return
rows
.
map
((
row
:
any
)
=>
({
id
:
String
(
row
.
id
??
""
),
name
:
String
(
row
.
name
??
""
),
}));
},
[
businessesQuery
.
data
]);
const
filtered
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
...
...
@@ -46,46 +61,54 @@ export default function AdminMemberFieldsPage() {
setDialogOpen
(
true
);
};
const
openEdit
=
(
item
:
MemberField
)
=>
{
const
openEdit
=
(
item
:
{
id
:
string
;
name
:
string
}
)
=>
{
setEditTarget
(
item
);
setDialogOpen
(
true
);
};
const
handleSave
=
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
let
next
:
MemberField
[];
const
handleSave
=
async
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
try
{
if
(
data
.
id
)
{
next
=
items
.
map
((
item
)
=>
(
item
.
id
===
data
.
id
?
{
...
item
,
name
:
data
.
name
}
:
item
)
);
toast
.
success
(
"Đã cập nhật lĩnh vực
"
);
await
updateBusiness
({
id
:
data
.
id
,
data
:
{
name
:
data
.
name
}
}
);
toast
.
success
(
"Đã cập nhật doanh nghiệp
"
);
}
else
{
next
=
[...
items
,
{
id
:
createMemberFieldId
(),
name
:
data
.
name
}]
;
toast
.
success
(
"Đã thêm lĩnh vực
mới"
);
await
createBusiness
({
data
:
{
name
:
data
.
name
}
})
;
toast
.
success
(
"Đã thêm doanh nghiệp
mới"
);
}
setItems
(
next
);
persistMemberFields
(
next
);
queryClient
.
invalidateQueries
();
setDialogOpen
(
false
);
}
catch
(
error
)
{
console
.
error
(
error
);
toast
.
error
(
"Không lưu được doanh nghiệp"
);
}
};
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
next
);
persistMemberFields
(
next
);
toast
.
success
(
"Đã xóa lĩnh vực
"
);
try
{
await
deleteBusiness
({
id
:
deleteTarget
.
id
}
);
queryClient
.
invalidateQueries
(
);
toast
.
success
(
"Đã xóa doanh nghiệp
"
);
setDeleteTarget
(
null
);
}
catch
(
error
)
{
console
.
error
(
error
);
toast
.
error
(
"Không xóa được doanh nghiệp"
);
}
};
const
hasLoaded
=
businessesQuery
.
isSuccess
||
businessesQuery
.
isError
;
const
saving
=
isCreating
||
isUpdating
;
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm
lĩnh vực
..."
actionLabel=
"Thêm
lĩnh vực
"
searchPlaceholder=
"Tìm kiếm
doanh nghiệp
..."
actionLabel=
"Thêm
doanh nghiệp
"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
Tổng
lĩnh vực
:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
Tổng
doanh nghiệp
:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
</
div
>
}
onSearchChange=
{
setSearch
}
...
...
@@ -95,12 +118,12 @@ export default function AdminMemberFieldsPage() {
<
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-white"
>
Tên
lĩnh vực
</
TableHead
>
<
TableHead
className=
"py-4 text-white"
>
Tên
doanh nghiệp
</
TableHead
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
{
!
hasLoaded
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
...
...
@@ -111,7 +134,7 @@ export default function AdminMemberFieldsPage() {
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
3
}
className=
"py-16 text-center text-gray-400"
>
Không có
lĩnh vực
nào
Không có
doanh nghiệp
nào
</
TableCell
>
</
TableRow
>
)
:
(
...
...
@@ -129,8 +152,8 @@ export default function AdminMemberFieldsPage() {
<
TableCell
className=
"py-3 text-center"
>
<
AdminRowActions
actions=
{
[
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
lĩnh vực
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
lĩnh vực
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
doanh nghiệp
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
doanh nghiệp
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
]
}
/>
</
TableCell
>
...
...
@@ -146,14 +169,15 @@ export default function AdminMemberFieldsPage() {
initial=
{
editTarget
}
onOpenChange=
{
setDialogOpen
}
onSave=
{
handleSave
}
saving=
{
saving
||
isDeleting
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa
lĩnh vực
"
title=
"Xóa
doanh nghiệp
"
description=
{
<>
Bạn có chắc muốn xóa
lĩnh vực
{
" "
}
Bạn có chắc muốn xóa
doanh nghiệp
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành động này không thể
hoàn tác.
</>
...
...
src/app/admin/members/page.tsx
View file @
d0f45809
"use client"
;
import
{
use
Effect
,
use
Memo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
Plus
,
Users
}
from
"lucide-react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
toast
}
from
"sonner"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useDeleteApiV10MemberId
}
from
"@/api/vcci-news/endpoints/member"
;
import
{
useGetApiV10Business
}
from
"@/api/vcci-news/endpoints/business"
;
import
{
useGetApiV10Position
}
from
"@/api/vcci-news/endpoints/position"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
...
...
@@ -16,93 +20,125 @@ import {
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
MemberField
,
type
MemberItem
,
type
MemberRegion
,
persistMembers
,
readMemberFields
,
readMemberRegions
,
readMembers
,
}
from
"@/mockdata/members"
;
extractRows
,
mapApiRowToAdminMemberRow
,
type
AdminMemberRow
,
}
from
"@/lib/utils/admin-member"
;
import
{
MemberFilters
}
from
"./_components/member-filters"
;
import
{
MemberRow
}
from
"./_components/member-row"
;
import
{
MemberTableLoading
}
from
"./_components/member-table-loading"
;
import
{
useGetApiV10Member
}
from
"@/api/vcci-news/endpoints/member"
;
export
default
function
AdminMembersPage
()
{
const
router
=
useRouter
();
const
[
items
,
setItems
]
=
useState
<
MemberItem
[]
>
([]);
const
[
fields
,
setFields
]
=
useState
<
MemberField
[]
>
([]);
const
[
regions
,
setRegions
]
=
useState
<
MemberRegion
[]
>
([]);
const
queryClient
=
useQueryClient
();
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
fieldFilter
,
setFieldFilter
]
=
useState
(
"all"
);
const
[
regionFilter
,
setRegionFilter
]
=
useState
(
"all"
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
MemberItem
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
useState
(
false
);
useEffect
(()
=>
{
setItems
(
readMembers
());
setFields
(
readMemberFields
());
setRegions
(
readMemberRegions
());
setReady
(
true
);
},
[]);
const
[
businessFilter
,
setBusinessFilter
]
=
useState
(
"all"
);
const
[
positionFilter
,
setPositionFilter
]
=
useState
(
"all"
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
AdminMemberRow
|
null
>
(
null
);
const
membersQuery
=
useGetApiV10Member
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
businessesQuery
=
useGetApiV10Business
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
positionsQuery
=
useGetApiV10Position
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
const
{
mutateAsync
:
deleteMember
}
=
useDeleteApiV10MemberId
();
const
members
=
useMemo
(
()
=>
extractRows
((
membersQuery
.
data
as
any
)?.
responseData
).
map
(
mapApiRowToAdminMemberRow
),
[
membersQuery
.
data
],
);
const
businesses
=
useMemo
(()
=>
{
const
rows
=
extractRows
((
businessesQuery
.
data
as
any
)?.
responseData
);
return
rows
.
map
((
row
:
any
)
=>
({
id
:
String
(
row
.
id
??
""
),
name
:
row
.
name
??
""
}));
},
[
businessesQuery
.
data
]);
const
positions
=
useMemo
(()
=>
{
const
rows
=
extractRows
((
positionsQuery
.
data
as
any
)?.
responseData
);
return
rows
.
map
((
row
:
any
)
=>
({
id
:
String
(
row
.
id
??
""
),
name
:
row
.
name
??
""
}));
},
[
positionsQuery
.
data
]);
const
businessMap
=
useMemo
(
()
=>
Object
.
fromEntries
(
businesses
.
map
((
b
)
=>
[
b
.
id
,
b
.
name
])),
[
businesses
],
);
const
positionMap
=
useMemo
(
()
=>
Object
.
fromEntries
(
positions
.
map
((
p
)
=>
[
p
.
id
,
p
.
name
])),
[
positions
],
);
const
filtered
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
return
item
s
.
filter
((
item
)
=>
{
return
member
s
.
filter
((
item
)
=>
{
const
matchesKeyword
=
!
keyword
||
item
.
name
.
toLowerCase
().
includes
(
keyword
)
||
item
.
email
.
toLowerCase
().
includes
(
keyword
)
||
item
.
address
.
toLowerCase
().
includes
(
keyword
);
item
.
full_name
.
toLowerCase
().
includes
(
keyword
)
||
(
item
.
job_title
??
""
).
toLowerCase
().
includes
(
keyword
);
const
matchesField
=
fieldFilter
===
"all"
||
item
.
field_id
===
fieldFilter
;
const
matchesRegion
=
regionFilter
===
"all"
||
item
.
region_id
===
regionFilter
;
const
matchesBusiness
=
businessFilter
===
"all"
||
(
item
.
business_id
??
""
)
===
businessFilter
;
const
matchesPosition
=
positionFilter
===
"all"
||
(
item
.
position_id
??
""
)
===
positionFilter
;
return
matchesKeyword
&&
matches
Field
&&
matchesReg
ion
;
return
matchesKeyword
&&
matches
Business
&&
matchesPosit
ion
;
});
},
[
items
,
search
,
fieldFilter
,
reg
ionFilter
]);
},
[
members
,
search
,
businessFilter
,
posit
ionFilter
]);
const
stats
=
useMemo
(
()
=>
[
{
label
:
"Tổng hội viên"
,
value
:
item
s
.
length
,
value
:
member
s
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
{
label
:
"Số
lĩnh vực
"
,
value
:
field
s
.
length
,
label
:
"Số
doanh nghiệp
"
,
value
:
businesse
s
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
{
label
:
"Số
khu vực
"
,
value
:
reg
ions
.
length
,
label
:
"Số
chức vụ
"
,
value
:
posit
ions
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
],
[
items
,
fields
,
regions
],
);
const
fieldMap
=
useMemo
(
()
=>
Object
.
fromEntries
(
fields
.
map
((
f
)
=>
[
f
.
id
,
f
.
name
])),
[
fields
],
);
const
regionMap
=
useMemo
(
()
=>
Object
.
fromEntries
(
regions
.
map
((
r
)
=>
[
r
.
id
,
r
.
name
])),
[
regions
],
[
members
,
businesses
,
positions
],
);
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
m
)
=>
m
.
id
!==
deleteTarget
.
id
);
setItems
(
next
);
persistMembers
(
next
);
try
{
await
deleteMember
({
id
:
deleteTarget
.
id
}
);
queryClient
.
invalidateQueries
(
);
toast
.
success
(
"Đã xóa hội viên"
);
setDeleteTarget
(
null
);
}
catch
(
error
)
{
console
.
error
(
error
);
toast
.
error
(
"Không xóa được hội viên"
);
}
};
const
ready
=
!
membersQuery
.
isLoading
;
const
hasLoaded
=
membersQuery
.
isSuccess
||
membersQuery
.
isError
;
return
(
<
div
className=
"space-y-8"
>
<
AdminStatsGrid
items=
{
stats
}
/>
...
...
@@ -116,12 +152,12 @@ export default function AdminMembersPage() {
onActionClick=
{
()
=>
router
.
push
(
"/admin/members/new"
)
}
filters=
{
<
MemberFilters
fields=
{
field
s
}
regions=
{
reg
ions
}
fieldFilter=
{
field
Filter
}
regionFilter=
{
reg
ionFilter
}
onFieldFilterChange=
{
set
Field
Filter
}
onRegionFilterChange=
{
set
Reg
ionFilter
}
fields=
{
businesse
s
}
regions=
{
posit
ions
}
fieldFilter=
{
business
Filter
}
regionFilter=
{
posit
ionFilter
}
onFieldFilterChange=
{
set
Business
Filter
}
onRegionFilterChange=
{
set
Posit
ionFilter
}
/>
}
>
...
...
@@ -129,17 +165,17 @@ export default function AdminMembersPage() {
<
Table
className=
"min-w-[900px] table-fixed"
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-[2
0
0px] py-4 text-center text-white"
>
Tên hội viên
</
TableHead
>
<
TableHead
className=
"w-[1
20px] py-4 text-center text-white"
>
Ảnh
</
TableHead
>
<
TableHead
className=
"w-4
0 py-4 text-center text-white"
>
Khu vực
</
TableHead
>
<
TableHead
className=
"w-4
0 py-4 text-center text-white"
>
Lĩnh vực
</
TableHead
>
<
TableHead
className=
"w-[
200px] py-4 text-center text-white"
>
Liên hệ
</
TableHead
>
<
TableHead
className=
"w-[1
80px] py-4 text-center text-white"
>
Địa chỉ
</
TableHead
>
<
TableHead
className=
"w-[2
2
0px] py-4 text-center text-white"
>
Tên hội viên
</
TableHead
>
<
TableHead
className=
"w-[1
60px] py-4 text-center text-white"
>
Ảnh đại diện
</
TableHead
>
<
TableHead
className=
"w-4
8 py-4 text-center text-white"
>
Chức vụ
</
TableHead
>
<
TableHead
className=
"w-4
8 py-4 text-center text-white"
>
Doanh nghiệp
</
TableHead
>
<
TableHead
className=
"w-[
180px] py-4 text-center text-white"
>
Chức danh
</
TableHead
>
<
TableHead
className=
"w-[1
40px] py-4 text-center text-white"
>
Ngày sinh
</
TableHead
>
<
TableHead
className=
"w-[100px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
{
!
hasLoaded
||
!
ready
?
(
<
MemberTableLoading
/>
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
...
...
@@ -153,8 +189,8 @@ export default function AdminMembersPage() {
key=
{
item
.
id
}
item=
{
item
}
index=
{
index
}
fieldName=
{
fieldMap
[
item
.
field_id
]
}
regionName=
{
regionMap
[
item
.
region_id
]
}
fieldName=
{
item
.
business_id
?
businessMap
[
item
.
business_id
]
:
undefined
}
regionName=
{
item
.
position_id
?
positionMap
[
item
.
position_id
]
:
undefined
}
onEdit=
{
()
=>
router
.
push
(
`/admin/members/${item.id}`
)
}
onDelete=
{
()
=>
setDeleteTarget
(
item
)
}
/>
...
...
@@ -171,7 +207,7 @@ export default function AdminMembersPage() {
description=
{
<>
Bạn có chắc muốn xóa hội viên
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành động này không thể
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
full_
name
}
</
span
>
? Hành động này không thể
hoàn tác.
</>
}
...
...
src/app/admin/members/regions/_components/region-form-dialog.tsx
View file @
d0f45809
...
...
@@ -12,19 +12,18 @@ import {
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
type
{
MemberRegion
}
from
"@/api/vcci-news/types/member"
;
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
RegionFormDialogProps
{
open
:
boolean
;
initial
:
MemberRegion
|
null
;
initial
:
{
id
:
string
;
name
:
string
}
|
null
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onSave
:
(
data
:
{
id
?:
string
;
name
:
string
})
=>
void
;
saving
?:
boolean
;
}
export
function
RegionFormDialog
({
open
,
initial
,
onOpenChange
,
onSave
}:
RegionFormDialogProps
)
{
export
function
RegionFormDialog
({
open
,
initial
,
onOpenChange
,
onSave
,
saving
}:
RegionFormDialogProps
)
{
const
[
name
,
setName
]
=
useState
(
""
);
useEffect
(()
=>
{
...
...
@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
const
handleSave
=
()
=>
{
const
trimmed
=
name
.
trim
();
if
(
!
trimmed
)
{
toast
.
error
(
"Vui lòng nhập tên
khu vực
"
);
toast
.
error
(
"Vui lòng nhập tên
chức vụ
"
);
return
;
}
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
...
...
@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa
khu vực"
:
"Thêm khu vực
mới"
}
{
initial
?
"Chỉnh sửa
chức vụ"
:
"Thêm chức vụ
mới"
}
</
DialogTitle
>
</
DialogHeader
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
Tên
khu vực
<
span
className=
"text-red-500"
>
*
</
span
>
Tên
chức vụ
<
span
className=
"text-red-500"
>
*
</
span
>
</
Label
>
<
Input
value=
{
name
}
onChange=
{
(
event
)
=>
setName
(
event
.
target
.
value
)
}
placeholder=
"Nhập tên
khu vực
..."
placeholder=
"Nhập tên
chức vụ
..."
className=
{
fieldClassName
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
handleSave
()
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
!
saving
&&
handleSave
()
}
disabled=
{
saving
}
/>
</
div
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
...
...
@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
type=
"button"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick=
{
handleSave
}
disabled=
{
saving
}
>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu
{
saving
?
"Đang lưu..."
:
"Lưu"
}
</
Button
>
</
div
>
</
div
>
...
...
src/app/admin/members/regions/page.tsx
View file @
d0f45809
"use client"
;
import
{
use
Effect
,
use
Memo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
Plus
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useDeleteApiV10PositionId
,
useGetApiV10Position
,
usePostApiV10Position
,
usePutApiV10PositionId
,
}
from
"@/api/vcci-news/endpoints/position"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
...
...
@@ -14,26 +21,34 @@ import {
TableHeader
,
TableRow
,
}
from
"@/components/ui/table"
;
import
{
type
MemberRegion
,
createMemberRegionId
,
persistMemberRegions
,
readMemberRegions
,
}
from
"@/mockdata/members"
;
import
{
extractRows
}
from
"@/lib/utils/admin-member"
;
import
{
RegionFormDialog
}
from
"./_components/region-form-dialog"
;
export
default
function
AdminMemberRegionsPage
()
{
const
[
items
,
setItems
]
=
useState
<
MemberRegion
[]
>
([]
);
const
queryClient
=
useQueryClient
(
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
dialogOpen
,
setDialogOpen
]
=
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
MemberRegion
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
MemberRegion
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
positionsQuery
=
useGetApiV10Position
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
useEffect
(()
=>
{
setItems
(
readMemberRegions
());
setReady
(
true
);
},
[]);
const
{
mutateAsync
:
createPosition
,
isPending
:
isCreating
}
=
usePostApiV10Position
();
const
{
mutateAsync
:
updatePosition
,
isPending
:
isUpdating
}
=
usePutApiV10PositionId
();
const
{
mutateAsync
:
deletePosition
,
isPending
:
isDeleting
}
=
useDeleteApiV10PositionId
();
const
items
=
useMemo
(()
=>
{
const
rows
=
extractRows
((
positionsQuery
.
data
as
any
)?.
responseData
);
return
rows
.
map
((
row
:
any
)
=>
({
id
:
String
(
row
.
id
??
""
),
name
:
String
(
row
.
name
??
""
),
}));
},
[
positionsQuery
.
data
]);
const
filtered
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
...
...
@@ -46,46 +61,54 @@ export default function AdminMemberRegionsPage() {
setDialogOpen
(
true
);
};
const
openEdit
=
(
item
:
MemberRegion
)
=>
{
const
openEdit
=
(
item
:
{
id
:
string
;
name
:
string
}
)
=>
{
setEditTarget
(
item
);
setDialogOpen
(
true
);
};
const
handleSave
=
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
let
next
:
MemberRegion
[];
const
handleSave
=
async
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
try
{
if
(
data
.
id
)
{
next
=
items
.
map
((
item
)
=>
(
item
.
id
===
data
.
id
?
{
...
item
,
name
:
data
.
name
}
:
item
)
);
toast
.
success
(
"Đã cập nhật khu vực
"
);
await
updatePosition
({
id
:
data
.
id
,
data
:
{
name
:
data
.
name
}
}
);
toast
.
success
(
"Đã cập nhật chức vụ
"
);
}
else
{
next
=
[...
items
,
{
id
:
createMemberRegionId
(),
name
:
data
.
name
}]
;
toast
.
success
(
"Đã thêm khu vực
mới"
);
await
createPosition
({
data
:
{
name
:
data
.
name
}
})
;
toast
.
success
(
"Đã thêm chức vụ
mới"
);
}
setItems
(
next
);
persistMemberRegions
(
next
);
queryClient
.
invalidateQueries
();
setDialogOpen
(
false
);
}
catch
(
error
)
{
console
.
error
(
error
);
toast
.
error
(
"Không lưu được chức vụ"
);
}
};
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
setItems
(
next
);
persistMemberRegions
(
next
);
toast
.
success
(
"Đã xóa khu vực
"
);
try
{
await
deletePosition
({
id
:
deleteTarget
.
id
}
);
queryClient
.
invalidateQueries
(
);
toast
.
success
(
"Đã xóa chức vụ
"
);
setDeleteTarget
(
null
);
}
catch
(
error
)
{
console
.
error
(
error
);
toast
.
error
(
"Không xóa được chức vụ"
);
}
};
const
hasLoaded
=
positionsQuery
.
isSuccess
||
positionsQuery
.
isError
;
const
saving
=
isCreating
||
isUpdating
;
return
(
<
div
className=
"space-y-8"
>
<
AdminTableLayout
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm
khu vực
..."
actionLabel=
"Thêm
khu vực
"
searchPlaceholder=
"Tìm kiếm
chức vụ
..."
actionLabel=
"Thêm
chức vụ
"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
Tổng
khu vực
:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
Tổng
chức vụ
:
<
span
className=
"font-semibold text-[#063e8e]"
>
{
items
.
length
}
</
span
>
</
div
>
}
onSearchChange=
{
setSearch
}
...
...
@@ -95,12 +118,12 @@ export default function AdminMemberRegionsPage() {
<
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-white"
>
Tên
khu vực
</
TableHead
>
<
TableHead
className=
"py-4 text-white"
>
Tên
chức vụ
</
TableHead
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
{
!
ready
?
(
{
!
hasLoaded
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
...
...
@@ -111,7 +134,7 @@ export default function AdminMemberRegionsPage() {
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
3
}
className=
"py-16 text-center text-gray-400"
>
Không có
khu vực
nào
Không có
chức vụ
nào
</
TableCell
>
</
TableRow
>
)
:
(
...
...
@@ -129,8 +152,8 @@ export default function AdminMemberRegionsPage() {
<
TableCell
className=
"py-3 text-center"
>
<
AdminRowActions
actions=
{
[
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
khu vực
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
khu vực
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
chức vụ
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
chức vụ
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
]
}
/>
</
TableCell
>
...
...
@@ -146,14 +169,15 @@ export default function AdminMemberRegionsPage() {
initial=
{
editTarget
}
onOpenChange=
{
setDialogOpen
}
onSave=
{
handleSave
}
saving=
{
saving
||
isDeleting
}
/>
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
title=
"Xóa
khu vực
"
title=
"Xóa
chức vụ
"
description=
{
<>
Bạn có chắc muốn xóa
khu vực
{
" "
}
Bạn có chắc muốn xóa
chức vụ
{
" "
}
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành động này không thể
hoàn tác.
</>
...
...
src/app/admin/news/[id]/_components/admin-news-form-content.tsx
View file @
d0f45809
...
...
@@ -17,7 +17,7 @@ import { AdminImagePicker } from "@/components/admin/image-picker";
import
{
AdminPostContentEditor
}
from
"@/components/admin/post-content-editor"
;
import
{
PostHistoryViewer
}
from
"./post-history-viewer"
;
import
{
AdminRichTextEditor
}
from
"@/components/shared/rich-text-editor"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
PermissionGate
}
from
"@/components/shared/permission-gate"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Checkbox
}
from
"@/components/ui/checkbox"
;
...
...
@@ -37,31 +37,33 @@ import {
type
TagItem
,
}
from
"@/api/vcci-news/types/tag"
;
import
{
g
etApiV10Post
,
g
etApiV10PostId
,
p
ostApiV10Post
,
p
utApiV10PostId
,
useG
etApiV10Post
,
useG
etApiV10PostId
,
useP
ostApiV10Post
,
useP
utApiV10PostId
,
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
g
etApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
useG
etApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
d
eleteApiV10PostTagPostId
,
g
etApiV10PostTagPostId
,
p
ostApiV10PostTagPostIdBulk
,
useD
eleteApiV10PostTagPostId
,
useG
etApiV10PostTagPostId
,
useP
ostApiV10PostTagPostIdBulk
,
}
from
"@/api/vcci-news/endpoints/post-tag"
;
import
{
g
etApiV10Tag
,
p
ostApiV10TagIds
,
useG
etApiV10Tag
,
useP
ostApiV10TagIds
,
}
from
"@/api/vcci-news/endpoints/tag"
;
import
{
useGetApiV10EventAddress
}
from
"@/api/vcci-news/endpoints/event-address"
;
import
{
ADMIN_NEWS_TYPE_OPTIONS
,
cloneAdminNewsFormValues
,
type
AdminMediaItem
,
type
AdminNewsFormValues
,
type
AdminNewsItem
,
}
from
"@/api/vcci-news/types/post"
;
import
{
cloneAdminNewsFormValues
,
resolveAdminNewsType
,
slugifyAdminNews
,
}
from
"@/
mockdata
/admin-news"
;
}
from
"@/
lib/utils
/admin-news"
;
import
{
normalizeUser
}
from
"@/lib/utils/cms-user"
;
import
{
normalizeDateTimeInput
}
from
"@/lib/utils/datetime"
;
import
{
parsePostContent
,
parseLegacyPostContent
}
from
"@/lib/utils/post-content"
;
...
...
@@ -91,6 +93,45 @@ import {
}
from
"./utils"
;
import
useNewsDraftStore
,
{
type
NewsDraftData
}
from
"@/store/useNewsDraftStore"
;
function
mapPostRowToAdminNewsItem
(
item
:
any
):
AdminNewsItem
{
const
structuredContent
=
parsePostContent
(
item
.
content_structure
);
const
postContent
=
structuredContent
.
length
>
0
?
structuredContent
:
parseLegacyPostContent
(
item
.
content
);
const
categories
=
Array
.
isArray
(
item
.
categories
)
?
item
.
categories
:
[];
const
primaryCategory
=
categories
[
0
]
??
null
;
const
primaryCategoryType
=
primaryCategory
?.
type
??
null
;
return
{
id
:
item
.
id
??
""
,
title
:
item
.
title
??
""
,
slug
:
item
.
slug
??
""
,
summary
:
item
.
summary
??
""
,
type
:
item
.
type
===
"page"
||
primaryCategoryType
===
"post"
||
primaryCategoryType
===
"page"
?
"baiviettrang"
:
"tintuc"
,
header_category_id
:
primaryCategory
?.
id
??
""
,
category_ids
:
categories
.
map
((
c
:
any
)
=>
c
.
id
),
tagsearch_values
:
[],
is_featured
:
Boolean
(
item
.
is_featured
),
thumbnail
:
item
.
thumbnail
?.
id
?
{
id
:
item
.
thumbnail
.
id
,
name
:
item
.
thumbnail
.
original
??
item
.
thumbnail
.
path
??
"thumbnail"
,
alt
:
item
.
thumbnail
.
original
??
item
.
thumbnail
.
path
??
"thumbnail"
,
url
:
links
.
resolveImageUrl
(
item
.
thumbnail
.
path
),
}
:
null
,
is_hidden
:
Boolean
(
item
.
is_hidden
),
created_at
:
item
.
created_at
??
""
,
updated_at
:
item
.
updated_at
??
""
,
published_at
:
normalizeDateTimeInput
(
item
.
published_at
??
item
.
release_at
),
expired_at
:
normalizeDateTimeInput
(
item
.
expired_at
),
started_at
:
normalizeDateTimeInput
(
item
.
started_at
),
ended_at
:
normalizeDateTimeInput
(
item
.
ended_at
),
registration_deadline
:
normalizeDateTimeInput
(
item
.
registration_deadline
),
location
:
item
.
location
??
""
,
participation_fee
:
item
.
participation_fee
??
""
,
event_dates
:
Array
.
isArray
(
item
.
event_dates
)
?
item
.
event_dates
.
filter
((
d
:
any
):
d
is
string
=>
typeof
d
===
"string"
)
:
[],
post_content
:
postContent
,
creator
:
normalizeUser
(
item
.
creator
),
editor
:
normalizeUser
(
item
.
editor
),
};
}
export
function
AdminNewsFormContent
()
{
const
params
=
useParams
();
const
searchParams
=
useSearchParams
();
...
...
@@ -112,6 +153,7 @@ export function AdminNewsFormContent() {
const
[
useEventDates
,
setUseEventDates
]
=
useState
(
false
);
const
[
isAddressManagerOpen
,
setIsAddressManagerOpen
]
=
useState
(
false
);
const
[
isAddressSuggestOpen
,
setIsAddressSuggestOpen
]
=
useState
(
false
);
const
[
postTagNames
,
setPostTagNames
]
=
useState
<
string
[]
>
([]);
const
eventAddressesQuery
=
useGetApiV10EventAddress
({
page
:
1
,
...
...
@@ -120,6 +162,35 @@ export function AdminNewsFormContent() {
sortOrder
:
"asc"
,
});
// Orval hooks
const
{
data
:
treeResponse
}
=
useGetApiV10CategoryTree
();
const
{
data
:
tagsResponse
}
=
useGetApiV10Tag
({
page
:
1
,
pageSize
:
10
,
sortField
:
"name"
,
sortOrder
:
"asc"
,
});
const
postsListQuery
=
useGetApiV10Post
({
page
:
1
,
pageSize
:
10
,
filters
:
!
isCreate
&&
newsId
?
`id==
${
newsId
}
`
:
undefined
,
});
const
postDetailQuery
=
useGetApiV10PostId
(
newsId
||
""
,
{
query
:
{
enabled
:
!
isCreate
&&
Boolean
(
newsId
)
},
});
const
postTagsQuery
=
useGetApiV10PostTagPostId
(
!
isCreate
&&
newsId
?
newsId
:
""
,
undefined
,
{
query
:
{
enabled
:
!
isCreate
&&
Boolean
(
newsId
)
},
},
);
const
{
mutateAsync
:
fetchTagsByIds
}
=
usePostApiV10TagIds
();
const
{
mutateAsync
:
createPost
,
isPending
:
isCreatingPost
}
=
usePostApiV10Post
();
const
{
mutateAsync
:
updatePost
,
isPending
:
isUpdatingPost
}
=
usePutApiV10PostId
();
const
{
mutateAsync
:
deletePostTag
}
=
useDeleteApiV10PostTagPostId
();
const
{
mutateAsync
:
bulkAddPostTags
}
=
usePostApiV10PostTagPostIdBulk
();
const
addressOptions
=
useMemo
(()
=>
{
const
rows
=
eventAddressesQuery
.
data
?.
responseData
?.
rows
??
[];
const
values
=
Array
.
from
(
...
...
@@ -151,78 +222,57 @@ export function AdminNewsFormContent() {
const
[
pendingDraft
,
setPendingDraft
]
=
useState
<
NewsDraftData
|
null
>
(
null
);
const
draftRestoredRef
=
useRef
(
false
);
// Sync header tree from orval query
useEffect
(()
=>
{
let
cancelled
=
false
;
const
tree
=
(
treeResponse
?.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[];
setHeaderTree
(
tree
);
},
[
treeResponse
]);
const
load
=
async
()
=>
{
setIsLoadingInitialData
(
true
);
setIsMissingPost
(
false
);
setForm
(
isCreate
?
cloneAdminNewsFormValues
()
:
null
);
// Sync tag list from orval query
useEffect
(()
=>
{
const
nextTagsResult
=
(
tagsResponse
?.
responseData
??
{})
as
{
rows
?:
TagItem
[]
};
setAllTags
(
nextTagsResult
.
rows
??
[]);
},
[
tagsResponse
]);
// Sync post rows from orval query
useEffect
(()
=>
{
if
(
isCreate
)
{
setItems
([]);
return
;
}
const
result
=
(
postsListQuery
.
data
?.
responseData
??
{})
as
{
rows
?:
any
[]
};
setItems
((
result
.
rows
??
[]).
map
(
mapPostRowToAdminNewsItem
));
},
[
isCreate
,
postsListQuery
.
data
]);
// Resolve tag names for current post
useEffect
(()
=>
{
if
(
isCreate
||
!
newsId
)
{
setPostTagNames
([]);
return
;
}
const
tagsResult
=
(
postTagsQuery
.
data
?.
responseData
??
{})
as
{
rows
?:
{
tag_id
?:
string
}[]
};
const
tagIds
=
(
tagsResult
.
rows
??
[]).
map
(
r
=>
r
.
tag_id
).
filter
(
Boolean
)
as
string
[];
if
(
tagIds
.
length
===
0
)
{
setPostTagNames
([]);
return
;
}
let
cancelled
=
false
;
void
(
async
()
=>
{
try
{
const
[
treeResponse
,
nextTagsResponse
]
=
await
Promise
.
all
([
getApiV10CategoryTree
(),
getApiV10Tag
({
page
:
1
,
pageSize
:
10
,
sortField
:
"name"
,
sortOrder
:
"asc"
}),
]);
const
nextTagsResult
=
(
nextTagsResponse
.
responseData
??
{})
as
{
rows
?:
TagItem
[]
};
const
nextTags
=
nextTagsResult
.
rows
??
[];
const
tree
=
(
treeResponse
.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[];
const
nextHeaderTree
=
tree
;
const
nextNewsItems
=
isCreate
?
[]
:
await
(
async
()
=>
{
const
response
=
await
getApiV10Post
({
page
:
1
,
pageSize
:
10
,
filters
:
newsId
?
`id==
${
newsId
}
`
:
undefined
,
});
const
result
=
(
response
.
responseData
??
{})
as
{
rows
?:
any
[]
};
return
(
result
.
rows
??
[]).
map
((
item
:
any
):
AdminNewsItem
=>
{
const
structuredContent
=
parsePostContent
(
item
.
content_structure
);
const
postContent
=
structuredContent
.
length
>
0
?
structuredContent
:
parseLegacyPostContent
(
item
.
content
);
const
categories
=
Array
.
isArray
(
item
.
categories
)
?
item
.
categories
:
[];
const
primaryCategory
=
categories
[
0
]
??
null
;
const
primaryCategoryType
=
primaryCategory
?.
type
??
null
;
return
{
id
:
item
.
id
??
""
,
title
:
item
.
title
??
""
,
slug
:
item
.
slug
??
""
,
summary
:
item
.
summary
??
""
,
type
:
item
.
type
===
"page"
||
primaryCategoryType
===
"post"
||
primaryCategoryType
===
"page"
?
"baiviettrang"
:
"tintuc"
,
header_category_id
:
primaryCategory
?.
id
??
""
,
category_ids
:
categories
.
map
((
c
:
any
)
=>
c
.
id
),
tagsearch_values
:
[],
is_featured
:
Boolean
(
item
.
is_featured
),
thumbnail
:
item
.
thumbnail
?.
id
?
{
id
:
item
.
thumbnail
.
id
,
name
:
item
.
thumbnail
.
original
??
item
.
thumbnail
.
path
??
"thumbnail"
,
alt
:
item
.
thumbnail
.
original
??
item
.
thumbnail
.
path
??
"thumbnail"
,
url
:
links
.
resolveImageUrl
(
item
.
thumbnail
.
path
),
}
:
null
,
is_hidden
:
Boolean
(
item
.
is_hidden
),
created_at
:
item
.
created_at
??
""
,
updated_at
:
item
.
updated_at
??
""
,
published_at
:
normalizeDateTimeInput
(
item
.
published_at
??
item
.
release_at
),
expired_at
:
normalizeDateTimeInput
(
item
.
expired_at
),
started_at
:
normalizeDateTimeInput
(
item
.
started_at
),
ended_at
:
normalizeDateTimeInput
(
item
.
ended_at
),
registration_deadline
:
normalizeDateTimeInput
(
item
.
registration_deadline
),
location
:
item
.
location
??
""
,
participation_fee
:
item
.
participation_fee
??
""
,
event_dates
:
Array
.
isArray
(
item
.
event_dates
)
?
item
.
event_dates
.
filter
((
d
:
any
):
d
is
string
=>
typeof
d
===
"string"
)
:
[],
post_content
:
postContent
,
creator
:
normalizeUser
(
item
.
creator
),
editor
:
normalizeUser
(
item
.
editor
),
};
});
const
response
=
await
fetchTagsByIds
({
data
:
{
tag_ids
:
tagIds
}
});
const
tags
=
(
response
.
responseData
??
[])
as
TagItem
[];
if
(
!
cancelled
)
setPostTagNames
(
tags
.
map
((
t
)
=>
t
.
name
));
}
catch
{
if
(
!
cancelled
)
setPostTagNames
([]);
}
})();
return
()
=>
{
cancelled
=
true
;
};
},
[
isCreate
,
newsId
,
postTagsQuery
.
data
,
fetchTagsByIds
]);
if
(
cancelled
)
return
;
setItems
(
nextNewsItems
);
setHeaderTree
(
nextHeaderTree
);
setAllTags
(
nextTags
);
// Build form from post detail query
useEffect
(()
=>
{
if
(
isCreate
)
{
const
now
=
new
Date
().
toISOString
();
setForm
({
...
...
@@ -234,33 +284,27 @@ export function AdminNewsFormContent() {
updated_at
:
now
,
});
setUseEventDates
(
false
);
setIsMissingPost
(
false
);
setIsLoadingInitialData
(
false
);
return
;
}
const
existingItem
=
nextNewsItems
.
find
((
item
)
=>
item
.
id
===
newsId
);
const
currentItem
=
existingItem
?
existingItem
:
newsId
?
await
(
async
()
=>
{
const
response
=
await
getApiV10PostId
(
newsId
);
const
post
=
(
response
.
responseData
??
{})
as
any
;
// Fetch tags for post
const
tagsRes
=
await
getApiV10PostTagPostId
(
newsId
);
const
tagsResult
=
(
tagsRes
.
responseData
??
{})
as
{
rows
?:
{
tag_id
?:
string
}[]
};
const
tagIds
=
(
tagsResult
.
rows
??
[]).
map
(
r
=>
r
.
tag_id
).
filter
(
Boolean
)
as
string
[];
let
tags
:
TagItem
[]
=
[];
if
(
tagIds
.
length
>
0
)
{
const
tagsResponse
=
await
postApiV10TagIds
({
tag_ids
:
tagIds
});
tags
=
(
tagsResponse
.
responseData
??
[])
as
TagItem
[];
if
(
!
newsId
||
postDetailQuery
.
isLoading
||
!
postDetailQuery
.
data
)
return
;
const
post
=
((
postDetailQuery
.
data
as
{
responseData
?:
unknown
})?.
responseData
??
null
)
as
any
;
if
(
!
post
)
{
setIsMissingPost
(
true
);
setForm
(
null
);
setIsLoadingInitialData
(
false
);
return
;
}
const
tagMap
=
new
Map
<
string
,
TagItem
[]
>
([[
newsId
,
tags
]]);
const
tagItems
=
tagMap
.
get
(
newsId
)
??
[];
const
structuredContent
=
parsePostContent
(
post
.
content_structure
);
const
postContent
=
structuredContent
.
length
>
0
?
structuredContent
:
parseLegacyPostContent
(
post
.
content
);
const
categories
=
Array
.
isArray
(
post
.
categories
)
?
post
.
categories
:
[];
const
primaryCategory
=
categories
[
0
]
??
null
;
const
primaryCategoryType
=
primaryCategory
?.
type
??
null
;
return
{
const
currentItem
=
{
id
:
post
.
id
??
""
,
title
:
post
.
title
??
""
,
slug
:
post
.
slug
??
""
,
...
...
@@ -268,7 +312,7 @@ export function AdminNewsFormContent() {
type
:
post
.
type
===
"page"
||
primaryCategoryType
===
"post"
||
primaryCategoryType
===
"page"
?
"baiviettrang"
:
"tintuc"
,
header_category_id
:
primaryCategory
?.
id
??
""
,
category_ids
:
categories
.
map
((
c
:
any
)
=>
c
.
id
),
tagsearch_values
:
tagItems
.
map
((
t
)
=>
t
.
name
)
,
tagsearch_values
:
postTagNames
,
is_featured
:
Boolean
(
post
.
is_featured
),
thumbnail
:
post
.
thumbnail
?.
id
?
{
id
:
post
.
thumbnail
.
id
,
...
...
@@ -291,39 +335,13 @@ export function AdminNewsFormContent() {
creator
:
normalizeUser
(
post
.
creator
),
editor
:
normalizeUser
(
post
.
editor
),
}
as
AdminNewsItem
;
})()
:
null
;
if
(
cancelled
)
return
;
if
(
!
currentItem
)
{
setIsMissingPost
(
true
);
setForm
(
null
);
return
;
}
setIsMissingPost
(
false
);
const
nextForm
=
cloneAdminNewsFormValues
(
currentItem
);
setForm
(
nextForm
);
setUseEventDates
((
nextForm
.
event_dates
??
[]).
length
>
0
);
}
catch
(
error
)
{
if
(
cancelled
)
return
;
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải bài viết"
);
setIsMissingPost
(
!
isCreate
);
setForm
(
isCreate
?
cloneAdminNewsFormValues
()
:
null
);
}
finally
{
if
(
!
cancelled
)
{
setIsLoadingInitialData
(
false
);
}
}
};
void
load
();
return
()
=>
{
cancelled
=
true
;
};
},
[
isCreate
,
newsId
]);
},
[
isCreate
,
newsId
,
postDetailQuery
.
data
,
postDetailQuery
.
isLoading
,
postTagNames
]);
// Check draft sau khi load xong + store hydrated — chỉ hỏi 1 lần
useEffect
(()
=>
{
...
...
@@ -536,7 +554,7 @@ export function AdminNewsFormContent() {
const handleSubmit = async (event: FormEvent
<
HTMLFormElement
>
) =
>
{
event
.
preventDefault
();
if
(
!
form
||
isSubmitting
)
return
;
if
(
!
form
||
isSubmitting
||
isCreatingPost
||
isUpdatingPost
)
return
;
if
(
!
form
.
title
.
trim
())
{
toast
.
error
(
"Tiêu đề bài viết là bắt buộc"
);
...
...
@@ -608,20 +626,20 @@ export function AdminNewsFormContent() {
};
if
(
isCreate
)
{
const
response
=
await
postApiV10Post
(
payload
as
any
);
const
response
=
await
createPost
({
data
:
payload
as
any
}
);
const
created
=
(
response
.
responseData
??
{})
as
{
id
?:
string
};
if
(
created
.
id
&&
tagIds
.
length
>
0
)
{
await
postApiV10PostTagPostIdBulk
(
created
.
id
,
{
tag_ids
:
tagIds
});
await
bulkAddPostTags
({
postId
:
created
.
id
,
data
:
{
tag_ids
:
tagIds
}
});
}
}
else
if
(
newsId
)
{
await
putApiV10PostId
(
newsId
,
payload
as
any
);
await
updatePost
({
id
:
newsId
,
data
:
payload
as
any
}
);
// Xóa tags cũ rồi bulk add tags mới
const
currentRes
=
await
getApiV10PostTagPostId
(
newsId
);
const
currentResult
=
(
currentRes
.
responseData
??
{})
as
{
rows
?:
{
tag_id
?:
string
}[]
};
const
currentRes
=
await
postTagsQuery
.
refetch
(
);
const
currentResult
=
(
currentRes
.
data
?.
responseData
??
{})
as
{
rows
?:
{
tag_id
?:
string
}[]
};
const
currentTagIds
=
(
currentResult
.
rows
??
[]).
map
(
r
=>
r
.
tag_id
).
filter
(
Boolean
)
as
string
[];
await
Promise
.
all
(
currentTagIds
.
map
(
tagId
=>
delete
ApiV10PostTagPostId
(
newsId
,
{
tag_id
:
tagId
})));
await
Promise
.
all
(
currentTagIds
.
map
(
tagId
=>
delete
PostTag
({
postId
:
newsId
,
params
:
{
tag_id
:
tagId
}
})));
if
(
tagIds
.
length
>
0
)
{
await
postApiV10PostTagPostIdBulk
(
newsId
,
{
tag_ids
:
tagIds
});
await
bulkAddPostTags
({
postId
:
newsId
,
data
:
{
tag_ids
:
tagIds
}
});
}
}
...
...
@@ -748,7 +766,7 @@ export function AdminNewsFormContent() {
<
div
className=
"relative overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white"
>
<
div
className=
"relative aspect-[16/11]"
>
{
form
.
thumbnail
?
(
<
Safe
Image
<
Image
src=
{
form
.
thumbnail
.
url
}
alt=
{
form
.
thumbnail
.
alt
||
form
.
thumbnail
.
name
}
fill
...
...
src/app/admin/news/_components/utils.ts
View file @
d0f45809
import
{
useEffect
,
useState
}
from
"react"
;
import
dayjs
from
"dayjs"
;
import
{
type
AdminNewsItem
,
}
from
"@/mockdata/admin-news"
;
import
{
type
HeaderCategoryItem
,
type
HeaderCategoryTreeItem
,
}
from
"@/mockdata/header-config"
;
import
type
{
AdminNewsItem
}
from
"@/api/vcci-news/types/post"
;
import
type
{
HeaderCategoryItem
,
HeaderCategoryTreeItem
,
}
from
"@/api/vcci-news/types/header-config"
;
export
type
FlattenedHeaderCategory
=
HeaderCategoryItem
&
{
depth
:
number
};
...
...
src/app/admin/news/page.tsx
View file @
d0f45809
...
...
@@ -15,7 +15,7 @@ import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
...
...
@@ -44,14 +44,12 @@ import {
ADMIN_NEWS_TYPE_LABELS
,
ADMIN_NEWS_TYPE_OPTIONS
,
type
AdminNewsItem
,
}
from
"@/
mockdata/admin-news
"
;
}
from
"@/
api/vcci-news/types/post
"
;
import
{
normalizeUser
}
from
"@/lib/utils/cms-user"
;
import
{
normalizeDateTimeInput
}
from
"@/lib/utils/datetime"
;
import
{
parsePostContent
,
parseLegacyPostContent
}
from
"@/lib/utils/post-content"
;
import
links
from
"@/links"
;
import
{
type
HeaderCategoryTreeItem
,
}
from
"@/mockdata/header-config"
;
import
type
{
HeaderCategoryTreeItem
}
from
"@/api/vcci-news/types/header-config"
;
import
{
AdminNewsTableLoading
}
from
"./_components/admin-news-table-loading"
;
import
{
CategoryFilterCombobox
}
from
"./_components/category-filter-combobox"
;
import
{
...
...
@@ -468,7 +466,7 @@ export default function AdminNewsPage() {
<
TableCell
className=
"text-center"
>
<
div
className=
"relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]"
>
{
item
.
thumbnail
?
(
<
Safe
Image
<
Image
src=
{
item
.
thumbnail
.
url
}
alt=
{
item
.
thumbnail
.
alt
||
item
.
thumbnail
.
name
}
fill
...
...
src/app/admin/password-reset-requests/page.tsx
View file @
d0f45809
...
...
@@ -198,25 +198,25 @@ export default function PasswordResetRequestsPage() {
<
Table
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-1
0 py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"w-1
6 py-4 text-center text-white
"
>
STT
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Email
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Ghi chú
</
TableHead
>
<
TableHead
className=
"
py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"
w-[140px] py-4 text-center text-white
"
>
Trạng thái
</
TableHead
>
<
TableHead
className=
"
py-3 text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"
w-[170px] py-4 text-center text-white
"
>
Thời gian
</
TableHead
>
<
TableHead
className=
"
py-3 text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"
w-[180px] py-4 text-center text-white
"
>
Người xử lý
</
TableHead
>
<
TableHead
className=
"w-
32 py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"w-
[130px] py-4 text-center text-white
"
>
Thao tác
</
TableHead
>
</
TableRow
>
...
...
@@ -224,7 +224,7 @@ export default function PasswordResetRequestsPage() {
<
TableBody
>
{
rows
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
7
}
className=
"text-center py-12 text-
slate-5
00"
>
<
TableCell
colSpan=
{
7
}
className=
"text-center py-12 text-
gray-4
00"
>
Không có yêu cầu nào
</
TableCell
>
</
TableRow
>
...
...
@@ -232,66 +232,62 @@ export default function PasswordResetRequestsPage() {
rows
.
map
((
req
,
index
)
=>
(
<
TableRow
key=
{
req
.
id
}
className=
"border-b border-[#063e8e]/5 hover:bg-[#063e8e]/2"
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/3"
}
>
<
TableCell
className=
"
text-center text-slate-600 whitespace-nowrap
"
>
<
TableCell
className=
"
py-3 text-center text-sm text-gray-500
"
>
{
(
currentPage
-
1
)
*
PAGE_SIZE
+
index
+
1
}
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
div
className=
"flex items-center gap-2"
>
<
Mail
className=
"h-4 w-4 text-
slate
-400 shrink-0"
/>
<
span
className=
"t
ext-sm font-medium text-[#163b73]"
>
<
Mail
className=
"h-4 w-4 text-
gray
-400 shrink-0"
/>
<
span
className=
"t
runcate max-w-[200px]"
title=
{
req
.
email
}
>
{
req
.
email
}
</
span
>
</
div
>
</
TableCell
>
<
TableCell
>
<
p
className=
"text-sm text-slate-600 truncate max-w-[200px]"
>
{
req
.
note
||
"-"
}
</
p
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
p
className=
"truncate max-w-[200px]"
>
{
req
.
note
||
"—"
}
</
p
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
TableCell
className=
"
py-3
text-center"
>
{
req
.
status
===
"PENDING"
&&
(
<
Badge
variant=
"outline"
className=
"text-sm border-amber-200 bg-amber-50 text-amber-700 whitespace-nowrap
"
>
<
span
className=
"inline-flex rounded-full bg-[#fff4e5] px-3 py-1 text-xs font-normal text-[#c7760d]
"
>
<
Clock
className=
"mr-1 h-4 w-4"
/>
Chờ xử lý
</
Badge
>
</
span
>
)
}
{
req
.
status
===
"RESOLVED"
&&
(
<
Badge
variant=
"outline"
className=
"text-sm border-green-200 bg-green-50 text-green-700 whitespace-nowrap
"
>
<
span
className=
"inline-flex rounded-full bg-[#edf7ee] px-3 py-1 text-xs font-normal text-[#16803d]
"
>
<
CheckCircle
className=
"mr-1 h-4 w-4"
/>
Đã xử lý
</
Badge
>
</
span
>
)
}
{
req
.
status
===
"REJECTED"
&&
(
<
Badge
variant=
"outline"
className=
"text-sm border-red-200 bg-red-50 text-red-700 whitespace-nowrap
"
>
<
span
className=
"inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-normal text-red-700
"
>
<
XCircle
className=
"mr-1 h-4 w-4"
/>
Từ chối
</
Badge
>
</
span
>
)
}
</
TableCell
>
<
TableCell
>
<
p
className=
"text-sm text-slate-500 whitespace-nowrap"
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
formatDate
(
req
.
created_at
)
}
</
p
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
req
.
resolved_by_user
?
(
<
span
className=
"t
ext-sm text-slate-600
"
>
<
span
className=
"t
runcate max-w-[160px] inline-block
"
>
{
req
.
resolved_by_user
.
email
}
</
span
>
)
:
(
<
span
className=
"text-
sm text-slate-400"
>
-
</
span
>
<
span
className=
"text-
gray-400"
>
—
</
span
>
)
}
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
{
req
.
status
===
"PENDING"
&&
canWrite
?
(
<
div
className=
"flex items-center gap-1"
>
<
div
className=
"flex items-center
justify-center
gap-1"
>
<
Button
variant=
"ghost"
size=
"sm"
onClick=
{
()
=>
handleOpenResolve
(
req
)
}
className=
"h-
9
rounded-lg text-green-600 hover:bg-green-50"
className=
"h-
8
rounded-lg text-green-600 hover:bg-green-50"
title=
"Reset mật khẩu"
>
<
KeyRound
className=
"h-4 w-4"
/>
...
...
@@ -300,14 +296,14 @@ export default function PasswordResetRequestsPage() {
variant=
"ghost"
size=
"sm"
onClick=
{
()
=>
handleOpenReject
(
req
)
}
className=
"h-
9
rounded-lg text-red-600 hover:bg-red-50"
className=
"h-
8
rounded-lg text-red-600 hover:bg-red-50"
title=
"Từ chối"
>
<
XCircle
className=
"h-4 w-4"
/>
</
Button
>
</
div
>
)
:
(
<
span
className=
"text-sm text-
slate-400"
>
-
</
span
>
<
span
className=
"text-sm text-
gray-400"
>
—
</
span
>
)
}
</
TableCell
>
</
TableRow
>
...
...
src/app/admin/users/_components/UserTable.tsx
View file @
d0f45809
...
...
@@ -60,14 +60,14 @@ export function UserTable({
<
Table
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"
py-3 text-white text-sm font-semibold whitespace-nowrap
"
>
STT
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
Email
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
Họ tên
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
Vai trò
</
TableHead
>
<
TableHead
className=
"
py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
Trạng thái
</
TableHead
>
<
TableHead
className=
"
py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
Ngày tạo
</
TableHead
>
<
TableHead
className=
"
py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
Cập nhật
</
TableHead
>
<
TableHead
className=
"w-
24 py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
Thao tác
</
TableHead
>
<
TableHead
className=
"
w-16 py-4 text-center text-white
"
>
STT
</
TableHead
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Email
</
TableHead
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Họ tên
</
TableHead
>
<
TableHead
className=
"py-
4 text-center text-white
"
>
Vai trò
</
TableHead
>
<
TableHead
className=
"
w-[140px] py-4 text-center text-white
"
>
Trạng thái
</
TableHead
>
<
TableHead
className=
"
w-[170px] py-4 text-center text-white
"
>
Ngày tạo
</
TableHead
>
<
TableHead
className=
"
w-[170px] py-4 text-center text-white
"
>
Cập nhật
</
TableHead
>
<
TableHead
className=
"w-
[130px] py-4 text-center text-white
"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableHeader
>
<
TableBody
>
...
...
@@ -79,7 +79,7 @@ export function UserTable({
</
TableRow
>
)
:
users
.
length
===
0
?
(
<
TableRow
>
<
TableCell
colSpan=
{
8
}
className=
"text-center py-12 text-
slate-5
00"
>
<
TableCell
colSpan=
{
8
}
className=
"text-center py-12 text-
gray-4
00"
>
Không tìm thấy người dùng nào
</
TableCell
>
</
TableRow
>
...
...
@@ -87,58 +87,58 @@ export function UserTable({
users
.
map
((
user
,
index
)
=>
(
<
TableRow
key=
{
user
.
id
}
className=
"border-b border-[#063e8e]/5 hover:bg-[#063e8e]/2"
className=
{
index
%
2
===
0
?
"bg-white"
:
"bg-[#063e8e]/3"
}
>
<
TableCell
className=
"
text-center text-slate-600 whitespace-nowrap
"
>
<
TableCell
className=
"
py-3 text-center text-sm text-gray-500
"
>
{
(
currentPage
-
1
)
*
PAGE_SIZE
+
index
+
1
}
</
TableCell
>
<
TableCell
>
<
p
className=
"t
ext-sm text-slate-700 t
runcate max-w-[180px]"
title=
{
user
.
email
}
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
p
className=
"truncate max-w-[180px]"
title=
{
user
.
email
}
>
{
user
.
email
}
</
p
>
{
user
.
username
&&
(
<
p
className=
"text-xs text-
slate-4
00"
>
@
{
user
.
username
}
</
p
>
<
p
className=
"text-xs text-
gray-5
00"
>
@
{
user
.
username
}
</
p
>
)
}
</
TableCell
>
<
TableCell
>
<
p
className=
"
text-sm font-medium text-[#163b73]
whitespace-nowrap"
>
{
user
.
first_name
||
user
.
last_name
?
`${user.first_name || ""} ${user.last_name || ""}`
.
trim
()
:
"
-
"
}
<
TableCell
className=
"py-3 text-left text-sm text-gray-800"
>
<
p
className=
"whitespace-nowrap"
>
{
user
.
first_name
||
user
.
last_name
?
`${user.first_name || ""} ${user.last_name || ""}`
.
trim
()
:
"
—
"
}
</
p
>
</
TableCell
>
<
TableCell
>
<
div
className=
"flex flex-wrap
gap-1 max-w-[120px]
"
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex flex-wrap
justify-center gap-1 max-w-[140px] mx-auto
"
>
{
user
.
roles
?.
slice
(
0
,
2
).
map
((
roleName
)
=>
(
<
Badge
key=
{
roleName
}
variant=
"
secondary
"
className=
"border
border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5 whitespace-nowrap
"
variant=
"
outline
"
className=
"border
-[#063e8e]/25 text-[#063e8e] text-xs px-2 py-0.5 whitespace-nowrap font-normal
"
>
{
roleName
}
</
Badge
>
))
}
{
(
user
.
roles
?.
length
||
0
)
>
2
&&
(
<
Badge
variant=
"
secondary
"
className=
"border
border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5
"
variant=
"
outline
"
className=
"border
-[#063e8e]/25 text-[#063e8e] text-xs px-2 py-0.5 font-normal
"
>
+
{
user
.
roles
!
.
length
-
2
}
</
Badge
>
)
}
{
(
!
user
.
roles
||
user
.
roles
.
length
===
0
)
&&
(
<
span
className=
"text-sm text-
slate-400"
>
-
</
span
>
<
span
className=
"text-sm text-
gray-500"
>
—
</
span
>
)
}
</
div
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
TableCell
className=
"
py-3
text-center"
>
<
div
className=
"flex flex-col items-center gap-1"
>
<
Badge
variant=
"outline"
className=
{
`text-sm whitespace-nowrap ${
user.status === "active"
? "
border-green-200 bg-green-50 text-green-700
"
<
span
className=
{
user
.
status
===
"active"
?
"
inline-flex rounded-full bg-[#edf7ee] px-3 py-1 text-xs font-semibold text-[#16803d]
"
:
user
.
status
===
"pending_verification"
? "
border-amber-200 bg-amber-50 text-amber-700
"
: "
border-red-200 bg-red-50
text-red-700"
}`
}
?
"
inline-flex rounded-full bg-[#fff4e5] px-3 py-1 text-xs font-semibold text-[#c7760d]
"
:
"
inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-semibold
text-red-700"
}
>
{
user
.
status
===
"active"
?
(
<
CheckCircle
className=
"mr-1 h-4 w-4"
/>
...
...
@@ -150,35 +150,31 @@ export function UserTable({
{
user
.
status
===
"active"
?
"Hoạt động"
:
user
.
status
===
"pending_verification"
?
"Chờ duyệt"
:
user
.
status
===
"inactive"
?
"Khóa"
:
user
.
status
}
</
Badge
>
</
span
>
{
user
.
user_auth
?.
must_change_password
&&
(
<
Badge
variant=
"outline"
className=
"border-
slate-200 bg-slate-50 text-slate-600 text-sm px-1.5
"
className=
"border-
gray-200 bg-gray-50 text-gray-600 text-xs px-1.5 font-normal
"
>
<
Key
className=
"mr-0.5 h-3 w-3"
/>
</
Badge
>
)
}
</
div
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
p
className=
"text-sm text-slate-500 whitespace-nowrap"
>
{
user
.
created_at
?
formatDate
(
user
.
created_at
)
:
"-"
}
</
p
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
user
.
created_at
?
formatDate
(
user
.
created_at
)
:
"—"
}
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
p
className=
"text-sm text-slate-500 whitespace-nowrap"
>
{
user
.
updated_at
?
formatDate
(
user
.
updated_at
)
:
"-"
}
</
p
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
user
.
updated_at
?
formatDate
(
user
.
updated_at
)
:
"—"
}
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
PermissionGate
required=
"users:write"
>
<
DropdownMenu
>
<
DropdownMenuTrigger
asChild
>
<
Button
variant=
"ghost"
size=
"sm"
className=
"h-8 w-8 p-0 rounded-lg"
className=
"h-8 w-8 p-0 rounded-lg
text-gray-700 hover:bg-[#063e8e]/10 hover:text-[#063e8e]
"
>
<
MoreHorizontal
className=
"h-4 w-4"
/>
</
Button
>
...
...
src/components/admin/image-picker.tsx
View file @
d0f45809
...
...
@@ -2,6 +2,7 @@
import
*
as
React
from
"react"
;
import
{
ImagePlus
,
Search
,
Upload
,
X
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
{
toast
}
from
"sonner"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
...
...
@@ -12,12 +13,12 @@ import {
DialogHeader
,
DialogTitle
,
}
from
"@/components/ui/dialog"
;
import
{
SafeImage
}
from
"@/components/shared/safe-image"
;
import
type
{
AdminMediaItem
}
from
"@/mockdata/admin-news"
;
import
{
toAdminMediaItem
}
from
"@/lib/utils/file"
;
import
{
getApiV10File
,
p
ostApiV10FileUpload
}
from
"@/api/vcci-news/endpoints/file"
;
import
{
useGetApiV10File
,
useP
ostApiV10FileUpload
}
from
"@/api/vcci-news/endpoints/file"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
ChangeEvent
,
useEffect
,
useRef
,
useState
}
from
"react"
;
const
PAGE_SIZE
=
10
;
...
...
@@ -41,68 +42,55 @@ export function AdminImagePicker({
onOpenChange
,
onSelect
,
}:
AdminImagePickerProps
)
{
const
inputRef
=
React
.
useRef
<
HTMLInputElement
|
null
>
(
null
);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
items
,
setItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
page
,
setPage
]
=
React
.
useState
(
1
);
const
[
total
,
setTotal
]
=
React
.
useState
(
0
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
uploading
,
setUploading
]
=
React
.
useState
(
false
);
const
load
=
React
.
useCallback
(
async
()
=>
{
if
(
!
open
)
return
;
setReady
(
false
);
try
{
const
keyword
=
search
.
trim
();
const
filters
=
[
"mime@=image"
,
keyword
?
`original@=
${
keyword
}
|path@=
${
keyword
}
`
:
""
,
].
filter
(
Boolean
).
join
(
","
);
const
response
=
await
getApiV10File
({
const
inputRef
=
useRef
<
HTMLInputElement
|
null
>
(
null
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
items
,
setItems
]
=
useState
<
AdminMediaItem
[]
>
([]);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
fileQuery
=
useGetApiV10File
(
{
page
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
filters
,
});
const
pageData
=
response
.
responseData
??
{};
filters
:
[
"mime@=image"
,
search
.
trim
()
?
`original@=
${
search
.
trim
()}
|path@=
${
search
.
trim
()}
`
:
""
,
].
filter
(
Boolean
).
join
(
","
),
},
{
query
:
{
enabled
:
open
}
},
);
const
uploadMutation
=
usePostApiV10FileUpload
();
const
ready
=
open
&&
!
fileQuery
.
isFetching
;
const
uploading
=
uploadMutation
.
isPending
;
useEffect
(()
=>
{
const
pageData
=
fileQuery
.
data
?.
responseData
;
if
(
!
pageData
)
return
;
setItems
((
pageData
.
rows
??
[]).
map
(
toAdminMediaItem
));
setTotal
(
pageData
.
count
??
0
);
}
catch
(
error
)
{
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải thư viện hình ảnh"
);
},
[
fileQuery
.
data
]);
useEffect
(()
=>
{
if
(
!
fileQuery
.
error
)
return
;
toast
.
error
(
fileQuery
.
error
instanceof
Error
?
fileQuery
.
error
.
message
:
"Không thể tải thư viện hình ảnh"
);
setItems
([]);
setTotal
(
0
);
}
finally
{
setReady
(
true
);
}
},
[
open
,
page
,
search
]);
React
.
useEffect
(()
=>
{
void
load
();
},
[
load
]);
},
[
fileQuery
.
error
]);
React
.
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
open
)
return
;
setPage
(
1
);
},
[
open
,
search
]);
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
PAGE_SIZE
));
const
handleUpload
=
async
(
event
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
handleUpload
=
async
(
event
:
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
event
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
setUploading
(
true
);
try
{
const
response
=
await
postApiV10FileUpload
({
file
,
original
:
file
.
name
,
});
const
response
=
await
uploadMutation
.
mutateAsync
({
data
:
{
file
,
original
:
file
.
name
}
});
const
uploaded
=
response
.
responseData
??
null
;
if
(
!
uploaded
)
{
...
...
@@ -116,7 +104,6 @@ export function AdminImagePicker({
}
catch
(
error
)
{
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải hình ảnh lên"
);
}
finally
{
setUploading
(
false
);
event
.
target
.
value
=
""
;
}
};
...
...
@@ -210,7 +197,7 @@ export function AdminImagePicker({
)
}
>
<
div
className=
"relative aspect-[4/3] overflow-hidden bg-[#063e8e]/[0.04]"
>
<
Safe
Image
<
Image
src=
{
item
.
url
}
alt=
{
item
.
alt
||
item
.
name
}
fill
...
...
src/components/layout/main/header.tsx
View file @
d0f45809
...
...
@@ -2,8 +2,8 @@
import
React
,
{
useCallback
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
Image
from
"next/image"
;
import
{
Facebook
,
Linkedin
,
Menu
,
Twitter
,
X
,
Youtube
}
from
"lucide-react"
;
import
{
SafeImage
}
from
"@/components/shared/safe-image"
;
import
Link
from
"next/link"
;
const
fallbackLogo
=
"/logo.png"
;
import
{
useGetApiV10Logo
}
from
"@/api/vcci-news/endpoints/logo"
;
...
...
@@ -336,12 +336,11 @@ function Header() {
href=
"/"
className=
"flex w-[136px] shrink-0 items-center xl:w-[152px]"
>
<
Safe
Image
<
Image
width=
{
108
}
height=
{
40
}
className=
"h-auto max-h-10 w-[108px] object-contain"
src=
{
currentLogo
?.
logo_url
?
links
.
resolveImageUrl
(
currentLogo
.
logo_url
)
:
fallbackLogo
}
fallbackSrc=
{
fallbackLogo
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
/>
...
...
@@ -391,12 +390,11 @@ function Header() {
className=
"flex w-[136px] shrink-0 items-center"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
<
Safe
Image
<
Image
width=
{
108
}
height=
{
40
}
className=
"h-auto max-h-10 w-[108px] object-contain"
src=
{
currentLogo
?.
logo_url
?
links
.
resolveImageUrl
(
currentLogo
.
logo_url
)
:
fallbackLogo
}
fallbackSrc=
{
fallbackLogo
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
/>
...
...
src/components/shared/sidebar-advertisements/components/fallback-sidebar-ad-item.tsx
View file @
d0f45809
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
Link
from
"next/link"
;
const
FALLBACK_HREF
=
"https://vcci-hcm.org.vn"
;
...
...
@@ -13,7 +13,7 @@ export function FallbackSidebarAdItem({ src }: { src: string }) {
title=
"Quảng cáo VCCI HCM"
>
<
div
className=
"relative aspect-[16/10]"
>
<
Safe
Image
<
Image
src=
{
src
}
alt=
"Quảng cáo VCCI HCM"
fill
...
...
src/components/shared/sidebar-advertisements/components/sidebar-ad-item.tsx
View file @
d0f45809
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
Link
from
"next/link"
;
import
links
from
"@/links"
;
import
type
{
Advertisement
}
from
"@/api/vcci-news/models/advertisement"
;
...
...
@@ -15,9 +15,8 @@ export function SidebarAdItem({ item, fallbackSrc }: { item: Advertisement; fall
title=
{
item
.
name
}
>
<
div
className=
"relative aspect-[16/10]"
>
<
Safe
Image
<
Image
src=
{
src
}
fallbackSrc=
{
fallbackSrc
}
alt=
{
item
.
alt
||
item
.
name
}
fill
sizes=
"(max-width: 768px) 100vw, 300px"
...
...
src/lib/utils/admin-contact.ts
0 → 100644
View file @
d0f45809
import
type
{
Contact
}
from
"@/api/vcci-news/models/contact"
;
export
const
CONTACT_PURPOSE_OPTIONS
:
string
[]
=
[
"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"
,
];
export
interface
AdminContactRequestRow
{
id
:
string
;
purpose
:
string
;
contactName
:
string
;
contactPosition
:
string
;
contactEmail
:
string
;
contactPhone
:
string
;
message
:
string
;
organizationName
:
string
;
businessField
:
string
;
email
:
string
;
website
:
string
;
submittedAt
:
string
;
status
:
string
;
}
export
function
mapApiRowToAdminContactRequest
(
item
:
any
):
AdminContactRequestRow
{
const
contact
=
(
item
as
Partial
<
Contact
>
)
??
{};
return
{
id
:
String
(
contact
.
id
??
""
),
purpose
:
contact
.
title
??
""
,
contactName
:
contact
.
fullname
??
""
,
contactPosition
:
""
,
contactEmail
:
contact
.
email
??
""
,
contactPhone
:
contact
.
phone
??
""
,
message
:
contact
.
content
??
""
,
organizationName
:
contact
.
fullname
??
""
,
businessField
:
""
,
email
:
contact
.
email
??
""
,
website
:
""
,
submittedAt
:
contact
.
created_at
??
""
,
status
:
contact
.
status
??
""
,
};
}
export
function
extractRows
(
responseData
:
unknown
):
any
[]
{
if
(
!
responseData
||
typeof
responseData
!==
"object"
)
return
[];
const
rows
=
(
responseData
as
{
rows
?:
unknown
}).
rows
;
return
Array
.
isArray
(
rows
)
?
rows
:
[];
}
src/lib/utils/admin-member.ts
0 → 100644
View file @
d0f45809
import
type
{
AdminNewsImageRef
,
AdminNewsContentSection
,
}
from
"@/api/vcci-news/types/post"
;
export
interface
AdminMemberRow
{
id
:
string
;
full_name
:
string
;
avatar_url
:
string
|
null
;
birth_date
:
string
|
null
;
business_id
:
string
|
null
;
position_id
:
string
|
null
;
job_title
:
string
|
null
;
created_at
?:
string
|
null
;
updated_at
?:
string
|
null
;
}
export
interface
AdminMemberFormValues
{
id
?:
string
;
full_name
:
string
;
avatar_url
:
string
|
null
;
birth_date
:
string
;
business_id
:
string
;
position_id
:
string
;
job_title
:
string
;
}
export
const
EMPTY_MEMBER_FORM
:
AdminMemberFormValues
=
{
full_name
:
""
,
avatar_url
:
null
,
birth_date
:
""
,
business_id
:
""
,
position_id
:
""
,
job_title
:
""
,
};
export
function
mapApiRowToAdminMemberRow
(
item
:
any
):
AdminMemberRow
{
return
{
id
:
String
(
item
?.
id
??
""
),
full_name
:
item
?.
full_name
??
""
,
avatar_url
:
item
?.
avatar_url
??
null
,
birth_date
:
item
?.
birth_date
??
null
,
business_id
:
item
?.
business_id
??
null
,
position_id
:
item
?.
position_id
??
null
,
job_title
:
item
?.
job_title
??
null
,
created_at
:
item
?.
created_at
??
null
,
updated_at
:
item
?.
updated_at
??
null
,
};
}
export
function
extractRows
(
responseData
:
unknown
):
any
[]
{
if
(
!
responseData
||
typeof
responseData
!==
"object"
)
return
[];
const
rows
=
(
responseData
as
{
rows
?:
unknown
}).
rows
;
return
Array
.
isArray
(
rows
)
?
rows
:
[];
}
// Re-export media types used by member form media picker
export
type
AdminMemberImageRef
=
AdminNewsImageRef
;
export
type
AdminMemberContentSection
=
AdminNewsContentSection
;
src/lib/utils/admin-news.ts
0 → 100644
View file @
d0f45809
import
{
toCmsSlug
}
from
"@/lib/utils/cms-slug"
;
import
{
ADMIN_NEWS_TYPE_OPTIONS
,
type
AdminNewsFormValues
,
type
AdminNewsItem
,
type
AdminNewsType
,
}
from
"@/api/vcci-news/types/post"
;
export
const
EMPTY_ADMIN_NEWS_FORM
:
AdminNewsFormValues
=
{
title
:
""
,
slug
:
""
,
summary
:
""
,
type
:
"tintuc"
,
header_category_id
:
""
,
category_ids
:
[],
tagsearch_values
:
[],
is_featured
:
false
,
thumbnail
:
null
,
is_hidden
:
false
,
created_at
:
""
,
updated_at
:
""
,
published_at
:
""
,
expired_at
:
""
,
started_at
:
""
,
ended_at
:
""
,
registration_deadline
:
""
,
location
:
""
,
participation_fee
:
""
,
event_dates
:
[],
post_content
:
[],
};
export
function
slugifyAdminNews
(
value
:
string
)
{
return
toCmsSlug
(
value
);
}
export
function
resolveAdminNewsType
(
value
?:
string
|
null
):
AdminNewsType
|
undefined
{
if
(
!
value
)
return
undefined
;
const
normalized
=
value
.
trim
().
toLowerCase
();
const
compact
=
normalized
.
replace
(
/
[\s
_-
]
+/g
,
""
);
const
direct
=
ADMIN_NEWS_TYPE_OPTIONS
.
find
(
(
option
)
=>
option
.
value
===
normalized
||
option
.
value
===
compact
,
);
if
(
direct
)
return
direct
.
value
;
const
aliases
:
Record
<
string
,
AdminNewsType
>
=
{
news
:
"tintuc"
,
"tin tuc"
:
"tintuc"
,
"tin tức"
:
"tintuc"
,
pagepost
:
"baiviettrang"
,
"bai viet trang"
:
"baiviettrang"
,
"bài viết trang"
:
"baiviettrang"
,
};
return
aliases
[
normalized
]
||
aliases
[
compact
];
}
export
function
cloneAdminNewsFormValues
(
item
?:
AdminNewsItem
|
null
):
AdminNewsFormValues
{
if
(
!
item
)
{
return
{
...
EMPTY_ADMIN_NEWS_FORM
,
category_ids
:
[],
tagsearch_values
:
[],
post_content
:
[],
};
}
return
{
title
:
item
.
title
,
slug
:
item
.
slug
,
summary
:
item
.
summary
,
type
:
item
.
type
,
header_category_id
:
item
.
header_category_id
,
category_ids
:
[...
item
.
category_ids
],
tagsearch_values
:
[...(
item
.
tagsearch_values
??
[])],
is_featured
:
item
.
is_featured
??
false
,
thumbnail
:
item
.
thumbnail
?
{
...
item
.
thumbnail
}
:
null
,
is_hidden
:
item
.
is_hidden
,
created_at
:
item
.
created_at
,
updated_at
:
item
.
updated_at
,
published_at
:
item
.
published_at
,
expired_at
:
item
.
expired_at
,
started_at
:
item
.
started_at
,
ended_at
:
item
.
ended_at
,
registration_deadline
:
item
.
registration_deadline
,
location
:
item
.
location
,
participation_fee
:
item
.
participation_fee
,
event_dates
:
[...(
item
.
event_dates
??
[])],
post_content
:
item
.
post_content
.
map
((
section
)
=>
({
...
section
,
images
:
section
.
images
.
map
((
image
)
=>
({
...
image
,
caption
:
image
.
caption
??
""
,
image
:
{
...
image
.
image
},
})),
})),
};
}
src/lib/utils/header-category.ts
0 → 100644
View file @
d0f45809
import
{
toCmsSlug
}
from
"@/lib/utils/cms-slug"
;
import
type
{
HeaderCategoryType
}
from
"@/api/vcci-news/types/header-config"
;
export
function
toSlug
(
value
:
string
)
{
return
toCmsSlug
(
value
);
}
export
function
getHeaderCategoryTypeLabel
(
type
:
HeaderCategoryType
)
{
switch
(
type
)
{
case
"category"
:
return
"Danh mục"
;
case
"page"
:
return
"Bài viết trang"
;
case
"news"
:
return
"Tin tức"
;
default
:
return
type
;
}
}
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