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
ead1f37d
Commit
ead1f37d
authored
Oct 13, 2026
by
Phạm Quang Bảo
Browse files
Options
Browse Files
Download
Plain Diff
[tag]0.1-vcci
parents
ee930a20
2f632b41
Pipeline
#53650
passed with stages
in 1 minute and 54 seconds
Changes
2
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
130 additions
and
21 deletions
+130
-21
index.tsx
src/app/(main)/(home)/components/events/index.tsx
+69
-17
SoDoToChuc.tsx
src/app/(main)/[...slug]/static-pages/SoDoToChuc.tsx
+61
-4
No files found.
src/app/(main)/(home)/components/events/index.tsx
View file @
ead1f37d
import
{
getApiV10Post
}
from
"@/api/vcci-news/endpoints/post"
;
import
type
{
RawEventPost
}
from
"@/api/vcci-news/types/post-raw"
;
import
type
{
RawEvent
Calendar
Post
}
from
"@/api/vcci-news/types/post-raw"
;
import
type
{
PagedResult
}
from
"@/api/vcci-news/types/paged-result"
;
import
{
buildPostLink
,
...
...
@@ -17,22 +17,27 @@ import { getFallbackImage } from "@/lib/utils/fallback-image";
const
SU_KIEN_CATEGORY_ID
=
"b85f6710-bcbc-4c0b-8b3a-09fff0e5e51a"
;
const
DAO_TAO_CATEGORY_ID
=
"36df7021-9a74-43d6-9084-0d5ed347b7f4"
;
const
SECTION_LINK_FALLBACK
=
"/hoat-dong/su-kien"
;
const
PAGE_SIZE
=
5
;
const
DISPLAY_SIZE
=
5
;
// Lấy dư số bài để sau khi lọc "sắp diễn ra" vẫn đủ 5 thẻ hiển thị.
const
FETCH_SIZE
=
15
;
/** Multi-category filter (OR) — sắp xếp & lọc hoàn toàn ở BE. */
const
SECTION_PARAMS
=
{
const
buildSectionParams
=
(
now
:
Date
,
options
?:
{
skipDateFilter
?:
boolean
})
=>
(
{
page
:
1
,
pageSize
:
PAGE
_SIZE
,
sortField
:
"
crea
ted_at"
,
sortOrder
:
"
desc"
,
pageSize
:
FETCH
_SIZE
,
sortField
:
"
star
ted_at"
,
sortOrder
:
"
asc"
as
const
,
priorityFeatured
:
false
,
filters
:
[
`category.id==(
${
SU_KIEN_CATEGORY_ID
}
|
${
DAO_TAO_CATEGORY_ID
}
)`
,
"is_hidden==false"
,
"is_active==true"
,
"type==news"
,
// Lọc trước ở BE: chỉ lấy sự kiện có started_at từ hiện tại trở đi,
// bài không có started_at (NULL) bị loại tự nhiên ở đây.
...(
options
?.
skipDateFilter
?
[]
:
[
`started_at>=
${
dayjs
(
now
).
format
(
"YYYY-MM-DD"
)}
`
]),
].
join
(
","
),
}
as
const
;
}
)
;
type
EventItem
=
{
id
:
string
;
...
...
@@ -41,6 +46,7 @@ type EventItem = {
createdAt
:
string
;
publishedAt
:
string
;
startedAt
:
string
;
endedAt
:
string
;
contentText
:
string
;
summary
:
string
;
/** Text đã strip HTML (caption/figure/img/tag/entity). */
...
...
@@ -49,8 +55,36 @@ type EventItem = {
thumbnail
:
{
url
:
string
;
alt
:
string
}
|
null
;
};
/** Ngày dùng để sắp xếp sự kiện: ưu tiên started_at, fallback published_at rồi created_at. */
const
eventSortDate
=
(
item
:
EventItem
)
=>
{
const
value
=
item
.
startedAt
||
item
.
publishedAt
||
item
.
createdAt
;
const
timestamp
=
dayjs
(
value
).
valueOf
();
return
Number
.
isFinite
(
timestamp
)
?
timestamp
:
Number
.
MAX_SAFE_INTEGER
;
};
/** Sự kiện sắp diễn ra gần nhất đứng đầu; bài thiếu ngày hợp lệ xếp cuối. */
const
sortEventItems
=
(
items
:
EventItem
[])
=>
[...
items
].
sort
((
a
,
b
)
=>
eventSortDate
(
a
)
-
eventSortDate
(
b
));
/**
* Sự kiện còn hiệu lực từ hiện tại trở đi: dùng ended_at nếu có (sự kiện nhiều ngày),
* không có thì dùng started_at. Bài thiếu cả hai coi là không xác định.
*/
const
isUpcomingEvent
=
(
item
:
EventItem
,
now
:
number
)
=>
{
const
endValue
=
item
.
endedAt
||
item
.
startedAt
;
if
(
!
endValue
)
return
false
;
const
endAt
=
dayjs
(
endValue
).
valueOf
();
return
Number
.
isFinite
(
endAt
)
&&
endAt
>=
now
;
};
/** Lọc sự kiện sắp tới, sort gần nhất trước; nếu không còn bài nào hợp lệ thì fallback sang bài không có ngày. */
const
pickUpcomingEvents
=
(
items
:
EventItem
[],
now
:
number
)
=>
{
const
upcoming
=
sortEventItems
(
items
.
filter
((
item
)
=>
isUpcomingEvent
(
item
,
now
)));
if
(
upcoming
.
length
)
return
upcoming
.
slice
(
0
,
DISPLAY_SIZE
);
return
sortEventItems
(
items
.
filter
((
item
)
=>
!
item
.
startedAt
&&
!
item
.
endedAt
)).
slice
(
0
,
DISPLAY_SIZE
);
};
const
toEventItem
=
(
post
:
RawEventPost
):
EventItem
=>
{
const
toEventItem
=
(
post
:
RawEvent
Calendar
Post
):
EventItem
=>
{
const
title
=
String
(
post
.
title
??
""
).
trim
();
const
categories
=
post
.
categories
??
[];
const
thumbnailPath
=
post
.
thumbnail
?.
path
??
post
.
thumbnail
?.
original
??
null
;
...
...
@@ -72,6 +106,7 @@ const toEventItem = (post: RawEventPost): EventItem => {
createdAt
:
String
(
post
.
created_at
??
""
),
publishedAt
:
String
(
post
.
published_at
??
post
.
release_at
??
post
.
created_at
??
""
),
startedAt
:
String
(
post
.
started_at
??
""
),
endedAt
:
String
(
post
.
ended_at
??
""
),
contentText
,
summary
,
excerpt
:
stripHtml
(
contentText
||
summary
),
...
...
@@ -83,7 +118,7 @@ const toEventItem = (post: RawEventPost): EventItem => {
};
/** Lấy url danh mục sự kiện/đào tạo từ chính danh sách posts, fallback nếu không có. */
const
resolveSectionLink
=
(
posts
:
RawEventPost
[])
=>
{
const
resolveSectionLink
=
(
posts
:
RawEvent
Calendar
Post
[])
=>
{
for
(
const
post
of
posts
)
{
const
category
=
post
.
categories
?.
find
(
(
item
)
=>
...
...
@@ -105,13 +140,29 @@ const fetchEventPosts = async (): Promise<{
sectionLink
:
string
;
}
>
=>
{
try
{
const
response
=
await
getApiV10Post
(
SECTION_PARAMS
);
const
response
=
await
getApiV10Post
(
buildSectionParams
(
new
Date
())
);
const
rows
=
(
response
as
PagedResult
<
RawEventPost
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
throw
new
Error
(
"Empty rows"
);
(
response
as
PagedResult
<
RawEventCalendarPost
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
rows
.
length
===
0
)
{
// BE chưa áp dụng filter theo ngày -> thử lại không có điều kiện lọc,
// để sự kiện cũ không làm mất sự kiện đang sắp diễn ra.
const
fallbackResponse
=
await
getApiV10Post
(
buildSectionParams
(
new
Date
(),
{
skipDateFilter
:
true
}),
);
const
fallbackRows
=
(
fallbackResponse
as
PagedResult
<
RawEventCalendarPost
>
|
undefined
)?.
responseData
?.
rows
??
[];
if
(
fallbackRows
.
length
===
0
)
throw
new
Error
(
"Empty rows"
);
return
{
posts
:
pickUpcomingEvents
(
fallbackRows
.
map
(
toEventItem
),
Date
.
now
()),
sectionLink
:
resolveSectionLink
(
fallbackRows
),
};
}
// BE đã lọc started_at >= hôm nay, lọc lại phía client để bao phủ ended_at
// của sự kiện nhiều ngày và bảo đảm đủ số lượng hiển thị.
return
{
posts
:
rows
.
map
(
toEventItem
),
posts
:
pickUpcomingEvents
(
rows
.
map
(
toEventItem
),
Date
.
now
()
),
sectionLink
:
resolveSectionLink
(
rows
),
};
}
catch
(
error
)
{
...
...
@@ -127,19 +178,20 @@ const fetchEventPosts = async (): Promise<{
?.
url
??
SECTION_LINK_FALLBACK
;
return
{
posts
:
mockPosts
.
map
((
item
)
=>
({
posts
:
pickUpcomingEvents
(
mockPosts
.
map
((
item
)
=>
({
id
:
item
.
id
,
title
:
item
.
title
,
externalLink
:
item
.
externalLink
,
createdAt
:
item
.
createdAt
,
publishedAt
:
item
.
publishedAt
,
startedAt
:
item
.
startedAt
,
endedAt
:
item
.
endedAt
,
contentText
:
item
.
contentText
,
summary
:
item
.
summary
,
excerpt
:
stripHtml
(
item
.
contentText
||
item
.
summary
),
categoryName
:
item
.
categories
[
0
]?.
name
??
""
,
thumbnail
:
item
.
thumbnail
,
})),
})),
Date
.
now
()),
sectionLink
:
mockSectionLink
,
};
}
...
...
@@ -187,8 +239,8 @@ export function EventsSkeleton() {
export
async
function
Events
()
{
const
{
posts
,
sectionLink
}
=
await
fetchEventPosts
();
//
Giữ nguyên hành vi cũ: đảo ngược danh sách trước khi chọn bài featured
const
eventItems
=
[...
posts
].
reverse
(
);
//
BE chỉ sort theo started_at, chưa loại sự kiện đã diễn ra -> lọc + sort lại phía client.
const
eventItems
=
pickUpcomingEvents
(
posts
,
Date
.
now
()
);
const
[
featuredEvent
,
...
sideEvents
]
=
eventItems
;
const
sideSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
sideEvents
[
index
]
??
null
);
...
...
src/app/(main)/[...slug]/static-pages/SoDoToChuc.tsx
View file @
ead1f37d
...
...
@@ -29,6 +29,28 @@ const ORG_DIRECTOR: OrgNode = {
title
:
"Giám đốc"
,
};
const
ORG_NODE_WIDTH
=
260
;
// Estimate rendered height of a node title so cards grow when names + phone numbers wrap.
const
estimateTitleHeight
=
(
title
:
string
)
=>
{
const
roleFontSize
=
18
;
const
personFontSize
=
16
;
const
availableWidth
=
ORG_NODE_WIDTH
-
36
;
// Rough average glyph width ratio for Arial, tuned conservatively (wider) so wrapped
// text is over-counted rather than under-counted, preventing clipped cards.
const
charsPerLine
=
Math
.
max
(
8
,
Math
.
floor
(
availableWidth
/
(
personFontSize
*
0.62
)));
return
title
.
split
(
"
\n
"
).
reduce
((
height
,
line
)
=>
{
const
separatorIndex
=
line
.
indexOf
(
":"
);
if
(
separatorIndex
===
-
1
)
return
height
+
roleFontSize
*
1.55
;
const
person
=
line
.
slice
(
separatorIndex
+
1
).
trim
();
const
personWithPhone
=
ORG_PHONES
[
person
]
?
`
${
person
}
-
${
ORG_PHONES
[
person
]}
`
:
person
;
const
personLines
=
Math
.
max
(
1
,
Math
.
ceil
(
person
.
length
/
charsPerLine
));
const
personWithPhoneLines
=
Math
.
max
(
1
,
Math
.
ceil
(
personWithPhone
.
length
/
charsPerLine
));
return
height
+
9
+
roleFontSize
*
1.55
+
personWithPhoneLines
*
personFontSize
*
1.5
;
},
0
);
};
const
ORG_DEPUTIES
:
OrgNode
[]
=
[
{
id
:
"deputy-nam"
,
parentId
:
ORG_DIRECTOR
.
id
,
name
:
"Nguyễn Hữu Nam"
,
title
:
"Phó Giám đốc"
},
{
id
:
"deputy-ninh"
,
parentId
:
ORG_DIRECTOR
.
id
,
name
:
"Bùi Thị Ninh"
,
title
:
"Phó Giám đốc"
},
...
...
@@ -61,6 +83,33 @@ type SoDoToChucProps = {
allCategories
:
DynamicCategoryRouteItem
[];
};
const
ORG_PHONES
:
Record
<
string
,
string
>
=
{
"Trần Ngọc Liêm"
:
"0903338350"
,
"Nguyễn Hữu Nam"
:
"0907790989"
,
"Bùi Thị Ninh"
:
"0938150708"
,
"Nguyễn Thị Huệ"
:
"0918904296"
,
"Vũ Thị Thanh Huyền"
:
"0907081974"
,
"Nguyễn Hữu Trường"
:
"0917688959"
,
"Nguyễn Xuân Bích Thoại"
:
"0982459399"
,
"Lê Thanh Thúy"
:
"0903789233"
,
"Nguyễn Văn Quân"
:
"0983945914"
,
"Vũ Xuân Hưng"
:
"0909170171"
,
"Nguyễn Văn Đức"
:
"0909497155"
,
"Nguyễn Khánh Linh"
:
"0393112520"
,
"Nguyễn Đoàn Thông"
:
"0903857520"
,
"Bùi Mạnh Hùng"
:
"0877892442"
,
"Trần Ngọc Đào"
:
"02839325166"
,
"Hồ Ngọc Vinh"
:
"0918866055"
,
"Bùi Tường Vi"
:
"0906050580"
,
"Nguyễn Hữu Nghĩa"
:
"0982012258"
,
"Nguyễn Lê Nhật Thanh"
:
"0903877111"
,
"Ma Thị Hương"
:
"0395122922"
,
"Đỗ Quang Huy"
:
"0396982299"
,
"Nguyễn Huy Hoàng"
:
"0983349447"
,
"Nguyễn Thị Quỳnh Như"
:
"0937267286"
,
"Nguyễn Thị Như Quỳnh"
:
"0937267286"
,
};
export
default
function
SoDoToChuc
({
category
,
allCategories
}:
SoDoToChucProps
)
{
const
chartContainerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
categoryMenu
=
category
?
buildDynamicCategoryMenu
(
category
,
allCategories
)
:
[];
...
...
@@ -97,9 +146,10 @@ export default function SoDoToChuc({ category, allCategories }: SoDoToChucProps)
const
showDetails
=
!
isDepartment
||
expandedDepartmentDetails
.
has
(
data
.
id
);
if
(
isDepartment
&&
!
showDetails
)
return
116
;
const
titleLineCount
=
data
.
title
.
split
(
"
\n
"
).
reduce
((
count
,
line
)
=>
count
+
(
line
.
includes
(
":"
)
?
2
:
1
),
0
);
const
departmentExtraHeight
=
isDepartment
?
40
+
(
titleLineCount
>
2
?
(
titleLineCount
-
2
)
*
5
:
0
)
:
0
;
return
Math
.
max
(
132
,
94
+
titleLineCount
*
28
+
(
data
.
note
?
24
:
0
)
+
departmentExtraHeight
);
const
titleHeight
=
estimateTitleHeight
(
data
.
title
);
const
phoneHeight
=
ORG_PHONES
[
data
.
name
]
&&
!
data
.
title
.
includes
(
":"
)
?
26
:
0
;
const
departmentExtraHeight
=
isDepartment
?
56
:
0
;
return
Math
.
max
(
132
,
110
+
titleHeight
+
phoneHeight
+
(
data
.
note
?
24
:
0
)
+
departmentExtraHeight
);
})
.
childrenMargin
(()
=>
36
)
.
siblingsMargin
(()
=>
15
)
...
...
@@ -145,11 +195,18 @@ export default function SoDoToChuc({ category, allCategories }: SoDoToChucProps)
if
(
separatorIndex
===
-
1
)
return
`<div>
${
line
}
</div>`
;
const
role
=
line
.
slice
(
0
,
separatorIndex
);
const
person
=
line
.
slice
(
separatorIndex
+
1
).
trim
();
return
`<div style="margin-top:9px"><span style="font-weight:600">
${
role
}
</span><br/><span>
${
person
}
</span></div>`
;
const
phone
=
ORG_PHONES
[
person
];
const
personHtml
=
phone
?
`
${
person
}
- <span style="color:
${
featured
?
"#86efac"
:
"#16a34a"
}
;font-weight:600">
${
phone
}
</span>`
:
person
;
return
`<div style="margin-top:9px"><span style="font-weight:600">
${
role
}
</span><br/><span style="font-size:16px">
${
personHtml
}
</span></div>`
;
}).
join
(
""
);
const
nodePhone
=
ORG_PHONES
[
data
.
name
];
return
`<div style="box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:
${
showDetails
?
"18px"
:
"12px"
}
;border:1px solid
${
featured
?
"#174a83"
:
"#d7e0e9"
}
;background:
${
featured
?
"#174a83"
:
"#fff"
}
;color:
${
featured
?
"#fff"
:
"#20364b"
}
;text-align:center;box-shadow:0 5px 16px rgba(23,52,83,.08);font-family:Arial,sans-serif;cursor:
${
isDepartment
?
"pointer"
:
"default"
}
">
<div style="font-size:
${
featured
?
"23px"
:
"20px"
}
;font-weight:700;line-height:1.4">
${
safeName
}
</div>
${
nodePhone
?
`<div style="font-size:16px;font-weight:600;margin-top:2px;color:
${
featured
?
"#86efac"
:
"#16a34a"
}
">
${
nodePhone
}
</div>`
:
""
}
${
showDetails
&&
safeTitle
?
`<div style="margin-top:8px;font-size:18px;line-height:1.55;color:
${
featured
?
"#dbeafe"
:
"#65778a"
}
">
${
titleLines
}
</div>`
:
""
}
${
showDetails
&&
safeNote
?
`<div style="margin-top:8px;color:#f4c45f;font-size:12px;font-weight:700;letter-spacing:1px">
${
safeNote
}
</div>`
:
""
}
</div>`
;
...
...
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