Commit ead1f37d authored by Phạm Quang Bảo's avatar Phạm Quang Bảo

[tag]0.1-vcci

parents ee930a20 2f632b41
Pipeline #53650 passed with stages
in 1 minute and 54 seconds
import { getApiV10Post } from "@/api/vcci-news/endpoints/post";
import type { RawEventPost } from "@/api/vcci-news/types/post-raw";
import type { RawEventCalendarPost } 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: "created_at",
sortOrder: "desc",
pageSize: FETCH_SIZE,
sortField: "started_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;
};
const toEventItem = (post: RawEventPost): EventItem => {
/** 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: RawEventCalendarPost): 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: RawEventCalendarPost[]) => {
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);
......
......@@ -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>`;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment