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