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
Expand all
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({
...
@@ -374,7 +374,6 @@ export function AdvertisementList({
alt=
{
ad
.
alt
||
ad
.
name
}
alt=
{
ad
.
alt
||
ad
.
name
}
fill
fill
className=
"object-cover"
className=
"object-cover"
unoptimized=
{
isGif
}
/>
/>
)
:
(
)
:
(
<
div
className=
"flex h-full w-full items-center justify-center"
>
<
div
className=
"flex h-full w-full items-center justify-center"
>
...
@@ -519,7 +518,6 @@ export function AdvertisementList({
...
@@ -519,7 +518,6 @@ export function AdvertisementList({
alt=
"Preview"
alt=
"Preview"
fill
fill
className=
"object-cover"
className=
"object-cover"
unoptimized=
{
form
.
filePreviewUrl
.
toLowerCase
().
endsWith
(
".gif"
)
}
/>
/>
)
:
(
)
:
(
<
div
className=
"flex h-full w-full items-center justify-center"
>
<
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() {
...
@@ -247,6 +247,11 @@ export function BannerTab() {
</
Button
>
</
Button
>
</
div
>
</
div
>
</
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"
>
<
div
className=
"grid gap-4 sm:grid-cols-2 xl:grid-cols-3"
>
{
banners
.
map
((
item
,
index
)
=>
(
{
banners
.
map
((
item
,
index
)
=>
(
<
ConfigItemPreview
<
ConfigItemPreview
...
@@ -258,6 +263,7 @@ export function BannerTab() {
...
@@ -258,6 +263,7 @@ export function BannerTab() {
/>
/>
))
}
))
}
</
div
>
</
div
>
)
}
{
current
?
(
{
current
?
(
<
div
className=
"grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
<
div
className=
"grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
<
div
>
<
div
>
...
...
src/app/admin/base-config/_components/ContactTab/index.tsx
View file @
d0f45809
...
@@ -66,7 +66,6 @@ export function ContactTab() {
...
@@ -66,7 +66,6 @@ export function ContactTab() {
);
);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
apiBranches
.
length
)
return
;
const
next
=
apiBranches
.
map
(
mapApiBranchToConfig
);
const
next
=
apiBranches
.
map
(
mapApiBranchToConfig
);
setBranches
(
next
);
setBranches
(
next
);
setCurrentId
((
previous
)
=>
setCurrentId
((
previous
)
=>
...
@@ -193,7 +192,12 @@ export function ContactTab() {
...
@@ -193,7 +192,12 @@ export function ContactTab() {
<
div
className=
"text-sm font-semibold uppercase text-[#4b74b8]"
>
<
div
className=
"text-sm font-semibold uppercase text-[#4b74b8]"
>
Danh sách chi nhánh
Danh sách chi nhánh
</
div
>
</
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
<
BranchCard
key=
{
branch
.
id
}
key=
{
branch
.
id
}
branch=
{
branch
}
branch=
{
branch
}
...
@@ -201,7 +205,8 @@ export function ContactTab() {
...
@@ -201,7 +205,8 @@ export function ContactTab() {
onSelect=
{
()
=>
setCurrentId
(
branch
.
id
)
}
onSelect=
{
()
=>
setCurrentId
(
branch
.
id
)
}
onDelete=
{
()
=>
remove
(
branch
.
id
)
}
onDelete=
{
()
=>
remove
(
branch
.
id
)
}
/>
/>
))
}
))
)
}
</
div
>
</
div
>
<
div
className=
"space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<
div
className=
"space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
{
current
?
(
{
current
?
(
...
...
src/app/admin/base-config/_components/SocialTab/index.tsx
View file @
d0f45809
...
@@ -97,7 +97,12 @@ export function SocialTab() {
...
@@ -97,7 +97,12 @@ export function SocialTab() {
</
div
>
</
div
>
</
CardHeader
>
</
CardHeader
>
<
CardContent
className=
"space-y-4 px-4 sm:px-6"
>
<
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
<
div
key=
{
item
.
id
}
key=
{
item
.
id
}
className=
"rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
className=
"rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
...
@@ -146,7 +151,8 @@ export function SocialTab() {
...
@@ -146,7 +151,8 @@ export function SocialTab() {
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
))
}
))
)
}
</
CardContent
>
</
CardContent
>
</
Card
>
</
Card
>
</
TabsContent
>
</
TabsContent
>
...
...
src/app/admin/contact-management/contact-requests/page.tsx
View file @
d0f45809
...
@@ -2,7 +2,6 @@
...
@@ -2,7 +2,6 @@
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
...
@@ -24,12 +23,15 @@ import {
...
@@ -24,12 +23,15 @@ import {
TableHeader
,
TableHeader
,
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useGetApiV10Contact
,
useDeleteApiV10ContactId
}
from
"@/api/vcci-news/endpoints/contact"
;
import
{
import
{
CONTACT_PURPOSE_OPTIONS
,
CONTACT_PURPOSE_OPTIONS
,
type
ContactRequestItem
,
type
AdminContactRequestRow
,
persistContactRequests
,
extractRows
,
readContactRequests
,
mapApiRowToAdminContactRequest
,
}
from
"@/mockdata/contact-management"
;
}
from
"@/lib/utils/admin-contact"
;
import
{
useMemo
,
useState
}
from
"react"
;
const
selectTriggerClassName
=
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]"
;
"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";
...
@@ -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]"
;
const
selectItemClassName
=
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"
;
function
formatDateTime
(
value
:
string
)
{
function
formatDateTime
(
value
:
string
)
{
if
(
!
value
)
return
"—"
;
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
}
}
export
default
function
AdminContactRequestsPage
()
{
export
default
function
AdminContactRequestsPage
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
ContactRequestItem
[]
>
([]);
const
queryClient
=
useQueryClient
();
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
purposeFilter
,
setPurposeFilter
]
=
React
.
useState
(
"all"
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
detailTarget
,
setDetailTarget
]
=
React
.
useState
<
ContactRequestItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
ContactRequestItem
|
null
>
(
null
);
React
.
useEffect
(()
=>
{
const
[
search
,
setSearch
]
=
useState
(
""
);
setItems
(
readContactRequests
()
);
const
[
purposeFilter
,
setPurposeFilter
]
=
useState
(
"all"
);
setReady
(
true
);
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
();
const
keyword
=
search
.
trim
().
toLowerCase
();
return
items
.
filter
((
item
)
=>
{
return
items
.
filter
((
item
)
=>
{
...
@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() {
...
@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() {
});
});
},
[
items
,
purposeFilter
,
search
]);
},
[
items
,
purposeFilter
,
search
]);
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
try
{
setItems
(
nextItems
);
await
deleteContact
.
mutateAsync
({
id
:
deleteTarget
.
id
});
persistContactRequests
(
nextItems
);
toast
.
success
(
"Đã xóa đơn liên hệ"
);
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
);
setDeleteTarget
(
null
);
}
};
};
return
(
return
(
...
@@ -127,7 +147,7 @@ export default function AdminContactRequestsPage() {
...
@@ -127,7 +147,7 @@ export default function AdminContactRequestsPage() {
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
{
!
ready
?
(
{
contactsQuery
.
isLoading
?
(
Array
.
from
({
length
:
4
}).
map
((
_
,
index
)
=>
(
Array
.
from
({
length
:
4
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
...
@@ -152,25 +172,25 @@ export default function AdminContactRequestsPage() {
...
@@ -152,25 +172,25 @@ export default function AdminContactRequestsPage() {
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
item
.
purpose
}
{
item
.
purpose
||
"—"
}
</
Badge
>
</
Badge
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
div
className=
"space-y-1"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-semibold"
>
{
item
.
contactName
}
</
div
>
<
div
className=
"font-semibold"
>
{
item
.
contactName
||
"—"
}
</
div
>
<
div
className=
"text-gray-600"
>
{
item
.
contactPhone
}
</
div
>
<
div
className=
"text-gray-600"
>
{
item
.
contactPhone
||
"—"
}
</
div
>
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
div
className=
"space-y-1"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-medium text-gray-800"
>
{
item
.
organizationName
}
</
div
>
<
div
className=
"font-medium text-gray-800"
>
{
item
.
organizationName
||
"—"
}
</
div
>
<
div
>
{
item
.
businessField
}
</
div
>
<
div
>
{
item
.
businessField
||
"—"
}
</
div
>
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
div
className=
"space-y-1"
>
<
div
className=
"space-y-1"
>
<
div
>
{
item
.
email
}
</
div
>
<
div
>
{
item
.
email
||
"—"
}
</
div
>
<
div
className=
"text-gray-500"
>
{
item
.
contactEmail
}
</
div
>
<
div
className=
"text-gray-500"
>
{
item
.
contactEmail
||
"—"
}
</
div
>
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
...
@@ -199,7 +219,7 @@ export default function AdminContactRequestsPage() {
...
@@ -199,7 +219,7 @@ export default function AdminContactRequestsPage() {
badge=
{
badge=
{
detailTarget
?
(
detailTarget
?
(
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
detailTarget
.
purpose
}
{
detailTarget
.
purpose
||
"—"
}
</
Badge
>
</
Badge
>
)
:
null
)
:
null
}
}
...
@@ -209,27 +229,27 @@ export default function AdminContactRequestsPage() {
...
@@ -209,27 +229,27 @@ export default function AdminContactRequestsPage() {
{
{
title
:
"Thông tin chung"
,
title
:
"Thông tin chung"
,
fields
:
[
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
)
},
{
label
:
"Ngày gửi"
,
value
:
formatDateTime
(
detailTarget
.
submittedAt
)
},
],
],
},
},
{
{
title
:
"Người liên hệ"
,
title
:
"Người liên hệ"
,
fields
:
[
fields
:
[
{
label
:
"Họ tên người liên hệ"
,
value
:
detailTarget
.
contactName
},
{
label
:
"Họ tên người liên hệ"
,
value
:
detailTarget
.
contactName
||
"—"
},
{
label
:
"Chức vụ"
,
value
:
detailTarget
.
contactPosition
},
{
label
:
"Chức vụ"
,
value
:
detailTarget
.
contactPosition
||
"—"
},
{
label
:
"Email người liên hệ"
,
value
:
detailTarget
.
contactEmail
},
{
label
:
"Email người liên hệ"
,
value
:
detailTarget
.
contactEmail
||
"—"
},
{
label
:
"Điện thoại người liên hệ"
,
value
:
detailTarget
.
contactPhone
},
{
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
:
"Nội dung liên hệ"
,
value
:
detailTarget
.
message
||
"—"
,
fullWidth
:
true
},
],
],
},
},
{
{
title
:
"Thông tin công ty / tổ chức"
,
title
:
"Thông tin công ty / tổ chức"
,
fields
:
[
fields
:
[
{
label
:
"Tên công ty / tổ chức"
,
value
:
detailTarget
.
organizationName
},
{
label
:
"Tên công ty / tổ chức"
,
value
:
detailTarget
.
organizationName
||
"—"
},
{
label
:
"Lĩnh vực hoạt động"
,
value
:
detailTarget
.
businessField
},
{
label
:
"Lĩnh vực hoạt động"
,
value
:
detailTarget
.
businessField
||
"—"
},
{
label
:
"Email"
,
value
:
detailTarget
.
email
},
{
label
:
"Email"
,
value
:
detailTarget
.
email
||
"—"
},
{
label
:
"Website"
,
value
:
detailTarget
.
website
},
{
label
:
"Website"
,
value
:
detailTarget
.
website
||
"—"
},
],
],
},
},
]
]
...
...
src/app/admin/contact-management/membership-applications/page.tsx
View file @
d0f45809
...
@@ -2,7 +2,6 @@
...
@@ -2,7 +2,6 @@
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
Trash2
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
...
@@ -17,29 +16,72 @@ import {
...
@@ -17,29 +16,72 @@ import {
TableHeader
,
TableHeader
,
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
{
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
type
MembershipApplicationItem
,
import
{
useGetApiV10Contact
,
useDeleteApiV10ContactId
}
from
"@/api/vcci-news/endpoints/contact"
;
persistMembershipApplications
,
import
{
extractRows
}
from
"@/lib/utils/admin-contact"
;
readMembershipApplications
,
import
{
useMemo
,
useState
}
from
"react"
;
}
from
"@/mockdata/contact-management"
;
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
)
{
function
formatDateTime
(
value
:
string
)
{
if
(
!
value
)
return
"—"
;
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
return
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
);
}
}
export
default
function
AdminMembershipApplicationsPage
()
{
export
default
function
AdminMembershipApplicationsPage
()
{
const
[
items
,
setItems
]
=
React
.
useState
<
MembershipApplicationItem
[]
>
([]);
const
queryClient
=
useQueryClient
();
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
[
detailTarget
,
setDetailTarget
]
=
React
.
useState
<
MembershipApplicationItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
React
.
useState
<
MembershipApplicationItem
|
null
>
(
null
);
React
.
useEffect
(()
=>
{
const
[
search
,
setSearch
]
=
useState
(
""
);
setItems
(
readMembershipApplications
());
const
[
detailTarget
,
setDetailTarget
]
=
useState
<
AdminMembershipApplicationRow
|
null
>
(
null
);
setReady
(
true
);
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
();
const
keyword
=
search
.
trim
().
toLowerCase
();
if
(
!
keyword
)
return
items
;
if
(
!
keyword
)
return
items
;
...
@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() {
...
@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() {
);
);
},
[
items
,
search
]);
},
[
items
,
search
]);
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
if
(
!
deleteTarget
)
return
;
const
nextItems
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
try
{
setItems
(
nextItems
);
await
deleteContact
.
mutateAsync
({
id
:
deleteTarget
.
id
});
persistMembershipApplications
(
nextItems
);
toast
.
success
(
"Đã xóa đơn đăng ký hội viên"
);
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
);
setDeleteTarget
(
null
);
}
};
};
return
(
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"space-y-8"
>
<
AdminTableLayout
<
AdminTableLayout
searchValue=
{
search
}
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm đơn đăng ký hội viên..."
searchPlaceholder=
"Tìm kiếm đơn đăng ký hội viên..."
...
@@ -91,7 +138,7 @@ export default function AdminMembershipApplicationsPage() {
...
@@ -91,7 +138,7 @@ export default function AdminMembershipApplicationsPage() {
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
{
!
ready
?
(
{
contactsQuery
.
isLoading
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
<
TableCell
colSpan=
{
7
}
className=
"px-4 py-4"
>
...
@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() {
...
@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() {
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
div
className=
"space-y-1"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-semibold"
>
{
item
.
organizationName
}
</
div
>
<
div
className=
"font-semibold"
>
{
item
.
organizationName
||
"—"
}
</
div
>
<
div
className=
"text-gray-600"
>
{
item
.
businessField
}
</
div
>
<
div
className=
"text-gray-600"
>
{
item
.
businessField
||
"—"
}
</
div
>
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
TableCell
className=
"py-3 text-sm text-gray-700"
>
<
div
className=
"space-y-1"
>
<
div
className=
"space-y-1"
>
<
div
className=
"font-medium text-gray-800"
>
{
item
.
contactName
}
</
div
>
<
div
className=
"font-medium text-gray-800"
>
{
item
.
contactName
||
"—"
}
</
div
>
<
div
>
{
item
.
contactPhone
}
</
div
>
<
div
>
{
item
.
contactPhone
||
"—"
}
</
div
>
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
item
.
membershipType
}
{
item
.
membershipType
||
"—"
}
</
Badge
>
</
Badge
>
</
TableCell
>
</
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"
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
formatDateTime
(
item
.
submittedAt
)
}
{
formatDateTime
(
item
.
submittedAt
)
}
</
TableCell
>
</
TableCell
>
...
@@ -158,11 +205,11 @@ export default function AdminMembershipApplicationsPage() {
...
@@ -158,11 +205,11 @@ export default function AdminMembershipApplicationsPage() {
<
ContactManagementDetailDialog
<
ContactManagementDetailDialog
open=
{
!!
detailTarget
}
open=
{
!!
detailTarget
}
title=
"Chi tiết đơn đăng ký hội viên"
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=
{
badge=
{
detailTarget
?
(
detailTarget
?
(
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
<
Badge
variant=
"outline"
className=
"border-[#063e8e]/25 text-[#063e8e]"
>
{
detailTarget
.
membershipType
}
{
detailTarget
.
membershipType
||
"—"
}
</
Badge
>
</
Badge
>
)
:
null
)
:
null
}
}
...
@@ -172,27 +219,27 @@ export default function AdminMembershipApplicationsPage() {
...
@@ -172,27 +219,27 @@ export default function AdminMembershipApplicationsPage() {
{
{
title
:
"Thông tin chung"
,
title
:
"Thông tin chung"
,
fields
:
[
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
)
},
{
label
:
"Ngày gửi"
,
value
:
formatDateTime
(
detailTarget
.
submittedAt
)
},
],
],
},
},
{
{
title
:
"Thông tin doanh nghiệp"
,
title
:
"Thông tin doanh nghiệp"
,
fields
:
[
fields
:
[
{
label
:
"Tên công ty / tổ chức"
,
value
:
detailTarget
.
organizationName
},
{
label
:
"Tên công ty / tổ chức"
,
value
:
detailTarget
.
organizationName
||
"—"
},
{
label
:
"Lĩnh vực hoạt động"
,
value
:
detailTarget
.
businessField
},
{
label
:
"Lĩnh vực hoạt động"
,
value
:
detailTarget
.
businessField
||
"—"
},
{
label
:
"Địa chỉ"
,
value
:
detailTarget
.
address
,
fullWidth
:
true
},
{
label
:
"Địa chỉ"
,
value
:
detailTarget
.
address
||
"—"
,
fullWidth
:
true
},
{
label
:
"Website"
,
value
:
detailTarget
.
website
},
{
label
:
"Website"
,
value
:
detailTarget
.
website
||
"—"
},
],
],
},
},
{
{
title
:
"Thông tin người liên hệ"
,
title
:
"Thông tin người liên hệ"
,
fields
:
[
fields
:
[
{
label
:
"Họ tên người liên hệ"
,
value
:
detailTarget
.
contactName
},
{
label
:
"Họ tên người liên hệ"
,
value
:
detailTarget
.
contactName
||
"—"
},
{
label
:
"Chức vụ"
,
value
:
detailTarget
.
contactPosition
},
{
label
:
"Chức vụ"
,
value
:
detailTarget
.
contactPosition
||
"—"
},
{
label
:
"Email người liên hệ"
,
value
:
detailTarget
.
contactEmail
},
{
label
:
"Email người liên hệ"
,
value
:
detailTarget
.
contactEmail
||
"—"
},
{
label
:
"Điện thoại người liên hệ"
,
value
:
detailTarget
.
contactPhone
},
{
label
:
"Điện thoại người liên hệ"
,
value
:
detailTarget
.
contactPhone
||
"—"
},
{
label
:
"Ghi chú"
,
value
:
detailTarget
.
note
,
fullWidth
:
true
},
{
label
:
"Ghi chú"
,
value
:
detailTarget
.
note
||
"—"
,
fullWidth
:
true
},
],
],
},
},
]
]
...
...
src/app/admin/dashboard/page.tsx
View file @
d0f45809
This diff is collapsed.
Click to expand it.
src/app/admin/header-config/[categoryId]/page.tsx
View file @
d0f45809
...
@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions";
...
@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions";
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
Pagination
}
from
"@/components/base/pagination"
;
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
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
...
@@ -26,10 +26,15 @@ import {
...
@@ -26,10 +26,15 @@ import {
TableHeader
,
TableHeader
,
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
{
import
{
deleteApiV10PostId
}
from
"@/api/vcci-news/endpoints/post"
;
useDeleteApiV10PostId
,
import
{
getApiV10CategoryTree
}
from
"@/api/vcci-news/endpoints/category"
;
useGetApiV10Post
,
import
{
ADMIN_NEWS_TYPE_LABELS
,
type
AdminNewsItem
}
from
"@/mockdata/admin-news"
;
}
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
{
normalizeUser
}
from
"@/lib/utils/cms-user"
;
import
{
normalizeDateTimeInput
}
from
"@/lib/utils/datetime"
;
import
{
normalizeDateTimeInput
}
from
"@/lib/utils/datetime"
;
import
{
parsePostContent
,
parseLegacyPostContent
}
from
"@/lib/utils/post-content"
;
import
{
parsePostContent
,
parseLegacyPostContent
}
from
"@/lib/utils/post-content"
;
...
@@ -48,64 +53,37 @@ export default function HeaderCategoryPostsPage() {
...
@@ -48,64 +53,37 @@ export default function HeaderCategoryPostsPage() {
const
pathname
=
usePathname
();
const
pathname
=
usePathname
();
const
searchParams
=
useSearchParams
();
const
searchParams
=
useSearchParams
();
const
categoryId
=
String
(
params
.
categoryId
??
""
);
const
categoryId
=
String
(
params
.
categoryId
??
""
);
const
[
items
,
setItems
]
=
useState
<
AdminNewsItem
[]
>
([]
);
const
{
data
:
treeResponse
,
isLoading
:
isTreeLoading
}
=
useGetApiV10CategoryTree
(
);
const
[
headerTree
,
setHeaderTree
]
=
useState
<
HeaderCategoryTreeItem
[]
>
([]
);
const
{
mutateAsync
:
deletePost
,
isPending
:
isDeletingPost
}
=
useDeleteApiV10PostId
(
);
const
[
search
,
setSearch
]
=
useState
(()
=>
searchParams
.
get
(
"q"
)
??
""
);
const
[
search
,
setSearch
]
=
useState
(()
=>
searchParams
.
get
(
"q"
)
??
""
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
AdminNewsItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
AdminNewsItem
|
null
>
(
null
);
const
didMountRef
=
useRef
(
false
);
const
didMountRef
=
useRef
(
false
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(()
=>
{
const
[
page
,
setPage
]
=
useState
(()
=>
{
const
parsedPage
=
Number
(
searchParams
.
get
(
"page"
)
??
1
);
const
parsedPage
=
Number
(
searchParams
.
get
(
"page"
)
??
1
);
return
Number
.
isFinite
(
parsedPage
)
&&
parsedPage
>
0
?
Math
.
floor
(
parsedPage
)
:
1
;
return
Number
.
isFinite
(
parsedPage
)
&&
parsedPage
>
0
?
Math
.
floor
(
parsedPage
)
:
1
;
});
});
const
listQueryString
=
useMemo
(()
=>
{
const
apiFilters
=
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
keyword
=
search
.
trim
();
const
keyword
=
search
.
trim
();
const
filters
=
[
const
filters
=
[
`category.id==
${
categoryId
}
`
,
`category.id==
${
categoryId
}
`
,
keyword
?
`title@=
${
keyword
}
|slug@=
${
keyword
}
`
:
""
,
keyword
?
`title@=
${
keyword
}
|slug@=
${
keyword
}
`
:
""
,
].
filter
(
Boolean
).
join
(
","
);
].
filter
(
Boolean
).
join
(
","
);
return
filters
.
trim
()
||
undefined
;
},
[
categoryId
,
search
]);
const
[
newsResponse
,
treeResponse
]
=
await
Promise
.
all
([
const
postsQuery
=
useGetApiV10Post
({
getApiV10Post
({
page
,
page
,
pageSize
:
PAGE_SIZE
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
sortOrder
:
"desc"
,
filters
:
filters
.
trim
()
||
undefined
,
filters
:
apiFilters
,
}),
});
getApiV10CategoryTree
(),
]);
if
(
cancelled
)
return
;
const
newsResult
=
(
newsResponse
.
responseData
??
{})
as
{
rows
?:
any
[];
count
?:
number
};
const
postsResponse
=
(
postsQuery
.
data
??
{})
as
{
responseData
?:
{
rows
?:
any
[];
count
?:
number
}
};
const
tree
=
(
treeResponse
.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[];
const
items
:
AdminNewsItem
[]
=
useMemo
(
setItems
((
newsResult
.
rows
??
[]).
map
((
item
:
any
):
AdminNewsItem
=>
{
()
=>
(
postsResponse
.
responseData
?.
rows
??
[]).
map
((
item
:
any
):
AdminNewsItem
=>
{
const
structuredContent
=
parsePostContent
(
item
.
content_structure
);
const
structuredContent
=
parsePostContent
(
item
.
content_structure
);
const
postContent
=
structuredContent
.
length
>
0
?
structuredContent
:
parseLegacyPostContent
(
item
.
content
);
const
postContent
=
structuredContent
.
length
>
0
?
structuredContent
:
parseLegacyPostContent
(
item
.
content
);
const
categories
=
Array
.
isArray
(
item
.
categories
)
?
item
.
categories
:
[];
const
categories
=
Array
.
isArray
(
item
.
categories
)
?
item
.
categories
:
[];
...
@@ -142,23 +120,35 @@ export default function HeaderCategoryPostsPage() {
...
@@ -142,23 +120,35 @@ export default function HeaderCategoryPostsPage() {
creator
:
normalizeUser
(
item
.
creator
),
creator
:
normalizeUser
(
item
.
creator
),
editor
:
normalizeUser
(
item
.
editor
),
editor
:
normalizeUser
(
item
.
editor
),
};
};
}));
}),
setTotal
(
newsResult
.
count
??
0
);
[
postsResponse
.
responseData
],
setHeaderTree
(
tree
);
);
setReady
(
true
);
const
total
=
postsResponse
.
responseData
?.
count
??
0
;
}
catch
(
error
)
{
if
(
cancelled
)
return
;
const
headerTree
:
HeaderCategoryTreeItem
[]
=
useMemo
(
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải dữ liệu"
);
()
=>
(
treeResponse
?.
responseData
??
[])
as
unknown
as
HeaderCategoryTreeItem
[],
setReady
(
true
);
[
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
()
=>
{
return
nextParams
.
toString
();
cancelled
=
true
;
},
[
page
,
search
]);
};
},
[
categoryId
,
page
,
search
]);
const
listPath
=
useMemo
(
()
=>
(
listQueryString
?
`
${
pathname
}
?
${
listQueryString
}
`
:
pathname
),
[
listQueryString
,
pathname
],
);
const
flatCategories
=
useMemo
(()
=>
{
const
flatCategories
=
useMemo
(()
=>
{
return
flattenTree
(
headerTree
);
return
flattenTree
(
headerTree
);
...
@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() {
...
@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() {
},
[
items
,
total
]);
},
[
items
,
total
]);
const
handleDelete
=
async
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
if
(
!
deleteTarget
||
isDeletingPost
)
return
;
try
{
try
{
await
deleteApiV10PostId
(
deleteTarget
.
id
);
await
deletePost
({
id
:
deleteTarget
.
id
});
setItems
((
current
)
=>
current
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
));
setTotal
((
current
)
=>
Math
.
max
(
0
,
current
-
1
));
toast
.
success
(
"Đã xóa bài viết"
);
toast
.
success
(
"Đã xóa bài viết"
);
setDeleteTarget
(
null
);
setDeleteTarget
(
null
);
postsQuery
.
refetch
();
}
catch
(
error
)
{
}
catch
(
error
)
{
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể xóa bài viết"
);
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể xóa bài viết"
);
}
}
...
@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() {
...
@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() {
<
TableCell
className=
"text-center"
>
<
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]"
>
<
div
className=
"relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]"
>
{
item
.
thumbnail
?
(
{
item
.
thumbnail
?
(
<
Safe
Image
<
Image
src=
{
item
.
thumbnail
.
url
}
src=
{
item
.
thumbnail
.
url
}
alt=
{
item
.
thumbnail
.
alt
||
item
.
thumbnail
.
name
}
alt=
{
item
.
thumbnail
.
alt
||
item
.
thumbnail
.
name
}
fill
fill
...
...
src/app/admin/header-config/components/header-category-form-dialog.tsx
View file @
d0f45809
...
@@ -20,11 +20,11 @@ import {
...
@@ -20,11 +20,11 @@ import {
SelectValue
,
SelectValue
,
}
from
"@/components/ui/select"
;
}
from
"@/components/ui/select"
;
import
{
Textarea
}
from
"@/components/ui/textarea"
;
import
{
Textarea
}
from
"@/components/ui/textarea"
;
import
{
import
{
toSlug
}
from
"@/lib/utils/header-category"
;
type
HeaderCategoryTreeItem
,
import
type
{
type
HeaderCategoryType
,
HeaderCategoryTreeItem
,
toSlug
,
HeaderCategoryType
,
}
from
"@/
mockdata
/header-config"
;
}
from
"@/
api/vcci-news/types
/header-config"
;
export
type
HeaderCategoryFormMode
=
"create"
|
"edit"
;
export
type
HeaderCategoryFormMode
=
"create"
|
"edit"
;
...
...
src/app/admin/header-config/components/header-category-table.tsx
View file @
d0f45809
...
@@ -20,10 +20,8 @@ import {
...
@@ -20,10 +20,8 @@ import {
TableHeader
,
TableHeader
,
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
{
import
{
getHeaderCategoryTypeLabel
}
from
"@/lib/utils/header-category"
;
type
HeaderCategoryTreeItem
,
import
type
{
HeaderCategoryTreeItem
}
from
"@/api/vcci-news/types/header-config"
;
getHeaderCategoryTypeLabel
,
}
from
"@/mockdata/header-config"
;
export
type
HeaderCategoryFlatRow
=
HeaderCategoryTreeItem
&
{
export
type
HeaderCategoryFlatRow
=
HeaderCategoryTreeItem
&
{
depth
:
number
;
depth
:
number
;
...
...
src/app/admin/header-config/page.tsx
View file @
d0f45809
"use client"
;
"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
{
toast
}
from
"sonner"
;
import
{
import
{
HeaderCategoryDeleteDialog
,
HeaderCategoryDeleteDialog
,
...
@@ -12,17 +18,11 @@ import {
...
@@ -12,17 +18,11 @@ import {
HeaderCategoryTable
,
HeaderCategoryTable
,
}
from
"./components"
;
}
from
"./components"
;
import
{
buildStaticLink
}
from
"@/lib/utils/header-link"
;
import
{
buildStaticLink
}
from
"@/lib/utils/header-link"
;
import
{
import
{
toSlug
}
from
"@/lib/utils/header-category"
;
deleteApiV10CategoryId
,
import
type
{
getApiV10CategoryTree
,
postApiV10Category
,
putApiV10CategoryId
,
}
from
"@/api/vcci-news/endpoints/category"
;
import
{
HeaderCategoryItem
,
HeaderCategoryItem
,
HeaderCategoryTreeItem
,
HeaderCategoryTreeItem
,
toSlug
,
}
from
"@/api/vcci-news/types/header-config"
;
}
from
"@/mockdata/header-config"
;
const
EMPTY_HEADER_CATEGORY_FORM
:
HeaderCategoryFormValues
=
{
const
EMPTY_HEADER_CATEGORY_FORM
:
HeaderCategoryFormValues
=
{
name
:
""
,
name
:
""
,
...
@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
...
@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
}
}
function
useHeaderConfigModule
()
{
function
useHeaderConfigModule
()
{
const
{
data
:
treeResponse
,
isLoading
,
error
,
refetch
:
refetchTree
,
}
=
useGetApiV10CategoryTree
();
const
[
tree
,
setTree
]
=
useState
<
HeaderCategoryTreeItem
[]
>
([]);
const
[
tree
,
setTree
]
=
useState
<
HeaderCategoryTreeItem
[]
>
([]);
const
[
rootStaticLink
,
setRootStaticLink
]
=
useState
(
"/"
);
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
);
setTree
(
treeData
);
setRootStaticLink
(
"/"
);
setRootStaticLink
(
"/"
);
setIsReady
(
true
);
},
[
treeResponse
]);
},
[]);
useEffect
(()
=>
{
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"
);
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải cấu hình danh mục"
);
setIsReady
(
true
);
}
});
},
[
error
]);
},
[
load
]);
return
{
return
{
tree
,
tree
,
rootStaticLink
,
rootStaticLink
,
isReady
,
isReady
:
!
isLoading
,
reload
:
load
,
reload
:
refetchTree
,
};
};
}
}
export
default
function
HeaderConfigPage
()
{
export
default
function
HeaderConfigPage
()
{
const
{
tree
,
rootStaticLink
,
isReady
,
reload
}
=
useHeaderConfigModule
();
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
[
expanded
,
setExpanded
]
=
useState
<
Record
<
string
,
boolean
>>
({});
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
search
,
setSearch
]
=
useState
(
""
);
...
@@ -221,7 +226,7 @@ export default function HeaderConfigPage() {
...
@@ -221,7 +226,7 @@ export default function HeaderConfigPage() {
};
};
const
handleSubmit
=
async
()
=>
{
const
handleSubmit
=
async
()
=>
{
if
(
isSubmitting
)
return
;
if
(
isSubmitting
||
isMutating
)
return
;
if
(
isProtectedHomeCategory
(
formValues
.
id
))
{
if
(
isProtectedHomeCategory
(
formValues
.
id
))
{
return
;
return
;
...
@@ -277,10 +282,10 @@ export default function HeaderConfigPage() {
...
@@ -277,10 +282,10 @@ export default function HeaderConfigPage() {
};
};
if
(
formMode
===
"create"
)
{
if
(
formMode
===
"create"
)
{
await
postApiV10Category
(
payload
);
await
createCategory
({
data
:
payload
}
);
toast
.
success
(
"Tạo danh mục thành công"
);
toast
.
success
(
"Tạo danh mục thành công"
);
}
else
if
(
formValues
.
id
)
{
}
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"
);
toast
.
success
(
"Cập nhật danh mục thành công"
);
}
}
...
@@ -295,7 +300,7 @@ export default function HeaderConfigPage() {
...
@@ -295,7 +300,7 @@ export default function HeaderConfigPage() {
};
};
const
handleDelete
=
async
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
||
isSubmitting
)
return
;
if
(
!
deleteTarget
||
isSubmitting
||
isMutating
)
return
;
if
(
isProtectedHomeCategory
(
deleteTarget
.
id
))
{
if
(
isProtectedHomeCategory
(
deleteTarget
.
id
))
{
setDeleteTarget
(
null
);
setDeleteTarget
(
null
);
...
@@ -305,7 +310,7 @@ export default function HeaderConfigPage() {
...
@@ -305,7 +310,7 @@ export default function HeaderConfigPage() {
setIsSubmitting
(
true
);
setIsSubmitting
(
true
);
try
{
try
{
await
delete
ApiV10CategoryId
(
deleteTarget
.
id
);
await
delete
Category
({
id
:
deleteTarget
.
id
}
);
toast
.
success
(
"Xóa danh mục thành công"
);
toast
.
success
(
"Xóa danh mục thành công"
);
setDeleteTarget
(
null
);
setDeleteTarget
(
null
);
await
reload
();
await
reload
();
...
...
src/app/admin/media/_components/media-form-dialog.tsx
View file @
d0f45809
...
@@ -8,7 +8,7 @@ import {
...
@@ -8,7 +8,7 @@ import {
X
,
X
,
}
from
"lucide-react"
;
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
Dialog
,
Dialog
,
...
@@ -103,7 +103,7 @@ export function MediaFormDialog({
...
@@ -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=
"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%)]"
>
<
div
className=
"relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]"
>
{
form
.
previewUrl
?
(
{
form
.
previewUrl
?
(
<
Safe
Image
<
Image
src=
{
form
.
previewUrl
}
src=
{
form
.
previewUrl
}
alt=
{
form
.
name
}
alt=
{
form
.
name
}
fill
fill
...
...
src/app/admin/media/page.tsx
View file @
d0f45809
...
@@ -9,7 +9,7 @@ import {
...
@@ -9,7 +9,7 @@ import {
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
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
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
...
@@ -161,7 +161,7 @@ export default function AdminMediaPage() {
...
@@ -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)]"
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%)]"
>
<
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
)
}
src=
{
resolveCmsFileUrl
(
item
.
path
)
}
alt=
{
item
.
original
??
""
}
alt=
{
item
.
original
??
""
}
fill
fill
...
...
src/app/admin/members/[id]/_components/member-form.tsx
View file @
d0f45809
This diff is collapsed.
Click to expand it.
src/app/admin/members/_components/member-filters.tsx
View file @
d0f45809
...
@@ -7,7 +7,6 @@ import {
...
@@ -7,7 +7,6 @@ import {
SelectTrigger
,
SelectTrigger
,
SelectValue
,
SelectValue
,
}
from
"@/components/ui/select"
;
}
from
"@/components/ui/select"
;
import
type
{
MemberField
,
MemberRegion
}
from
"@/api/vcci-news/types/member"
;
import
{
import
{
selectContentClassName
,
selectContentClassName
,
selectItemClassName
,
selectItemClassName
,
...
@@ -15,8 +14,8 @@ import {
...
@@ -15,8 +14,8 @@ import {
}
from
"./constants"
;
}
from
"./constants"
;
interface
MemberFiltersProps
{
interface
MemberFiltersProps
{
fields
:
MemberField
[];
fields
:
{
id
:
string
;
name
:
string
}
[];
regions
:
MemberRegion
[];
regions
:
{
id
:
string
;
name
:
string
}
[];
fieldFilter
:
string
;
fieldFilter
:
string
;
regionFilter
:
string
;
regionFilter
:
string
;
onFieldFilterChange
:
(
value
:
string
)
=>
void
;
onFieldFilterChange
:
(
value
:
string
)
=>
void
;
...
@@ -35,11 +34,11 @@ export function MemberFilters({
...
@@ -35,11 +34,11 @@ export function MemberFilters({
<
div
className=
"flex flex-col gap-3 lg:flex-row lg:items-center"
>
<
div
className=
"flex flex-col gap-3 lg:flex-row lg:items-center"
>
<
Select
value=
{
fieldFilter
}
onValueChange=
{
onFieldFilterChange
}
>
<
Select
value=
{
fieldFilter
}
onValueChange=
{
onFieldFilterChange
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectValue
placeholder=
"
Lĩnh vực
"
/>
<
SelectValue
placeholder=
"
Doanh nghiệp
"
/>
</
SelectTrigger
>
</
SelectTrigger
>
<
SelectContent
className=
{
selectContentClassName
}
>
<
SelectContent
className=
{
selectContentClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
Tất cả
lĩnh vực
Tất cả
doanh nghiệp
</
SelectItem
>
</
SelectItem
>
{
fields
.
map
((
f
)
=>
(
{
fields
.
map
((
f
)
=>
(
<
SelectItem
key=
{
f
.
id
}
value=
{
f
.
id
}
className=
{
selectItemClassName
}
>
<
SelectItem
key=
{
f
.
id
}
value=
{
f
.
id
}
className=
{
selectItemClassName
}
>
...
@@ -51,11 +50,11 @@ export function MemberFilters({
...
@@ -51,11 +50,11 @@ export function MemberFilters({
<
Select
value=
{
regionFilter
}
onValueChange=
{
onRegionFilterChange
}
>
<
Select
value=
{
regionFilter
}
onValueChange=
{
onRegionFilterChange
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectTrigger
className=
{
selectTriggerClassName
}
>
<
SelectValue
placeholder=
"
Khu vực
"
/>
<
SelectValue
placeholder=
"
Chức vụ
"
/>
</
SelectTrigger
>
</
SelectTrigger
>
<
SelectContent
className=
{
selectContentClassName
}
>
<
SelectContent
className=
{
selectContentClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
<
SelectItem
value=
"all"
className=
{
selectItemClassName
}
>
Tất cả
khu vực
Tất cả
chức vụ
</
SelectItem
>
</
SelectItem
>
{
regions
.
map
((
r
)
=>
(
{
regions
.
map
((
r
)
=>
(
<
SelectItem
key=
{
r
.
id
}
value=
{
r
.
id
}
className=
{
selectItemClassName
}
>
<
SelectItem
key=
{
r
.
id
}
value=
{
r
.
id
}
className=
{
selectItemClassName
}
>
...
...
src/app/admin/members/_components/member-row.tsx
View file @
d0f45809
"use client"
;
"use client"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
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
{
Badge
}
from
"@/components/ui/badge"
;
import
{
import
{
TableCell
,
TableCell
,
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
type
{
MemberItem
}
from
"@/api/vcci-news/types/
member"
;
import
type
{
AdminMemberRow
}
from
"@/lib/utils/admin-
member"
;
interface
MemberRowProps
{
interface
MemberRowProps
{
item
:
MemberItem
;
item
:
AdminMemberRow
;
index
:
number
;
index
:
number
;
fieldName
:
string
|
undefined
;
fieldName
:
string
|
undefined
;
regionName
:
string
|
undefined
;
regionName
:
string
|
undefined
;
...
@@ -33,30 +33,30 @@ export function MemberRow({
...
@@ -33,30 +33,30 @@ export function MemberRow({
>
>
<
TableCell
className=
"px-4 py-3 text-sm font-medium text-gray-800"
>
<
TableCell
className=
"px-4 py-3 text-sm font-medium text-gray-800"
>
<
div
className=
"space-y-1"
>
<
div
className=
"space-y-1"
>
<
div
>
{
item
.
name
}
</
div
>
<
div
>
{
item
.
full_
name
}
</
div
>
{
item
.
is_featured
?
(
{
item
.
job_title
?
(
<
Badge
<
Badge
variant=
"outline"
variant=
"outline"
className=
"border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
className=
"border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
>
>
Hội viên tiêu biểu
{
item
.
job_title
}
</
Badge
>
</
Badge
>
)
:
null
}
)
:
null
}
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
TableCell
className=
"px-4 py-3 text-center"
>
{
item
.
image
?
(
{
item
.
avatar_url
?
(
<
div
className=
"mx-auto h-12 w-1
6 overflow-hidden rounded-lg
border border-[#063e8e]/15"
>
<
div
className=
"mx-auto h-12 w-1
2 overflow-hidden rounded-full
border border-[#063e8e]/15"
>
<
Safe
Image
<
Image
src=
{
item
.
image
.
url
}
src=
{
item
.
avatar_
url
}
alt=
{
item
.
image
.
alt
||
item
.
name
}
alt=
{
item
.
full_
name
}
width=
{
64
}
width=
{
48
}
height=
{
48
}
height=
{
48
}
className=
"h-full w-full object-cover"
className=
"h-full w-full object-cover"
/>
/>
</
div
>
</
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ó
Chưa có
</
div
>
</
div
>
)
}
)
}
...
@@ -68,13 +68,10 @@ export function MemberRow({
...
@@ -68,13 +68,10 @@ export function MemberRow({
{
fieldName
??
"—"
}
{
fieldName
??
"—"
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
{
item
.
phone
&&
<
div
>
{
item
.
phone
}
</
div
>
}
<
span
className=
"line-clamp-2"
>
{
item
.
job_title
||
"—"
}
</
span
>
{
item
.
email
&&
(
<
div
className=
"truncate text-xs text-[#063e8e]"
>
{
item
.
email
}
</
div
>
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
TableCell
className=
"px-4 py-3 text-center text-sm text-gray-600"
>
<
span
className=
"line-clamp-2"
>
{
item
.
address
||
"—"
}
</
span
>
{
item
.
birth_date
||
"—"
}
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
TableCell
className=
"px-4 py-3 text-center"
>
<
AdminRowActions
<
AdminRowActions
...
...
src/app/admin/members/fields/_components/field-form-dialog.tsx
View file @
d0f45809
...
@@ -12,19 +12,18 @@ import {
...
@@ -12,19 +12,18 @@ import {
}
from
"@/components/ui/dialog"
;
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
type
{
MemberField
}
from
"@/api/vcci-news/types/member"
;
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
FieldFormDialogProps
{
interface
FieldFormDialogProps
{
open
:
boolean
;
open
:
boolean
;
initial
:
MemberField
|
null
;
initial
:
{
id
:
string
;
name
:
string
}
|
null
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onSave
:
(
data
:
{
id
?:
string
;
name
:
string
})
=>
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
(
""
);
const
[
name
,
setName
]
=
useState
(
""
);
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
...
@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
const
handleSave
=
()
=>
{
const
handleSave
=
()
=>
{
const
trimmed
=
name
.
trim
();
const
trimmed
=
name
.
trim
();
if
(
!
trimmed
)
{
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
;
return
;
}
}
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
...
@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
...
@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa
lĩnh vực"
:
"Thêm lĩnh vực
mới"
}
{
initial
?
"Chỉnh sửa
doanh nghiệp"
:
"Thêm doanh nghiệp
mới"
}
</
DialogTitle
>
</
DialogTitle
>
</
DialogHeader
>
</
DialogHeader
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-1.5"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
<
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
>
</
Label
>
<
Input
<
Input
value=
{
name
}
value=
{
name
}
onChange=
{
(
event
)
=>
setName
(
event
.
target
.
value
)
}
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
}
className=
{
fieldClassName
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
handleSave
()
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
!
saving
&&
handleSave
()
}
disabled=
{
saving
}
/>
/>
</
div
>
</
div
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
...
@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
...
@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
type=
"button"
type=
"button"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick=
{
handleSave
}
onClick=
{
handleSave
}
disabled=
{
saving
}
>
>
<
Save
className=
"mr-2 h-4 w-4"
/>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu
{
saving
?
"Đang lưu..."
:
"Lưu"
}
</
Button
>
</
Button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
src/app/admin/members/fields/page.tsx
View file @
d0f45809
"use client"
;
"use client"
;
import
{
use
Effect
,
use
Memo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
Plus
}
from
"lucide-react"
;
import
{
Plus
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
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
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
...
@@ -14,26 +21,34 @@ import {
...
@@ -14,26 +21,34 @@ import {
TableHeader
,
TableHeader
,
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
{
import
{
extractRows
}
from
"@/lib/utils/admin-member"
;
type
MemberField
,
createMemberFieldId
,
persistMemberFields
,
readMemberFields
,
}
from
"@/mockdata/members"
;
import
{
FieldFormDialog
}
from
"./_components/field-form-dialog"
;
import
{
FieldFormDialog
}
from
"./_components/field-form-dialog"
;
export
default
function
AdminMemberFieldsPage
()
{
export
default
function
AdminMemberFieldsPage
()
{
const
[
items
,
setItems
]
=
useState
<
MemberField
[]
>
([]
);
const
queryClient
=
useQueryClient
(
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
dialogOpen
,
setDialogOpen
]
=
useState
(
false
);
const
[
dialogOpen
,
setDialogOpen
]
=
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
MemberField
|
null
>
(
null
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
MemberField
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
businessesQuery
=
useGetApiV10Business
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
useEffect
(()
=>
{
const
{
mutateAsync
:
createBusiness
,
isPending
:
isCreating
}
=
usePostApiV10Business
();
setItems
(
readMemberFields
());
const
{
mutateAsync
:
updateBusiness
,
isPending
:
isUpdating
}
=
usePutApiV10BusinessId
();
setReady
(
true
);
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
filtered
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
const
keyword
=
search
.
trim
().
toLowerCase
();
...
@@ -46,46 +61,54 @@ export default function AdminMemberFieldsPage() {
...
@@ -46,46 +61,54 @@ export default function AdminMemberFieldsPage() {
setDialogOpen
(
true
);
setDialogOpen
(
true
);
};
};
const
openEdit
=
(
item
:
MemberField
)
=>
{
const
openEdit
=
(
item
:
{
id
:
string
;
name
:
string
}
)
=>
{
setEditTarget
(
item
);
setEditTarget
(
item
);
setDialogOpen
(
true
);
setDialogOpen
(
true
);
};
};
const
handleSave
=
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
const
handleSave
=
async
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
let
next
:
MemberField
[];
try
{
if
(
data
.
id
)
{
if
(
data
.
id
)
{
next
=
items
.
map
((
item
)
=>
(
item
.
id
===
data
.
id
?
{
...
item
,
name
:
data
.
name
}
:
item
)
);
await
updateBusiness
({
id
:
data
.
id
,
data
:
{
name
:
data
.
name
}
}
);
toast
.
success
(
"Đã cập nhật lĩnh vực
"
);
toast
.
success
(
"Đã cập nhật doanh nghiệp
"
);
}
else
{
}
else
{
next
=
[...
items
,
{
id
:
createMemberFieldId
(),
name
:
data
.
name
}]
;
await
createBusiness
({
data
:
{
name
:
data
.
name
}
})
;
toast
.
success
(
"Đã thêm lĩnh vực
mới"
);
toast
.
success
(
"Đã thêm doanh nghiệp
mới"
);
}
}
queryClient
.
invalidateQueries
();
setItems
(
next
);
persistMemberFields
(
next
);
setDialogOpen
(
false
);
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
;
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
try
{
setItems
(
next
);
await
deleteBusiness
({
id
:
deleteTarget
.
id
}
);
persistMemberFields
(
next
);
queryClient
.
invalidateQueries
(
);
toast
.
success
(
"Đã xóa lĩnh vực
"
);
toast
.
success
(
"Đã xóa doanh nghiệp
"
);
setDeleteTarget
(
null
);
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
(
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"space-y-8"
>
<
AdminTableLayout
<
AdminTableLayout
searchValue=
{
search
}
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm
lĩnh vực
..."
searchPlaceholder=
"Tìm kiếm
doanh nghiệp
..."
actionLabel=
"Thêm
lĩnh vực
"
actionLabel=
"Thêm
doanh nghiệp
"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
<
div
className=
"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
>
</
div
>
}
}
onSearchChange=
{
setSearch
}
onSearchChange=
{
setSearch
}
...
@@ -95,12 +118,12 @@ export default function AdminMemberFieldsPage() {
...
@@ -95,12 +118,12 @@ export default function AdminMemberFieldsPage() {
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-16 py-4 text-center text-white"
>
STT
</
TableHead
>
<
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
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
{
!
ready
?
(
{
!
hasLoaded
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
...
@@ -111,7 +134,7 @@ export default function AdminMemberFieldsPage() {
...
@@ -111,7 +134,7 @@ export default function AdminMemberFieldsPage() {
)
:
filtered
.
length
===
0
?
(
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
<
TableRow
>
<
TableCell
colSpan=
{
3
}
className=
"py-16 text-center text-gray-400"
>
<
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
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
)
:
(
)
:
(
...
@@ -129,8 +152,8 @@ export default function AdminMemberFieldsPage() {
...
@@ -129,8 +152,8 @@ export default function AdminMemberFieldsPage() {
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
AdminRowActions
<
AdminRowActions
actions=
{
[
actions=
{
[
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
lĩnh vực
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
doanh nghiệp
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
lĩnh vực
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
doanh nghiệp
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
]
}
]
}
/>
/>
</
TableCell
>
</
TableCell
>
...
@@ -146,14 +169,15 @@ export default function AdminMemberFieldsPage() {
...
@@ -146,14 +169,15 @@ export default function AdminMemberFieldsPage() {
initial=
{
editTarget
}
initial=
{
editTarget
}
onOpenChange=
{
setDialogOpen
}
onOpenChange=
{
setDialogOpen
}
onSave=
{
handleSave
}
onSave=
{
handleSave
}
saving=
{
saving
||
isDeleting
}
/>
/>
<
AdminDeleteDialog
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
open=
{
!!
deleteTarget
}
title=
"Xóa
lĩnh vực
"
title=
"Xóa
doanh nghiệp
"
description=
{
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ể
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành động này không thể
hoàn tác.
hoàn tác.
</>
</>
...
...
src/app/admin/members/page.tsx
View file @
d0f45809
"use client"
;
"use client"
;
import
{
use
Effect
,
use
Memo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
Plus
,
Users
}
from
"lucide-react"
;
import
{
Plus
,
Users
}
from
"lucide-react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
toast
}
from
"sonner"
;
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
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
...
@@ -16,93 +20,125 @@ import {
...
@@ -16,93 +20,125 @@ import {
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
{
import
{
type
MemberField
,
extractRows
,
type
MemberItem
,
mapApiRowToAdminMemberRow
,
type
MemberRegion
,
type
AdminMemberRow
,
persistMembers
,
}
from
"@/lib/utils/admin-member"
;
readMemberFields
,
readMemberRegions
,
readMembers
,
}
from
"@/mockdata/members"
;
import
{
MemberFilters
}
from
"./_components/member-filters"
;
import
{
MemberFilters
}
from
"./_components/member-filters"
;
import
{
MemberRow
}
from
"./_components/member-row"
;
import
{
MemberRow
}
from
"./_components/member-row"
;
import
{
MemberTableLoading
}
from
"./_components/member-table-loading"
;
import
{
MemberTableLoading
}
from
"./_components/member-table-loading"
;
import
{
useGetApiV10Member
}
from
"@/api/vcci-news/endpoints/member"
;
export
default
function
AdminMembersPage
()
{
export
default
function
AdminMembersPage
()
{
const
router
=
useRouter
();
const
router
=
useRouter
();
const
[
items
,
setItems
]
=
useState
<
MemberItem
[]
>
([]);
const
queryClient
=
useQueryClient
();
const
[
fields
,
setFields
]
=
useState
<
MemberField
[]
>
([]);
const
[
regions
,
setRegions
]
=
useState
<
MemberRegion
[]
>
([]);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
fieldFilter
,
setFieldFilter
]
=
useState
(
"all"
);
const
[
businessFilter
,
setBusinessFilter
]
=
useState
(
"all"
);
const
[
regionFilter
,
setRegionFilter
]
=
useState
(
"all"
);
const
[
positionFilter
,
setPositionFilter
]
=
useState
(
"all"
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
MemberItem
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
AdminMemberRow
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
membersQuery
=
useGetApiV10Member
({
useEffect
(()
=>
{
page
:
1
,
setItems
(
readMembers
());
pageSize
:
200
,
setFields
(
readMemberFields
());
sortField
:
"created_at"
,
setRegions
(
readMemberRegions
());
sortOrder
:
"desc"
,
setReady
(
true
);
});
},
[]);
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
filtered
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
const
keyword
=
search
.
trim
().
toLowerCase
();
return
item
s
.
filter
((
item
)
=>
{
return
member
s
.
filter
((
item
)
=>
{
const
matchesKeyword
=
const
matchesKeyword
=
!
keyword
||
!
keyword
||
item
.
name
.
toLowerCase
().
includes
(
keyword
)
||
item
.
full_name
.
toLowerCase
().
includes
(
keyword
)
||
item
.
email
.
toLowerCase
().
includes
(
keyword
)
||
(
item
.
job_title
??
""
).
toLowerCase
().
includes
(
keyword
);
item
.
address
.
toLowerCase
().
includes
(
keyword
);
const
matchesField
=
fieldFilter
===
"all"
||
item
.
field_id
===
fieldFilter
;
const
matchesBusiness
=
const
matchesRegion
=
regionFilter
===
"all"
||
item
.
region_id
===
regionFilter
;
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
(
const
stats
=
useMemo
(
()
=>
[
()
=>
[
{
{
label
:
"Tổng hội viên"
,
label
:
"Tổng hội viên"
,
value
:
item
s
.
length
,
value
:
member
s
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
},
{
{
label
:
"Số
lĩnh vực
"
,
label
:
"Số
doanh nghiệp
"
,
value
:
field
s
.
length
,
value
:
businesse
s
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
},
{
{
label
:
"Số
khu vực
"
,
label
:
"Số
chức vụ
"
,
value
:
reg
ions
.
length
,
value
:
posit
ions
.
length
,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
icon
:
<
Users
className=
"h-4 w-4 text-[#063e8e]"
/>,
},
},
],
],
[
items
,
fields
,
regions
],
[
members
,
businesses
,
positions
],
);
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
],
);
);
const
handleDelete
=
()
=>
{
const
handleDelete
=
async
()
=>
{
if
(
!
deleteTarget
)
return
;
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
m
)
=>
m
.
id
!==
deleteTarget
.
id
);
try
{
setItems
(
next
);
await
deleteMember
({
id
:
deleteTarget
.
id
}
);
persistMembers
(
next
);
queryClient
.
invalidateQueries
(
);
toast
.
success
(
"Đã xóa hội viên"
);
toast
.
success
(
"Đã xóa hội viên"
);
setDeleteTarget
(
null
);
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
(
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"space-y-8"
>
<
AdminStatsGrid
items=
{
stats
}
/>
<
AdminStatsGrid
items=
{
stats
}
/>
...
@@ -116,12 +152,12 @@ export default function AdminMembersPage() {
...
@@ -116,12 +152,12 @@ export default function AdminMembersPage() {
onActionClick=
{
()
=>
router
.
push
(
"/admin/members/new"
)
}
onActionClick=
{
()
=>
router
.
push
(
"/admin/members/new"
)
}
filters=
{
filters=
{
<
MemberFilters
<
MemberFilters
fields=
{
field
s
}
fields=
{
businesse
s
}
regions=
{
reg
ions
}
regions=
{
posit
ions
}
fieldFilter=
{
field
Filter
}
fieldFilter=
{
business
Filter
}
regionFilter=
{
reg
ionFilter
}
regionFilter=
{
posit
ionFilter
}
onFieldFilterChange=
{
set
Field
Filter
}
onFieldFilterChange=
{
set
Business
Filter
}
onRegionFilterChange=
{
set
Reg
ionFilter
}
onRegionFilterChange=
{
set
Posit
ionFilter
}
/>
/>
}
}
>
>
...
@@ -129,17 +165,17 @@ export default function AdminMembersPage() {
...
@@ -129,17 +165,17 @@ export default function AdminMembersPage() {
<
Table
className=
"min-w-[900px] table-fixed"
>
<
Table
className=
"min-w-[900px] table-fixed"
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-[2
0
0px] py-4 text-center text-white"
>
Tên hội viên
</
TableHead
>
<
TableHead
className=
"w-[2
2
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-[1
60px] py-4 text-center text-white"
>
Ảnh đại diện
</
TableHead
>
<
TableHead
className=
"w-4
0 py-4 text-center text-white"
>
Khu vực
</
TableHead
>
<
TableHead
className=
"w-4
8 py-4 text-center text-white"
>
Chức vụ
</
TableHead
>
<
TableHead
className=
"w-4
0 py-4 text-center text-white"
>
Lĩnh vực
</
TableHead
>
<
TableHead
className=
"w-4
8 py-4 text-center text-white"
>
Doanh nghiệp
</
TableHead
>
<
TableHead
className=
"w-[
200px] py-4 text-center text-white"
>
Liên hệ
</
TableHead
>
<
TableHead
className=
"w-[
180px] py-4 text-center text-white"
>
Chức danh
</
TableHead
>
<
TableHead
className=
"w-[1
80px] py-4 text-center text-white"
>
Địa chỉ
</
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
>
<
TableHead
className=
"w-[100px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
{
!
ready
?
(
{
!
hasLoaded
||
!
ready
?
(
<
MemberTableLoading
/>
<
MemberTableLoading
/>
)
:
filtered
.
length
===
0
?
(
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
<
TableRow
>
...
@@ -153,8 +189,8 @@ export default function AdminMembersPage() {
...
@@ -153,8 +189,8 @@ export default function AdminMembersPage() {
key=
{
item
.
id
}
key=
{
item
.
id
}
item=
{
item
}
item=
{
item
}
index=
{
index
}
index=
{
index
}
fieldName=
{
fieldMap
[
item
.
field_id
]
}
fieldName=
{
item
.
business_id
?
businessMap
[
item
.
business_id
]
:
undefined
}
regionName=
{
regionMap
[
item
.
region_id
]
}
regionName=
{
item
.
position_id
?
positionMap
[
item
.
position_id
]
:
undefined
}
onEdit=
{
()
=>
router
.
push
(
`/admin/members/${item.id}`
)
}
onEdit=
{
()
=>
router
.
push
(
`/admin/members/${item.id}`
)
}
onDelete=
{
()
=>
setDeleteTarget
(
item
)
}
onDelete=
{
()
=>
setDeleteTarget
(
item
)
}
/>
/>
...
@@ -171,7 +207,7 @@ export default function AdminMembersPage() {
...
@@ -171,7 +207,7 @@ export default function AdminMembersPage() {
description=
{
description=
{
<>
<>
Bạn có chắc muốn xóa hội viên
{
" "
}
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.
hoàn tác.
</>
</>
}
}
...
...
src/app/admin/members/regions/_components/region-form-dialog.tsx
View file @
d0f45809
...
@@ -12,19 +12,18 @@ import {
...
@@ -12,19 +12,18 @@ import {
}
from
"@/components/ui/dialog"
;
}
from
"@/components/ui/dialog"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
{
Label
}
from
"@/components/ui/label"
;
import
type
{
MemberRegion
}
from
"@/api/vcci-news/types/member"
;
const
fieldClassName
=
const
fieldClassName
=
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"
;
interface
RegionFormDialogProps
{
interface
RegionFormDialogProps
{
open
:
boolean
;
open
:
boolean
;
initial
:
MemberRegion
|
null
;
initial
:
{
id
:
string
;
name
:
string
}
|
null
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onOpenChange
:
(
open
:
boolean
)
=>
void
;
onSave
:
(
data
:
{
id
?:
string
;
name
:
string
})
=>
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
(
""
);
const
[
name
,
setName
]
=
useState
(
""
);
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
...
@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
const
handleSave
=
()
=>
{
const
handleSave
=
()
=>
{
const
trimmed
=
name
.
trim
();
const
trimmed
=
name
.
trim
();
if
(
!
trimmed
)
{
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
;
return
;
}
}
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
onSave
({
id
:
initial
?.
id
,
name
:
trimmed
});
...
@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
...
@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogContent
className=
"max-w-md border-[#063e8e]/15 bg-white"
>
<
DialogHeader
>
<
DialogHeader
>
<
DialogTitle
className=
"text-[#063e8e]"
>
<
DialogTitle
className=
"text-[#063e8e]"
>
{
initial
?
"Chỉnh sửa
khu vực"
:
"Thêm khu vực
mới"
}
{
initial
?
"Chỉnh sửa
chức vụ"
:
"Thêm chức vụ
mới"
}
</
DialogTitle
>
</
DialogTitle
>
</
DialogHeader
>
</
DialogHeader
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-4 pt-2"
>
<
div
className=
"space-y-1.5"
>
<
div
className=
"space-y-1.5"
>
<
Label
className=
"text-gray-700"
>
<
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
>
</
Label
>
<
Input
<
Input
value=
{
name
}
value=
{
name
}
onChange=
{
(
event
)
=>
setName
(
event
.
target
.
value
)
}
onChange=
{
(
event
)
=>
setName
(
event
.
target
.
value
)
}
placeholder=
"Nhập tên
khu vực
..."
placeholder=
"Nhập tên
chức vụ
..."
className=
{
fieldClassName
}
className=
{
fieldClassName
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
handleSave
()
}
onKeyDown=
{
(
event
)
=>
event
.
key
===
"Enter"
&&
!
saving
&&
handleSave
()
}
disabled=
{
saving
}
/>
/>
</
div
>
</
div
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
<
div
className=
"flex justify-end gap-2 pt-2"
>
...
@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
...
@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
type=
"button"
type=
"button"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
className=
"bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick=
{
handleSave
}
onClick=
{
handleSave
}
disabled=
{
saving
}
>
>
<
Save
className=
"mr-2 h-4 w-4"
/>
<
Save
className=
"mr-2 h-4 w-4"
/>
Lưu
{
saving
?
"Đang lưu..."
:
"Lưu"
}
</
Button
>
</
Button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
...
src/app/admin/members/regions/page.tsx
View file @
d0f45809
"use client"
;
"use client"
;
import
{
use
Effect
,
use
Memo
,
useState
}
from
"react"
;
import
{
useMemo
,
useState
}
from
"react"
;
import
{
Plus
}
from
"lucide-react"
;
import
{
Plus
}
from
"lucide-react"
;
import
{
toast
}
from
"sonner"
;
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
{
AdminDeleteDialog
}
from
"@/components/admin/admin-delete-dialog"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
...
@@ -14,26 +21,34 @@ import {
...
@@ -14,26 +21,34 @@ import {
TableHeader
,
TableHeader
,
TableRow
,
TableRow
,
}
from
"@/components/ui/table"
;
}
from
"@/components/ui/table"
;
import
{
import
{
extractRows
}
from
"@/lib/utils/admin-member"
;
type
MemberRegion
,
createMemberRegionId
,
persistMemberRegions
,
readMemberRegions
,
}
from
"@/mockdata/members"
;
import
{
RegionFormDialog
}
from
"./_components/region-form-dialog"
;
import
{
RegionFormDialog
}
from
"./_components/region-form-dialog"
;
export
default
function
AdminMemberRegionsPage
()
{
export
default
function
AdminMemberRegionsPage
()
{
const
[
items
,
setItems
]
=
useState
<
MemberRegion
[]
>
([]
);
const
queryClient
=
useQueryClient
(
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
dialogOpen
,
setDialogOpen
]
=
useState
(
false
);
const
[
dialogOpen
,
setDialogOpen
]
=
useState
(
false
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
MemberRegion
|
null
>
(
null
);
const
[
editTarget
,
setEditTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
MemberRegion
|
null
>
(
null
);
const
[
deleteTarget
,
setDeleteTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
);
const
[
ready
,
setReady
]
=
useState
(
false
);
const
positionsQuery
=
useGetApiV10Position
({
page
:
1
,
pageSize
:
200
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
});
useEffect
(()
=>
{
const
{
mutateAsync
:
createPosition
,
isPending
:
isCreating
}
=
usePostApiV10Position
();
setItems
(
readMemberRegions
());
const
{
mutateAsync
:
updatePosition
,
isPending
:
isUpdating
}
=
usePutApiV10PositionId
();
setReady
(
true
);
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
filtered
=
useMemo
(()
=>
{
const
keyword
=
search
.
trim
().
toLowerCase
();
const
keyword
=
search
.
trim
().
toLowerCase
();
...
@@ -46,46 +61,54 @@ export default function AdminMemberRegionsPage() {
...
@@ -46,46 +61,54 @@ export default function AdminMemberRegionsPage() {
setDialogOpen
(
true
);
setDialogOpen
(
true
);
};
};
const
openEdit
=
(
item
:
MemberRegion
)
=>
{
const
openEdit
=
(
item
:
{
id
:
string
;
name
:
string
}
)
=>
{
setEditTarget
(
item
);
setEditTarget
(
item
);
setDialogOpen
(
true
);
setDialogOpen
(
true
);
};
};
const
handleSave
=
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
const
handleSave
=
async
(
data
:
{
id
?:
string
;
name
:
string
})
=>
{
let
next
:
MemberRegion
[];
try
{
if
(
data
.
id
)
{
if
(
data
.
id
)
{
next
=
items
.
map
((
item
)
=>
(
item
.
id
===
data
.
id
?
{
...
item
,
name
:
data
.
name
}
:
item
)
);
await
updatePosition
({
id
:
data
.
id
,
data
:
{
name
:
data
.
name
}
}
);
toast
.
success
(
"Đã cập nhật khu vực
"
);
toast
.
success
(
"Đã cập nhật chức vụ
"
);
}
else
{
}
else
{
next
=
[...
items
,
{
id
:
createMemberRegionId
(),
name
:
data
.
name
}]
;
await
createPosition
({
data
:
{
name
:
data
.
name
}
})
;
toast
.
success
(
"Đã thêm khu vực
mới"
);
toast
.
success
(
"Đã thêm chức vụ
mới"
);
}
}
queryClient
.
invalidateQueries
();
setItems
(
next
);
persistMemberRegions
(
next
);
setDialogOpen
(
false
);
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
;
if
(
!
deleteTarget
)
return
;
const
next
=
items
.
filter
((
item
)
=>
item
.
id
!==
deleteTarget
.
id
);
try
{
setItems
(
next
);
await
deletePosition
({
id
:
deleteTarget
.
id
}
);
persistMemberRegions
(
next
);
queryClient
.
invalidateQueries
(
);
toast
.
success
(
"Đã xóa khu vực
"
);
toast
.
success
(
"Đã xóa chức vụ
"
);
setDeleteTarget
(
null
);
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
(
return
(
<
div
className=
"space-y-8"
>
<
div
className=
"space-y-8"
>
<
AdminTableLayout
<
AdminTableLayout
searchValue=
{
search
}
searchValue=
{
search
}
searchPlaceholder=
"Tìm kiếm
khu vực
..."
searchPlaceholder=
"Tìm kiếm
chức vụ
..."
actionLabel=
"Thêm
khu vực
"
actionLabel=
"Thêm
chức vụ
"
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionIcon=
{
<
Plus
className=
"mr-2 h-4 w-4"
/>
}
actionMeta=
{
actionMeta=
{
<
div
className=
"text-sm font-medium text-gray-700"
>
<
div
className=
"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
>
</
div
>
}
}
onSearchChange=
{
setSearch
}
onSearchChange=
{
setSearch
}
...
@@ -95,12 +118,12 @@ export default function AdminMemberRegionsPage() {
...
@@ -95,12 +118,12 @@ export default function AdminMemberRegionsPage() {
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
TableHead
className=
"w-16 py-4 text-center text-white"
>
STT
</
TableHead
>
<
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
>
<
TableHead
className=
"w-[120px] py-4 text-center text-white"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
{
!
ready
?
(
{
!
hasLoaded
?
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
Array
.
from
({
length
:
3
}).
map
((
_
,
index
)
=>
(
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableRow
key=
{
`loading-${index}`
}
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
<
TableCell
colSpan=
{
3
}
className=
"px-4 py-4"
>
...
@@ -111,7 +134,7 @@ export default function AdminMemberRegionsPage() {
...
@@ -111,7 +134,7 @@ export default function AdminMemberRegionsPage() {
)
:
filtered
.
length
===
0
?
(
)
:
filtered
.
length
===
0
?
(
<
TableRow
>
<
TableRow
>
<
TableCell
colSpan=
{
3
}
className=
"py-16 text-center text-gray-400"
>
<
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
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
)
:
(
)
:
(
...
@@ -129,8 +152,8 @@ export default function AdminMemberRegionsPage() {
...
@@ -129,8 +152,8 @@ export default function AdminMemberRegionsPage() {
<
TableCell
className=
"py-3 text-center"
>
<
TableCell
className=
"py-3 text-center"
>
<
AdminRowActions
<
AdminRowActions
actions=
{
[
actions=
{
[
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
khu vực
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"edit"
,
label
:
"Chỉnh sửa
chức vụ
"
,
onClick
:
()
=>
openEdit
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
khu vực
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
{
kind
:
"delete"
,
label
:
"Xóa
chức vụ
"
,
onClick
:
()
=>
setDeleteTarget
(
item
)
},
]
}
]
}
/>
/>
</
TableCell
>
</
TableCell
>
...
@@ -146,14 +169,15 @@ export default function AdminMemberRegionsPage() {
...
@@ -146,14 +169,15 @@ export default function AdminMemberRegionsPage() {
initial=
{
editTarget
}
initial=
{
editTarget
}
onOpenChange=
{
setDialogOpen
}
onOpenChange=
{
setDialogOpen
}
onSave=
{
handleSave
}
onSave=
{
handleSave
}
saving=
{
saving
||
isDeleting
}
/>
/>
<
AdminDeleteDialog
<
AdminDeleteDialog
open=
{
!!
deleteTarget
}
open=
{
!!
deleteTarget
}
title=
"Xóa
khu vực
"
title=
"Xóa
chức vụ
"
description=
{
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ể
<
span
className=
"font-semibold"
>
{
deleteTarget
?.
name
}
</
span
>
? Hành động này không thể
hoàn tác.
hoàn tác.
</>
</>
...
...
src/app/admin/news/[id]/_components/admin-news-form-content.tsx
View file @
d0f45809
This diff is collapsed.
Click to expand it.
src/app/admin/news/_components/utils.ts
View file @
d0f45809
import
{
useEffect
,
useState
}
from
"react"
;
import
{
useEffect
,
useState
}
from
"react"
;
import
dayjs
from
"dayjs"
;
import
dayjs
from
"dayjs"
;
import
{
import
type
{
AdminNewsItem
}
from
"@/api/vcci-news/types/post"
;
type
AdminNewsItem
,
import
type
{
}
from
"@/mockdata/admin-news"
;
HeaderCategoryItem
,
import
{
HeaderCategoryTreeItem
,
type
HeaderCategoryItem
,
}
from
"@/api/vcci-news/types/header-config"
;
type
HeaderCategoryTreeItem
,
}
from
"@/mockdata/header-config"
;
export
type
FlattenedHeaderCategory
=
HeaderCategoryItem
&
{
depth
:
number
};
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";
...
@@ -15,7 +15,7 @@ import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminRowActions
}
from
"@/components/admin/admin-row-actions"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminStatsGrid
}
from
"@/components/admin/admin-stats-grid"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
AdminTableLayout
}
from
"@/components/admin/admin-table-layout"
;
import
{
SafeImage
}
from
"@/components/shared/safe-
image"
;
import
Image
from
"next/
image"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Badge
}
from
"@/components/ui/badge"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
import
{
...
@@ -44,14 +44,12 @@ import {
...
@@ -44,14 +44,12 @@ import {
ADMIN_NEWS_TYPE_LABELS
,
ADMIN_NEWS_TYPE_LABELS
,
ADMIN_NEWS_TYPE_OPTIONS
,
ADMIN_NEWS_TYPE_OPTIONS
,
type
AdminNewsItem
,
type
AdminNewsItem
,
}
from
"@/
mockdata/admin-news
"
;
}
from
"@/
api/vcci-news/types/post
"
;
import
{
normalizeUser
}
from
"@/lib/utils/cms-user"
;
import
{
normalizeUser
}
from
"@/lib/utils/cms-user"
;
import
{
normalizeDateTimeInput
}
from
"@/lib/utils/datetime"
;
import
{
normalizeDateTimeInput
}
from
"@/lib/utils/datetime"
;
import
{
parsePostContent
,
parseLegacyPostContent
}
from
"@/lib/utils/post-content"
;
import
{
parsePostContent
,
parseLegacyPostContent
}
from
"@/lib/utils/post-content"
;
import
links
from
"@/links"
;
import
links
from
"@/links"
;
import
{
import
type
{
HeaderCategoryTreeItem
}
from
"@/api/vcci-news/types/header-config"
;
type
HeaderCategoryTreeItem
,
}
from
"@/mockdata/header-config"
;
import
{
AdminNewsTableLoading
}
from
"./_components/admin-news-table-loading"
;
import
{
AdminNewsTableLoading
}
from
"./_components/admin-news-table-loading"
;
import
{
CategoryFilterCombobox
}
from
"./_components/category-filter-combobox"
;
import
{
CategoryFilterCombobox
}
from
"./_components/category-filter-combobox"
;
import
{
import
{
...
@@ -468,7 +466,7 @@ export default function AdminNewsPage() {
...
@@ -468,7 +466,7 @@ export default function AdminNewsPage() {
<
TableCell
className=
"text-center"
>
<
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]"
>
<
div
className=
"relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]"
>
{
item
.
thumbnail
?
(
{
item
.
thumbnail
?
(
<
Safe
Image
<
Image
src=
{
item
.
thumbnail
.
url
}
src=
{
item
.
thumbnail
.
url
}
alt=
{
item
.
thumbnail
.
alt
||
item
.
thumbnail
.
name
}
alt=
{
item
.
thumbnail
.
alt
||
item
.
thumbnail
.
name
}
fill
fill
...
...
src/app/admin/password-reset-requests/page.tsx
View file @
d0f45809
...
@@ -198,25 +198,25 @@ export default function PasswordResetRequestsPage() {
...
@@ -198,25 +198,25 @@ export default function PasswordResetRequestsPage() {
<
Table
>
<
Table
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
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
STT
</
TableHead
>
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Email
Email
</
TableHead
>
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Ghi chú
Ghi chú
</
TableHead
>
</
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
Trạng thái
</
TableHead
>
</
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
Thời gian
</
TableHead
>
</
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ý
Người xử lý
</
TableHead
>
</
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
Thao tác
</
TableHead
>
</
TableHead
>
</
TableRow
>
</
TableRow
>
...
@@ -224,7 +224,7 @@ export default function PasswordResetRequestsPage() {
...
@@ -224,7 +224,7 @@ export default function PasswordResetRequestsPage() {
<
TableBody
>
<
TableBody
>
{
rows
.
length
===
0
?
(
{
rows
.
length
===
0
?
(
<
TableRow
>
<
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
Không có yêu cầu nào
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
...
@@ -232,66 +232,62 @@ export default function PasswordResetRequestsPage() {
...
@@ -232,66 +232,62 @@ export default function PasswordResetRequestsPage() {
rows
.
map
((
req
,
index
)
=>
(
rows
.
map
((
req
,
index
)
=>
(
<
TableRow
<
TableRow
key=
{
req
.
id
}
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
}
{
(
currentPage
-
1
)
*
PAGE_SIZE
+
index
+
1
}
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex items-center gap-2"
>
<
Mail
className=
"h-4 w-4 text-
slate
-400 shrink-0"
/>
<
Mail
className=
"h-4 w-4 text-
gray
-400 shrink-0"
/>
<
span
className=
"t
ext-sm font-medium text-[#163b73]"
>
<
span
className=
"t
runcate max-w-[200px]"
title=
{
req
.
email
}
>
{
req
.
email
}
{
req
.
email
}
</
span
>
</
span
>
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
p
className=
"text-sm text-slate-600 truncate max-w-[200px]"
>
<
p
className=
"truncate max-w-[200px]"
>
{
req
.
note
||
"—"
}
</
p
>
{
req
.
note
||
"-"
}
</
p
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
TableCell
className=
"
py-3
text-center"
>
{
req
.
status
===
"PENDING"
&&
(
{
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"
/>
<
Clock
className=
"mr-1 h-4 w-4"
/>
Chờ xử lý
Chờ xử lý
</
Badge
>
</
span
>
)
}
)
}
{
req
.
status
===
"RESOLVED"
&&
(
{
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"
/>
<
CheckCircle
className=
"mr-1 h-4 w-4"
/>
Đã xử lý
Đã xử lý
</
Badge
>
</
span
>
)
}
)
}
{
req
.
status
===
"REJECTED"
&&
(
{
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"
/>
<
XCircle
className=
"mr-1 h-4 w-4"
/>
Từ chối
Từ chối
</
Badge
>
</
span
>
)
}
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
<
p
className=
"text-sm text-slate-500 whitespace-nowrap"
>
{
formatDate
(
req
.
created_at
)
}
{
formatDate
(
req
.
created_at
)
}
</
p
>
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
{
req
.
resolved_by_user
?
(
{
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
}
{
req
.
resolved_by_user
.
email
}
</
span
>
</
span
>
)
:
(
)
:
(
<
span
className=
"text-
sm text-slate-400"
>
-
</
span
>
<
span
className=
"text-
gray-400"
>
—
</
span
>
)
}
)
}
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
{
req
.
status
===
"PENDING"
&&
canWrite
?
(
{
req
.
status
===
"PENDING"
&&
canWrite
?
(
<
div
className=
"flex items-center gap-1"
>
<
div
className=
"flex items-center
justify-center
gap-1"
>
<
Button
<
Button
variant=
"ghost"
variant=
"ghost"
size=
"sm"
size=
"sm"
onClick=
{
()
=>
handleOpenResolve
(
req
)
}
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"
title=
"Reset mật khẩu"
>
>
<
KeyRound
className=
"h-4 w-4"
/>
<
KeyRound
className=
"h-4 w-4"
/>
...
@@ -300,14 +296,14 @@ export default function PasswordResetRequestsPage() {
...
@@ -300,14 +296,14 @@ export default function PasswordResetRequestsPage() {
variant=
"ghost"
variant=
"ghost"
size=
"sm"
size=
"sm"
onClick=
{
()
=>
handleOpenReject
(
req
)
}
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"
title=
"Từ chối"
>
>
<
XCircle
className=
"h-4 w-4"
/>
<
XCircle
className=
"h-4 w-4"
/>
</
Button
>
</
Button
>
</
div
>
</
div
>
)
:
(
)
:
(
<
span
className=
"text-sm text-
slate-400"
>
-
</
span
>
<
span
className=
"text-sm text-
gray-400"
>
—
</
span
>
)
}
)
}
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
...
...
src/app/admin/users/_components/UserTable.tsx
View file @
d0f45809
...
@@ -60,14 +60,14 @@ export function UserTable({
...
@@ -60,14 +60,14 @@ export function UserTable({
<
Table
>
<
Table
>
<
TableHeader
>
<
TableHeader
>
<
TableRow
className=
"border-0 bg-[#063e8e] hover:bg-[#063e8e]"
>
<
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=
"
w-16 py-4 text-center text-white
"
>
STT
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
Email
</
TableHead
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Email
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
Họ tên
</
TableHead
>
<
TableHead
className=
"py-
4 text-left text-white
"
>
Họ tên
</
TableHead
>
<
TableHead
className=
"py-
3 text-white text-sm font-semibold whitespace-nowrap
"
>
Vai trò
</
TableHead
>
<
TableHead
className=
"py-
4 text-center text-white
"
>
Vai trò
</
TableHead
>
<
TableHead
className=
"
py-3 text-center text-white text-sm font-semibold whitespace-nowrap
"
>
Trạng thái
</
TableHead
>
<
TableHead
className=
"
w-[140px] py-4 text-center text-white
"
>
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=
"
w-[170px] py-4 text-center text-white
"
>
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-[170px] py-4 text-center text-white
"
>
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-
[130px] py-4 text-center text-white
"
>
Thao tác
</
TableHead
>
</
TableRow
>
</
TableRow
>
</
TableHeader
>
</
TableHeader
>
<
TableBody
>
<
TableBody
>
...
@@ -79,7 +79,7 @@ export function UserTable({
...
@@ -79,7 +79,7 @@ export function UserTable({
</
TableRow
>
</
TableRow
>
)
:
users
.
length
===
0
?
(
)
:
users
.
length
===
0
?
(
<
TableRow
>
<
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
Không tìm thấy người dùng nào
</
TableCell
>
</
TableCell
>
</
TableRow
>
</
TableRow
>
...
@@ -87,58 +87,58 @@ export function UserTable({
...
@@ -87,58 +87,58 @@ export function UserTable({
users
.
map
((
user
,
index
)
=>
(
users
.
map
((
user
,
index
)
=>
(
<
TableRow
<
TableRow
key=
{
user
.
id
}
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
}
{
(
currentPage
-
1
)
*
PAGE_SIZE
+
index
+
1
}
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-sm text-gray-800"
>
<
p
className=
"t
ext-sm text-slate-700 t
runcate max-w-[180px]"
title=
{
user
.
email
}
>
<
p
className=
"truncate max-w-[180px]"
title=
{
user
.
email
}
>
{
user
.
email
}
{
user
.
email
}
</
p
>
</
p
>
{
user
.
username
&&
(
{
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
>
<
TableCell
>
<
TableCell
className=
"py-3 text-left text-sm text-gray-800"
>
<
p
className=
"
text-sm font-medium text-[#163b73]
whitespace-nowrap"
>
<
p
className=
"whitespace-nowrap"
>
{
user
.
first_name
||
user
.
last_name
?
`${user.first_name || ""} ${user.last_name || ""}`
.
trim
()
:
"
-
"
}
{
user
.
first_name
||
user
.
last_name
?
`${user.first_name || ""} ${user.last_name || ""}`
.
trim
()
:
"
—
"
}
</
p
>
</
p
>
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
div
className=
"flex flex-wrap
gap-1 max-w-[120px]
"
>
<
div
className=
"flex flex-wrap
justify-center gap-1 max-w-[140px] mx-auto
"
>
{
user
.
roles
?.
slice
(
0
,
2
).
map
((
roleName
)
=>
(
{
user
.
roles
?.
slice
(
0
,
2
).
map
((
roleName
)
=>
(
<
Badge
<
Badge
key=
{
roleName
}
key=
{
roleName
}
variant=
"
secondary
"
variant=
"
outline
"
className=
"border
border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5 whitespace-nowrap
"
className=
"border
-[#063e8e]/25 text-[#063e8e] text-xs px-2 py-0.5 whitespace-nowrap font-normal
"
>
>
{
roleName
}
{
roleName
}
</
Badge
>
</
Badge
>
))
}
))
}
{
(
user
.
roles
?.
length
||
0
)
>
2
&&
(
{
(
user
.
roles
?.
length
||
0
)
>
2
&&
(
<
Badge
<
Badge
variant=
"
secondary
"
variant=
"
outline
"
className=
"border
border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5
"
className=
"border
-[#063e8e]/25 text-[#063e8e] text-xs px-2 py-0.5 font-normal
"
>
>
+
{
user
.
roles
!
.
length
-
2
}
+
{
user
.
roles
!
.
length
-
2
}
</
Badge
>
</
Badge
>
)
}
)
}
{
(
!
user
.
roles
||
user
.
roles
.
length
===
0
)
&&
(
{
(
!
user
.
roles
||
user
.
roles
.
length
===
0
)
&&
(
<
span
className=
"text-sm text-
slate-400"
>
-
</
span
>
<
span
className=
"text-sm text-
gray-500"
>
—
</
span
>
)
}
)
}
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
TableCell
className=
"
py-3
text-center"
>
<
div
className=
"flex flex-col items-center gap-1"
>
<
div
className=
"flex flex-col items-center gap-1"
>
<
Badge
<
span
variant=
"outline"
className=
{
className=
{
`text-sm whitespace-nowrap ${
user.status === "active"
user
.
status
===
"active"
? "
border-green-200 bg-green-50 text-green-700
"
?
"
inline-flex rounded-full bg-[#edf7ee] px-3 py-1 text-xs font-semibold text-[#16803d]
"
:
user
.
status
===
"pending_verification"
:
user
.
status
===
"pending_verification"
? "
border-amber-200 bg-amber-50 text-amber-700
"
?
"
inline-flex rounded-full bg-[#fff4e5] px-3 py-1 text-xs font-semibold text-[#c7760d]
"
: "
border-red-200 bg-red-50
text-red-700"
:
"
inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-semibold
text-red-700"
}`
}
}
>
>
{
user
.
status
===
"active"
?
(
{
user
.
status
===
"active"
?
(
<
CheckCircle
className=
"mr-1 h-4 w-4"
/>
<
CheckCircle
className=
"mr-1 h-4 w-4"
/>
...
@@ -150,35 +150,31 @@ export function UserTable({
...
@@ -150,35 +150,31 @@ export function UserTable({
{
user
.
status
===
"active"
?
"Hoạt động"
:
{
user
.
status
===
"active"
?
"Hoạt động"
:
user
.
status
===
"pending_verification"
?
"Chờ duyệt"
:
user
.
status
===
"pending_verification"
?
"Chờ duyệt"
:
user
.
status
===
"inactive"
?
"Khóa"
:
user
.
status
}
user
.
status
===
"inactive"
?
"Khóa"
:
user
.
status
}
</
Badge
>
</
span
>
{
user
.
user_auth
?.
must_change_password
&&
(
{
user
.
user_auth
?.
must_change_password
&&
(
<
Badge
<
Badge
variant=
"outline"
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"
/>
<
Key
className=
"mr-0.5 h-3 w-3"
/>
</
Badge
>
</
Badge
>
)
}
)
}
</
div
>
</
div
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
<
p
className=
"text-sm text-slate-500 whitespace-nowrap"
>
{
user
.
created_at
?
formatDate
(
user
.
created_at
)
:
"—"
}
{
user
.
created_at
?
formatDate
(
user
.
created_at
)
:
"-"
}
</
p
>
</
TableCell
>
</
TableCell
>
<
TableCell
className=
"text-center"
>
<
TableCell
className=
"py-3 text-center text-sm text-gray-700"
>
<
p
className=
"text-sm text-slate-500 whitespace-nowrap"
>
{
user
.
updated_at
?
formatDate
(
user
.
updated_at
)
:
"—"
}
{
user
.
updated_at
?
formatDate
(
user
.
updated_at
)
:
"-"
}
</
p
>
</
TableCell
>
</
TableCell
>
<
TableCell
>
<
TableCell
className=
"py-3 text-center"
>
<
PermissionGate
required=
"users:write"
>
<
PermissionGate
required=
"users:write"
>
<
DropdownMenu
>
<
DropdownMenu
>
<
DropdownMenuTrigger
asChild
>
<
DropdownMenuTrigger
asChild
>
<
Button
<
Button
variant=
"ghost"
variant=
"ghost"
size=
"sm"
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"
/>
<
MoreHorizontal
className=
"h-4 w-4"
/>
</
Button
>
</
Button
>
...
...
src/components/admin/image-picker.tsx
View file @
d0f45809
...
@@ -2,6 +2,7 @@
...
@@ -2,6 +2,7 @@
import
*
as
React
from
"react"
;
import
*
as
React
from
"react"
;
import
{
ImagePlus
,
Search
,
Upload
,
X
}
from
"lucide-react"
;
import
{
ImagePlus
,
Search
,
Upload
,
X
}
from
"lucide-react"
;
import
Image
from
"next/image"
;
import
{
toast
}
from
"sonner"
;
import
{
toast
}
from
"sonner"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Input
}
from
"@/components/ui/input"
;
import
{
Input
}
from
"@/components/ui/input"
;
...
@@ -12,12 +13,12 @@ import {
...
@@ -12,12 +13,12 @@ import {
DialogHeader
,
DialogHeader
,
DialogTitle
,
DialogTitle
,
}
from
"@/components/ui/dialog"
;
}
from
"@/components/ui/dialog"
;
import
{
SafeImage
}
from
"@/components/shared/safe-image"
;
import
type
{
AdminMediaItem
}
from
"@/mockdata/admin-news"
;
import
type
{
AdminMediaItem
}
from
"@/mockdata/admin-news"
;
import
{
toAdminMediaItem
}
from
"@/lib/utils/file"
;
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
{
Pagination
}
from
"@/components/base/pagination"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
ChangeEvent
,
useEffect
,
useRef
,
useState
}
from
"react"
;
const
PAGE_SIZE
=
10
;
const
PAGE_SIZE
=
10
;
...
@@ -41,68 +42,55 @@ export function AdminImagePicker({
...
@@ -41,68 +42,55 @@ export function AdminImagePicker({
onOpenChange
,
onOpenChange
,
onSelect
,
onSelect
,
}:
AdminImagePickerProps
)
{
}:
AdminImagePickerProps
)
{
const
inputRef
=
React
.
useRef
<
HTMLInputElement
|
null
>
(
null
);
const
inputRef
=
useRef
<
HTMLInputElement
|
null
>
(
null
);
const
[
search
,
setSearch
]
=
React
.
useState
(
""
);
const
[
search
,
setSearch
]
=
useState
(
""
);
const
[
items
,
setItems
]
=
React
.
useState
<
AdminMediaItem
[]
>
([]);
const
[
items
,
setItems
]
=
useState
<
AdminMediaItem
[]
>
([]);
const
[
page
,
setPage
]
=
React
.
useState
(
1
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
total
,
setTotal
]
=
React
.
useState
(
0
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
ready
,
setReady
]
=
React
.
useState
(
false
);
const
fileQuery
=
useGetApiV10File
(
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
({
page
,
page
,
pageSize
:
PAGE_SIZE
,
pageSize
:
PAGE_SIZE
,
sortField
:
"created_at"
,
sortField
:
"created_at"
,
sortOrder
:
"desc"
,
sortOrder
:
"desc"
,
filters
,
filters
:
[
});
"mime@=image"
,
const
pageData
=
response
.
responseData
??
{};
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
));
setItems
((
pageData
.
rows
??
[]).
map
(
toAdminMediaItem
));
setTotal
(
pageData
.
count
??
0
);
setTotal
(
pageData
.
count
??
0
);
}
catch
(
error
)
{
},
[
fileQuery
.
data
]);
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải thư viện hình ảnh"
);
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
([]);
setItems
([]);
setTotal
(
0
);
setTotal
(
0
);
}
finally
{
},
[
fileQuery
.
error
]);
setReady
(
true
);
}
},
[
open
,
page
,
search
]);
React
.
useEffect
(()
=>
{
void
load
();
},
[
load
]);
React
.
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
open
)
return
;
if
(
!
open
)
return
;
setPage
(
1
);
setPage
(
1
);
},
[
open
,
search
]);
},
[
open
,
search
]);
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
PAGE_SIZE
));
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
];
const
file
=
event
.
target
.
files
?.[
0
];
if
(
!
file
)
return
;
if
(
!
file
)
return
;
setUploading
(
true
);
try
{
try
{
const
response
=
await
postApiV10FileUpload
({
const
response
=
await
uploadMutation
.
mutateAsync
({
data
:
{
file
,
original
:
file
.
name
}
});
file
,
original
:
file
.
name
,
});
const
uploaded
=
response
.
responseData
??
null
;
const
uploaded
=
response
.
responseData
??
null
;
if
(
!
uploaded
)
{
if
(
!
uploaded
)
{
...
@@ -116,7 +104,6 @@ export function AdminImagePicker({
...
@@ -116,7 +104,6 @@ export function AdminImagePicker({
}
catch
(
error
)
{
}
catch
(
error
)
{
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải hình ảnh lên"
);
toast
.
error
(
error
instanceof
Error
?
error
.
message
:
"Không thể tải hình ảnh lên"
);
}
finally
{
}
finally
{
setUploading
(
false
);
event
.
target
.
value
=
""
;
event
.
target
.
value
=
""
;
}
}
};
};
...
@@ -210,7 +197,7 @@ export function AdminImagePicker({
...
@@ -210,7 +197,7 @@ export function AdminImagePicker({
)
}
)
}
>
>
<
div
className=
"relative aspect-[4/3] overflow-hidden bg-[#063e8e]/[0.04]"
>
<
div
className=
"relative aspect-[4/3] overflow-hidden bg-[#063e8e]/[0.04]"
>
<
Safe
Image
<
Image
src=
{
item
.
url
}
src=
{
item
.
url
}
alt=
{
item
.
alt
||
item
.
name
}
alt=
{
item
.
alt
||
item
.
name
}
fill
fill
...
...
src/components/layout/main/header.tsx
View file @
d0f45809
...
@@ -2,8 +2,8 @@
...
@@ -2,8 +2,8 @@
import
React
,
{
useCallback
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
React
,
{
useCallback
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
useRouter
}
from
"next/navigation"
;
import
{
useRouter
}
from
"next/navigation"
;
import
Image
from
"next/image"
;
import
{
Facebook
,
Linkedin
,
Menu
,
Twitter
,
X
,
Youtube
}
from
"lucide-react"
;
import
{
Facebook
,
Linkedin
,
Menu
,
Twitter
,
X
,
Youtube
}
from
"lucide-react"
;
import
{
SafeImage
}
from
"@/components/shared/safe-image"
;
import
Link
from
"next/link"
;
import
Link
from
"next/link"
;
const
fallbackLogo
=
"/logo.png"
;
const
fallbackLogo
=
"/logo.png"
;
import
{
useGetApiV10Logo
}
from
"@/api/vcci-news/endpoints/logo"
;
import
{
useGetApiV10Logo
}
from
"@/api/vcci-news/endpoints/logo"
;
...
@@ -336,12 +336,11 @@ function Header() {
...
@@ -336,12 +336,11 @@ function Header() {
href=
"/"
href=
"/"
className=
"flex w-[136px] shrink-0 items-center xl:w-[152px]"
className=
"flex w-[136px] shrink-0 items-center xl:w-[152px]"
>
>
<
Safe
Image
<
Image
width=
{
108
}
width=
{
108
}
height=
{
40
}
height=
{
40
}
className=
"h-auto max-h-10 w-[108px] object-contain"
className=
"h-auto max-h-10 w-[108px] object-contain"
src=
{
currentLogo
?.
logo_url
?
links
.
resolveImageUrl
(
currentLogo
.
logo_url
)
:
fallbackLogo
}
src=
{
currentLogo
?.
logo_url
?
links
.
resolveImageUrl
(
currentLogo
.
logo_url
)
:
fallbackLogo
}
fallbackSrc=
{
fallbackLogo
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
priority
/>
/>
...
@@ -391,12 +390,11 @@ function Header() {
...
@@ -391,12 +390,11 @@ function Header() {
className=
"flex w-[136px] shrink-0 items-center"
className=
"flex w-[136px] shrink-0 items-center"
onClick=
{
()
=>
setToggleMenu
(
false
)
}
onClick=
{
()
=>
setToggleMenu
(
false
)
}
>
>
<
Safe
Image
<
Image
width=
{
108
}
width=
{
108
}
height=
{
40
}
height=
{
40
}
className=
"h-auto max-h-10 w-[108px] object-contain"
className=
"h-auto max-h-10 w-[108px] object-contain"
src=
{
currentLogo
?.
logo_url
?
links
.
resolveImageUrl
(
currentLogo
.
logo_url
)
:
fallbackLogo
}
src=
{
currentLogo
?.
logo_url
?
links
.
resolveImageUrl
(
currentLogo
.
logo_url
)
:
fallbackLogo
}
fallbackSrc=
{
fallbackLogo
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
alt=
{
currentLogo
?.
logo_name
||
"VCCI-HCM"
}
priority
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"
;
import
Link
from
"next/link"
;
const
FALLBACK_HREF
=
"https://vcci-hcm.org.vn"
;
const
FALLBACK_HREF
=
"https://vcci-hcm.org.vn"
;
...
@@ -13,7 +13,7 @@ export function FallbackSidebarAdItem({ src }: { src: string }) {
...
@@ -13,7 +13,7 @@ export function FallbackSidebarAdItem({ src }: { src: string }) {
title=
"Quảng cáo VCCI HCM"
title=
"Quảng cáo VCCI HCM"
>
>
<
div
className=
"relative aspect-[16/10]"
>
<
div
className=
"relative aspect-[16/10]"
>
<
Safe
Image
<
Image
src=
{
src
}
src=
{
src
}
alt=
"Quảng cáo VCCI HCM"
alt=
"Quảng cáo VCCI HCM"
fill
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
Link
from
"next/link"
;
import
links
from
"@/links"
;
import
links
from
"@/links"
;
import
type
{
Advertisement
}
from
"@/api/vcci-news/models/advertisement"
;
import
type
{
Advertisement
}
from
"@/api/vcci-news/models/advertisement"
;
...
@@ -15,9 +15,8 @@ export function SidebarAdItem({ item, fallbackSrc }: { item: Advertisement; fall
...
@@ -15,9 +15,8 @@ export function SidebarAdItem({ item, fallbackSrc }: { item: Advertisement; fall
title=
{
item
.
name
}
title=
{
item
.
name
}
>
>
<
div
className=
"relative aspect-[16/10]"
>
<
div
className=
"relative aspect-[16/10]"
>
<
Safe
Image
<
Image
src=
{
src
}
src=
{
src
}
fallbackSrc=
{
fallbackSrc
}
alt=
{
item
.
alt
||
item
.
name
}
alt=
{
item
.
alt
||
item
.
name
}
fill
fill
sizes=
"(max-width: 768px) 100vw, 300px"
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