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

optimize

parent bb758888
......@@ -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">
......
......@@ -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>
......
......@@ -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 ? (
......
......@@ -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>
......
......@@ -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 || "—" },
],
},
]
......
......@@ -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 },
],
},
]
......
"use client";
import * as React from "react";
import Link from "next/link";
import dayjs from "dayjs";
import {
......@@ -16,49 +15,44 @@ import {
Sparkles,
Users,
} from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
type AdminMediaItem,
type AdminNewsItem,
readAdminMediaItems,
readAdminNewsItems,
} from "@/mockdata/admin-news";
import { type BaseConfigData, readBaseConfig } from "@/mockdata/base-config";
import {
type ContactRequestItem,
type MembershipApplicationItem,
type NewsletterSubscriptionItem,
readContactRequests,
readMembershipApplications,
readNewsletterSubscriptions,
} from "@/mockdata/contact-management";
import {
type HeaderCategoryPostItem,
getHeaderCategoryPostSeed,
} from "@/mockdata/header-category-posts";
import { type HeaderCategoryItem, getHeaderCategorySeed } from "@/mockdata/header-config";
import {
type MemberField,
type MemberItem,
type MemberRegion,
readMemberFields,
readMemberRegions,
readMembers,
} from "@/mockdata/members";
import { type VideoItem, readVideos } from "@/mockdata/videos";
useGetApiV10Post,
useGetApiV10PostId,
} from "@/api/vcci-news/endpoints/post";
import { useGetApiV10File, useGetApiV10FileId } from "@/api/vcci-news/endpoints/file";
import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
import { useGetApiV10Member } from "@/api/vcci-news/endpoints/member";
import { useGetApiV10Business } from "@/api/vcci-news/endpoints/business";
import { useGetApiV10Position } from "@/api/vcci-news/endpoints/position";
import { useGetApiV10Contact } from "@/api/vcci-news/endpoints/contact";
import { useGetApiV10NewsletterSubscription } from "@/api/vcci-news/endpoints/newsletter-subscription";
import { useGetApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import { useGetApiV10Banner } from "@/api/vcci-news/endpoints/banner";
import { useGetApiV10SiteInformation } from "@/api/vcci-news/endpoints/site-information";
import { resolveCmsFileUrl, toAdminMediaItem } from "@/lib/utils/file";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { ComponentType, useMemo } from "react";
function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm");
}
function extractRows(responseData: unknown): any[] {
if (!responseData || typeof responseData !== "object") return [];
const rows = (responseData as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows : [];
}
type DashboardMetric = {
title: string;
value: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
icon: ComponentType<{ className?: string }>;
href: string;
};
......@@ -66,7 +60,7 @@ type DashboardShortcut = {
title: string;
description: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
icon: ComponentType<{ className?: string }>;
};
type ActivityItem = {
......@@ -78,47 +72,167 @@ type ActivityItem = {
href: string;
};
interface SpotlightPostItem {
id: string;
title: string;
thumbnail_id?: string | null;
updated_at?: string | null;
created_at?: string | null;
}
function SpotlightThumbnail({ fileId }: { fileId?: string | null }) {
const { data } = useGetApiV10FileId(fileId ?? "", {
query: { enabled: Boolean(fileId) },
});
const file = (data as any)?.responseData ?? (data as any)?.data?.responseData;
const url = file?.path ? resolveCmsFileUrl(file.path) : null;
if (!url) {
return (
<div className="flex h-full items-center justify-center text-[#063e8e]">
<Newspaper className="h-5 w-5" />
</div>
);
}
return <Image
src={url}
alt={file?.original ?? "thumbnail"}
fill
className="object-cover"
unoptimized
/>;
}
export default function AdminDashboardPage() {
const [ready, setReady] = React.useState(false);
const [newsItems, setNewsItems] = React.useState<AdminNewsItem[]>([]);
const [mediaItems, setMediaItems] = React.useState<AdminMediaItem[]>([]);
const [videos, setVideos] = React.useState<VideoItem[]>([]);
const [members, setMembers] = React.useState<MemberItem[]>([]);
const [memberFields, setMemberFields] = React.useState<MemberField[]>([]);
const [memberRegions, setMemberRegions] = React.useState<MemberRegion[]>([]);
const [newsletterItems, setNewsletterItems] = React.useState<NewsletterSubscriptionItem[]>([]);
const [contactRequests, setContactRequests] = React.useState<ContactRequestItem[]>([]);
const [membershipApplications, setMembershipApplications] = React.useState<
MembershipApplicationItem[]
>([]);
const [baseConfig, setBaseConfig] = React.useState<BaseConfigData>(() => readBaseConfig());
React.useEffect(() => {
setNewsItems(readAdminNewsItems());
setMediaItems(readAdminMediaItems());
setVideos(readVideos());
setMembers(readMembers());
setMemberFields(readMemberFields());
setMemberRegions(readMemberRegions());
setNewsletterItems(readNewsletterSubscriptions());
setContactRequests(readContactRequests());
setMembershipApplications(readMembershipApplications());
setBaseConfig(readBaseConfig());
setReady(true);
}, []);
const headerCategories = React.useMemo<HeaderCategoryItem[]>(() => getHeaderCategorySeed(), []);
const headerPosts = React.useMemo<HeaderCategoryPostItem[]>(() => getHeaderCategoryPostSeed(), []);
const metrics = React.useMemo<DashboardMetric[]>(() => {
const totalContactForms =
newsletterItems.length + contactRequests.length + membershipApplications.length;
const postsQuery = useGetApiV10Post({
page: 1,
pageSize: 200,
sortField: "updated_at",
sortOrder: "desc",
});
const filesQuery = useGetApiV10File({ page: 1, pageSize: 200 });
const videosQuery = useGetApiV10Video({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const membersQuery = useGetApiV10Member({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const businessesQuery = useGetApiV10Business({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const positionsQuery = useGetApiV10Position({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const contactsQuery = useGetApiV10Contact({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const newsletterQuery = useGetApiV10NewsletterSubscription({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const categoryTreeQuery = useGetApiV10CategoryTree();
const bannersQuery = useGetApiV10Banner({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const siteInfoQuery = useGetApiV10SiteInformation();
const newsItems = useMemo(
() => extractRows((postsQuery.data as any)?.responseData),
[postsQuery.data],
);
const mediaItems = useMemo<AdminMediaItem[]>(
() => extractRows((filesQuery.data as any)?.responseData).map(toAdminMediaItem),
[filesQuery.data],
);
const videos = useMemo(
() => extractRows((videosQuery.data as any)?.responseData),
[videosQuery.data],
);
const members = useMemo(
() => extractRows((membersQuery.data as any)?.responseData),
[membersQuery.data],
);
const businesses = useMemo(
() => extractRows((businessesQuery.data as any)?.responseData),
[businessesQuery.data],
);
const positions = useMemo(
() => extractRows((positionsQuery.data as any)?.responseData),
[positionsQuery.data],
);
const contactRequests = useMemo(
() => extractRows((contactsQuery.data as any)?.responseData),
[contactsQuery.data],
);
const newsletterItems = useMemo(
() => extractRows((newsletterQuery.data as any)?.responseData),
[newsletterQuery.data],
);
const banners = useMemo(
() => extractRows((bannersQuery.data as any)?.responseData),
[bannersQuery.data],
);
const categoryTree = useMemo(
() => {
const raw = (categoryTreeQuery.data as any)?.responseData;
return Array.isArray(raw) ? raw : [];
},
[categoryTreeQuery.data],
);
const headerCategoryCount = useMemo(() => {
const walk = (nodes: any[]): number =>
nodes.reduce(
(total, node) => total + 1 + walk(Array.isArray(node?.children) ? node.children : []),
0,
);
return walk(categoryTree);
}, [categoryTree]);
const siteInfo = useMemo(
() =>
(siteInfoQuery.data as any)?.responseData ??
(siteInfoQuery.data as any)?.data?.responseData,
[siteInfoQuery.data],
);
const isLoading =
postsQuery.isLoading ||
filesQuery.isLoading ||
videosQuery.isLoading ||
membersQuery.isLoading ||
contactsQuery.isLoading;
const metrics = useMemo<DashboardMetric[]>(() => {
const totalContactForms = newsletterItems.length + contactRequests.length;
return [
{
title: "Bài viết nội dung",
value: String(newsItems.length + headerPosts.length),
description: `${newsItems.length} bài admin, ${headerPosts.length} bài danh mục`,
value: String(newsItems.length),
description: `${newsItems.length} bài viết đang quản lý`,
icon: Newspaper,
href: "/admin/news",
},
......@@ -130,9 +244,9 @@ export default function AdminDashboardPage() {
href: "/admin/media",
},
{
title: "Hội viên & biểu mẫu",
value: String(members.length + membershipApplications.length),
description: `${members.length} hội viên, ${membershipApplications.length} đơn chờ xử lý`,
title: "Hội viên",
value: String(members.length),
description: `${members.length} hội viên, ${businesses.length} doanh nghiệp, ${positions.length} chức vụ`,
icon: Users,
href: "/admin/members",
},
......@@ -145,17 +259,17 @@ export default function AdminDashboardPage() {
},
];
}, [
businesses.length,
contactRequests.length,
headerPosts.length,
mediaItems.length,
members.length,
membershipApplications.length,
newsletterItems.length,
newsItems.length,
positions.length,
videos.length,
]);
const shortcuts = React.useMemo<DashboardShortcut[]>(
const shortcuts = useMemo<DashboardShortcut[]>(
() => [
{
title: "Cấu hình chung",
......@@ -185,13 +299,13 @@ export default function AdminDashboardPage() {
[],
);
const recentActivities = React.useMemo<ActivityItem[]>(() => {
const recentActivities = useMemo<ActivityItem[]>(() => {
const items: ActivityItem[] = [
...newsItems.map((item) => ({
...newsItems.map((item: any) => ({
id: `news-${item.id}`,
title: item.title,
title: item.title ?? "",
description: "Cập nhật trong Quản lý bài viết",
time: item.updated_at || item.created_at,
time: item.updated_at || item.created_at || "",
badge: "Bài viết",
href: "/admin/news",
})),
......@@ -203,40 +317,40 @@ export default function AdminDashboardPage() {
badge: "Ảnh",
href: "/admin/media",
})),
...membershipApplications.map((item) => ({
id: `member-app-${item.id}`,
title: item.organizationName,
description: "Đơn đăng ký hội viên mới",
time: item.submittedAt,
badge: "Đơn hội viên",
href: "/admin/contact-management/membership-applications",
})),
...contactRequests.map((item) => ({
...contactRequests.map((item: any) => ({
id: `contact-${item.id}`,
title: item.contactName,
description: item.purpose,
time: item.submittedAt,
title: item.fullname ?? "",
description: item.title ?? "Đơn liên hệ từ website",
time: item.created_at ?? "",
badge: "Liên hệ",
href: "/admin/contact-management/contact-requests",
})),
];
return items
.filter((item) => item.id)
.sort((left, right) => dayjs(right.time).valueOf() - dayjs(left.time).valueOf())
.slice(0, 6);
}, [contactRequests, mediaItems, membershipApplications, newsItems]);
}, [contactRequests, mediaItems, newsItems]);
const spotlightNews = React.useMemo(() => newsItems.slice(0, 3), [newsItems]);
const visibleSocials = React.useMemo(
() => baseConfig.socials.filter((item) => item.isVisible).sort((a, b) => a.sortOrder - b.sortOrder),
[baseConfig.socials],
const spotlightNews = useMemo<SpotlightPostItem[]>(
() =>
newsItems.slice(0, 3).map((item: any) => ({
id: String(item?.id ?? ""),
title: item?.title ?? "",
thumbnail_id: item?.thumbnail_id ?? null,
updated_at: item?.updated_at ?? null,
created_at: item?.created_at ?? null,
})),
[newsItems],
);
const activeBanners = React.useMemo(
() => baseConfig.banners.filter((item) => item.isActive).sort((a, b) => a.sortOrder - b.sortOrder),
[baseConfig.banners],
const activeBanners = useMemo(
() => banners.filter((item: any) => item?.status === "ACTIVE" || item?.status === 1),
[banners],
);
if (!ready) {
if (isLoading) {
return (
<div className="grid gap-5 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
......@@ -284,7 +398,7 @@ export default function AdminDashboardPage() {
>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">Phản hồi</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">
{contactRequests.length + membershipApplications.length}
{contactRequests.length}
</div>
<div className="mt-1 text-sm text-slate-500">đơn đang cần theo dõi</div>
</Link>
......@@ -303,8 +417,12 @@ export default function AdminDashboardPage() {
<CardContent className="space-y-4">
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Website</div>
<div className="mt-2 text-lg font-semibold text-[#163b73]">{baseConfig.websiteName}</div>
<div className="mt-1 truncate text-sm text-slate-500">{baseConfig.websiteLink}</div>
<div className="mt-2 text-lg font-semibold text-[#163b73]">
{siteInfo?.website_name || "Chưa cấu hình"}
</div>
<div className="mt-1 truncate text-sm text-slate-500">
{siteInfo?.website_link || "—"}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
......@@ -313,16 +431,18 @@ export default function AdminDashboardPage() {
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Mạng xã hội hiển thị</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{visibleSocials.length}</div>
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Danh mục menu header</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerCategoryCount}</div>
</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Chi nhánh liên hệ</div>
<div className="mt-2 text-lg font-semibold text-[#163b73]">{baseConfig.branches.length} địa điểm</div>
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Liên hệ</div>
<div className="mt-2 text-lg font-semibold text-[#163b73]">
{siteInfo?.hotline || "Chưa cấu hình hotline"}
</div>
<div className="mt-1 text-sm text-slate-500">
{baseConfig.branches[0]?.branchName || "Chưa có chi nhánh nào được cấu hình"}
{siteInfo?.email || siteInfo?.address || "—"}
</div>
</div>
</CardContent>
......@@ -366,7 +486,12 @@ export default function AdminDashboardPage() {
</div>
</CardHeader>
<CardContent className="space-y-3">
{recentActivities.map((item) => (
{recentActivities.length === 0 ? (
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-6 text-center text-sm text-slate-500">
Không có dữ liệu hoạt động
</div>
) : (
recentActivities.map((item) => (
<Link
key={item.id}
href={item.href}
......@@ -380,13 +505,14 @@ export default function AdminDashboardPage() {
<span className="text-xs text-slate-400">{formatDateTime(item.time)}</span>
</div>
<div className="mt-2 line-clamp-1 text-sm font-semibold text-[#163b73]">
{item.title}
{item.title || "—"}
</div>
<div className="mt-1 line-clamp-2 text-sm text-slate-500">{item.description}</div>
</div>
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-slate-400" />
</Link>
))}
))
)}
</CardContent>
</Card>
......@@ -431,34 +557,31 @@ export default function AdminDashboardPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{spotlightNews.map((item) => (
{spotlightNews.length === 0 ? (
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-6 text-center text-sm text-slate-500">
Không có dữ liệu bài viết
</div>
) : (
spotlightNews.map((item) => (
<Link
key={item.id}
href={`/admin/news/${item.id}`}
className="flex gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]"
>
<div className="relative h-20 w-28 shrink-0 overflow-hidden rounded-2xl bg-[#eef4ff]">
{item.thumbnail ? (
<SafeImage
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-[#063e8e]">
<Newspaper className="h-5 w-5" />
</div>
)}
<SpotlightThumbnail fileId={item.thumbnail_id} />
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-semibold text-[#163b73]">{item.title}</div>
<div className="line-clamp-2 text-sm font-semibold text-[#163b73]">
{item.title || "—"}
</div>
<div className="mt-2 text-xs text-slate-400">
{formatDateTime(item.updated_at || item.created_at)}
{formatDateTime(item.updated_at || item.created_at || "")}
</div>
</div>
</Link>
))}
))
)}
</CardContent>
</Card>
......@@ -473,12 +596,12 @@ export default function AdminDashboardPage() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Menu header</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerCategories.length}</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerCategoryCount}</div>
<div className="mt-1 text-sm text-slate-500">mục điều hướng</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Bài trong danh mục</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerPosts.length}</div>
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Bài viết</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{newsItems.length}</div>
<div className="mt-1 text-sm text-slate-500">bản ghi nội dung</div>
</div>
</div>
......@@ -491,16 +614,22 @@ export default function AdminDashboardPage() {
</Badge>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{mediaItems.slice(0, 3).map((item) => (
{mediaItems.length === 0 ? (
<div className="col-span-3 py-6 text-center text-sm text-slate-500">
Không có dữ liệu ảnh
</div>
) : (
mediaItems.slice(0, 3).map((item) => (
<div key={item.id} className="relative aspect-square overflow-hidden rounded-2xl bg-[#eef4ff]">
<SafeImage
<Image
src={item.url}
alt={item.alt || item.name}
fill
className="object-cover"
/>
</div>
))}
))
)}
</div>
</div>
</CardContent>
......@@ -516,9 +645,9 @@ export default function AdminDashboardPage() {
<CardContent className="space-y-3">
{[
{ label: "Video", value: videos.length, icon: MonitorPlay },
{ label: "Lĩnh vực hội viên", value: memberFields.length, icon: FolderTree },
{ label: "Khu vực hội viên", value: memberRegions.length, icon: MapPin },
{ label: "Chi nhánh liên hệ", value: baseConfig.branches.length, icon: Globe },
{ label: "Doanh nghiệp hội viên", value: businesses.length, icon: FolderTree },
{ label: "Chức vụ hội viên", value: positions.length, icon: MapPin },
{ label: "Banner", value: banners.length, icon: Globe },
].map((item) => (
<div
key={item.label}
......
......@@ -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 ? (
<SafeImage
<Image
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
......
......@@ -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";
......
......@@ -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;
......
"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 deleteApiV10CategoryId(deleteTarget.id);
await deleteCategory({ id: deleteTarget.id });
toast.success("Xóa danh mục thành công");
setDeleteTarget(null);
await reload();
......
......@@ -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 ? (
<SafeImage
<Image
src={form.previewUrl}
alt={form.name}
fill
......
......@@ -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%)]">
<SafeImage
<Image
src={resolveCmsFileUrl(item.path)}
alt={item.original ?? ""}
fill
......
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Save, Upload, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminPostContentEditor } from "@/components/admin/post-content-editor";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
......@@ -19,24 +18,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import {
type AdminMediaItem,
type AdminNewsContentSection,
} from "@/mockdata/admin-news";
useGetApiV10MemberId,
usePostApiV10Member,
usePutApiV10MemberId,
} from "@/api/vcci-news/endpoints/member";
import { useGetApiV10Business } from "@/api/vcci-news/endpoints/business";
import { useGetApiV10Position } from "@/api/vcci-news/endpoints/position";
import {
type MemberField,
type MemberFormValues,
type MemberImageRef,
type MemberItem,
type MemberRegion,
type AdminMemberFormValues,
EMPTY_MEMBER_FORM,
cloneMemberFormValues,
createMemberId,
persistMembers,
readMemberFields,
readMemberRegions,
readMembers,
} from "@/mockdata/members";
extractRows,
} from "@/lib/utils/admin-member";
interface AdminMemberFormProps {
memberId?: string;
......@@ -53,112 +47,144 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function toImageRef(item: AdminMediaItem): MemberImageRef {
return { id: item.id, name: item.name, alt: item.alt, url: item.url };
}
export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
const router = useRouter();
const queryClient = useQueryClient();
const isNew = !memberId || memberId === "new";
const [form, setForm] = useState<MemberFormValues>(EMPTY_MEMBER_FORM);
const [fields, setFields] = useState<MemberField[]>([]);
const [regions, setRegions] = useState<MemberRegion[]>([]);
const [form, setForm] = useState<AdminMemberFormValues>(EMPTY_MEMBER_FORM);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [ready, setReady] = useState(false);
const [notFound, setNotFound] = useState(false);
const [hydrated, setHydrated] = useState(isNew);
const memberQuery = useGetApiV10MemberId(isNew ? "" : memberId!, {
query: { enabled: !isNew && !!memberId },
});
const businessesQuery = useGetApiV10Business({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const positionsQuery = useGetApiV10Position({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const businesses = useMemo(
() =>
extractRows((businessesQuery.data as any)?.responseData).map(
(row: any) => ({
id: String(row?.id ?? ""),
name: String(row?.name ?? ""),
}),
),
[businessesQuery.data],
);
const positions = useMemo(
() =>
extractRows((positionsQuery.data as any)?.responseData).map(
(row: any) => ({
id: String(row?.id ?? ""),
name: String(row?.name ?? ""),
}),
),
[positionsQuery.data],
);
const createMember = usePostApiV10Member();
const updateMember = usePutApiV10MemberId();
useEffect(() => {
const allFields = readMemberFields();
const allRegions = readMemberRegions();
setFields(allFields);
setRegions(allRegions);
if (!isNew) {
const all = readMembers();
const found = all.find((m) => m.id === memberId);
if (found) {
setForm(cloneMemberFormValues(found));
if (isNew) {
setForm(EMPTY_MEMBER_FORM);
setHydrated(true);
setNotFound(false);
return;
}
const memberData = (memberQuery as any).data as any;
if (!memberData) return;
const raw = memberData?.responseData ?? memberData ?? null;
const row = raw && typeof raw === "object" && "rows" in raw
? Array.isArray(raw.rows)
? raw.rows[0]
: null
: raw;
if (!row || !row.id) {
setNotFound(true);
setHydrated(true);
return;
}
setReady(true);
}, [isNew, memberId]);
const set = <K extends keyof MemberFormValues>(key: K, value: MemberFormValues[K]) => {
setForm({
id: String(row.id ?? ""),
full_name: row.full_name ?? "",
avatar_url: row.avatar_url ?? null,
birth_date: row.birth_date ? String(row.birth_date).slice(0, 10) : "",
business_id: row.business_id ? String(row.business_id) : "",
position_id: row.position_id ? String(row.position_id) : "",
job_title: row.job_title ?? "",
});
setNotFound(false);
setHydrated(true);
}, [isNew, memberQuery.data]);
const set = <K extends keyof AdminMemberFormValues>(
key: K,
value: AdminMemberFormValues[K],
) => {
setForm((previous) => ({ ...previous, [key]: value }));
};
const handleImageSelect = (item: AdminMediaItem) => {
set("image", toImageRef(item));
set("avatar_url", item.url);
setImagePickerOpen(false);
};
const handleIntroductionChange = (sections: AdminNewsContentSection[]) => {
set("introduction", sections);
};
const handleSave = () => {
if (!form.name.trim()) {
const handleSave = async () => {
if (!form.full_name.trim()) {
toast.error("Vui lòng nhập tên hội viên");
return;
}
setSaving(true);
try {
const now = new Date().toISOString();
const all = readMembers();
const payload = {
full_name: form.full_name.trim(),
avatar_url: form.avatar_url || null,
birth_date: form.birth_date ? form.birth_date : null,
business_id: form.business_id || null,
position_id: form.position_id || null,
job_title: form.job_title || null,
};
if (isNew) {
const newItem: MemberItem = {
id: createMemberId(),
name: form.name.trim(),
is_featured: form.is_featured,
image: form.image,
region_id: form.region_id,
field_id: form.field_id,
address: form.address,
phone: form.phone,
fax: form.fax,
email: form.email,
website: form.website,
introduction: form.introduction,
created_at: now,
updated_at: now,
};
persistMembers([...all, newItem]);
await createMember.mutateAsync({ data: payload });
toast.success("Đã thêm hội viên mới");
} else {
const nextAll = all.map((m) => {
if (m.id !== memberId) return m;
return {
...m,
name: form.name.trim(),
is_featured: form.is_featured,
image: form.image,
region_id: form.region_id,
field_id: form.field_id,
address: form.address,
phone: form.phone,
fax: form.fax,
email: form.email,
website: form.website,
introduction: form.introduction,
updated_at: now,
} satisfies MemberItem;
});
persistMembers(nextAll);
await updateMember.mutateAsync({ id: memberId!, data: payload });
toast.success("Đã lưu thay đổi");
}
queryClient.invalidateQueries({ queryKey: ["getApiV10Member"] });
router.push("/admin/members");
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Không thể lưu hội viên";
toast.error(message);
} finally {
setSaving(false);
}
};
if (!ready) {
if (!hydrated || (!isNew && memberQuery.isLoading)) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-[#063e8e] border-t-transparent" />
......@@ -166,12 +192,44 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
);
}
if (notFound) {
return (
<div className="space-y-8">
<div className="flex items-center gap-4">
<Link href="/admin/members">
<Button
type="button"
variant="outline"
size="icon"
className="border-[#063e8e]/15"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold text-[#063e8e]">Hội viên</h1>
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/15 bg-white px-6 py-12 text-center">
<p className="text-sm text-gray-700">
Không tìm thấy hội viên này hoặc dữ liệu không tồn tại.
</p>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/admin/members">
<Button type="button" variant="outline" size="icon" className="border-[#063e8e]/15">
<Button
type="button"
variant="outline"
size="icon"
className="border-[#063e8e]/15"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
......@@ -180,7 +238,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
{isNew ? "Thêm hội viên mới" : "Chỉnh sửa hội viên"}
</h1>
<p className="text-sm text-gray-500">
{isNew ? "Điền thông tin hội viên mới" : `Chỉnh sửa: ${form.name}`}
{isNew ? "Điền thông tin hội viên mới" : `Chỉnh sửa: ${form.full_name}`}
</p>
</div>
</div>
......@@ -189,7 +247,9 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
{/* Main info */}
<div className="space-y-6 xl:col-span-2">
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-base font-semibold text-[#063e8e]">Thông tin cơ bản</h2>
<h2 className="mb-4 text-base font-semibold text-[#063e8e]">
Thông tin cơ bản
</h2>
<div className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
......@@ -197,198 +257,161 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
Tên hội viên <span className="text-red-500">*</span>
</Label>
<Input
value={form.name}
onChange={(e) => set("name", e.target.value)}
value={form.full_name}
onChange={(e) => set("full_name", e.target.value)}
placeholder="Nhập tên hội viên..."
className={fieldClassName}
/>
</div>
{/* Region & Field */}
{/* Business & Position */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-gray-700">Khu vực</Label>
<Select value={form.region_id} onValueChange={(v) => set("region_id", v)}>
<Label className="text-gray-700">Doanh nghiệp</Label>
<Select
value={form.business_id || undefined}
onValueChange={(v) => set("business_id", v)}
>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn khu vực" />
<SelectValue placeholder="Chọn doanh nghiệp" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
{regions.map((r) => (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}>
{r.name}
{businesses.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-gray-700">
Không có dữ liệu doanh nghiệp
</div>
) : (
businesses.map((b) => (
<SelectItem
key={b.id}
value={b.id}
className={selectItemClassName}
>
{b.name}
</SelectItem>
))}
))
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-gray-700">Lĩnh vực</Label>
<Select value={form.field_id} onValueChange={(v) => set("field_id", v)}>
<Label className="text-gray-700">Chức vụ</Label>
<Select
value={form.position_id || undefined}
onValueChange={(v) => set("position_id", v)}
>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn lĩnh vực" />
<SelectValue placeholder="Chọn chức vụ" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
{fields.map((f) => (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}>
{f.name}
{positions.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-gray-700">
Không có dữ liệu chức vụ
</div>
) : (
positions.map((p) => (
<SelectItem
key={p.id}
value={p.id}
className={selectItemClassName}
>
{p.name}
</SelectItem>
))}
))
)}
</SelectContent>
</Select>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<Label className="text-gray-700">Địa chỉ</Label>
<Input
value={form.address}
onChange={(e) => set("address", e.target.value)}
placeholder="Nhập địa chỉ..."
className={fieldClassName}
/>
</div>
{/* Phone & Fax */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-gray-700">Điện thoại</Label>
<Input
value={form.phone}
onChange={(e) => set("phone", e.target.value)}
placeholder="Nhập số điện thoại..."
className={fieldClassName}
/>
</div>
<div className="space-y-1.5">
<Label className="text-gray-700">Fax</Label>
<Input
value={form.fax}
onChange={(e) => set("fax", e.target.value)}
placeholder="Nhập số fax..."
className={fieldClassName}
/>
</div>
</div>
{/* Email & Website */}
{/* Job title & Birth date */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-gray-700">Email</Label>
<Label className="text-gray-700">Chức danh</Label>
<Input
type="email"
value={form.email}
onChange={(e) => set("email", e.target.value)}
placeholder="Nhập email..."
value={form.job_title ?? ""}
onChange={(e) => set("job_title", e.target.value)}
placeholder="Nhập chức danh..."
className={fieldClassName}
/>
</div>
<div className="space-y-1.5">
<Label className="text-gray-700">Website</Label>
<Label className="text-gray-700">Ngày sinh</Label>
<Input
value={form.website}
onChange={(e) => set("website", e.target.value)}
placeholder="https://..."
type="date"
value={form.birth_date ?? ""}
onChange={(e) => set("birth_date", e.target.value)}
className={fieldClassName}
/>
</div>
</div>
<div className="rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-gray-700">Hội viên tiêu biểu</p>
<p className="mt-1 text-sm text-gray-700">
Đánh dấu để ưu tiên hiển thị như một hội viên tiêu biểu.
</p>
</div>
<Switch
checked={form.is_featured}
onCheckedChange={(checked) => set("is_featured", Boolean(checked))}
/>
</div>
</div>
</div>
</div>
{/* Introduction (CMS) */}
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-base font-semibold text-[#063e8e]">Giới thiệu</h2>
<AdminPostContentEditor
sections={form.introduction}
onChange={handleIntroductionChange}
/>
</div>
</div>
{/* Side panel */}
<div className="space-y-6">
{/* Actions */}
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-base font-semibold text-[#063e8e]">Thao tác</h2>
<h2 className="mb-4 text-base font-semibold text-[#063e8e]">
Thao tác
</h2>
<div className="flex flex-col gap-3">
<Button
type="button"
disabled={saving}
onClick={handleSave}
className="w-full bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
disabled={saving}
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Save className="mr-2 h-4 w-4" />
{saving ? "Đang lưu..." : "Lưu hội viên"}
</Button>
<Link href="/admin/members" className="w-full">
<Button type="button" variant="outline" className="w-full border-[#063e8e]/15">
<Button
type="button"
variant="outline"
className="w-full border-[#063e8e]/15"
disabled={saving}
>
Hủy
</Button>
</Link>
</div>
</div>
{/* Image */}
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-base font-semibold text-[#063e8e]">Ảnh đại diện</h2>
{form.image ? (
<h2 className="mb-4 text-base font-semibold text-[#063e8e]">
Ảnh đại diện
</h2>
{form.avatar_url ? (
<div className="space-y-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-[#063e8e]/15">
<SafeImage
src={form.image.url}
alt={form.image.alt || form.image.name}
<div className="relative aspect-square overflow-hidden rounded-full border border-[#063e8e]/15">
<Image
src={form.avatar_url}
alt={form.full_name}
fill
className="object-cover"
/>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1 border-[#063e8e]/15 text-xs"
onClick={() => setImagePickerOpen(true)}
className="w-full border-red-200 text-red-600 hover:bg-red-50"
onClick={() => set("avatar_url", null)}
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
Đổi ảnh
<X className="mr-2 h-4 w-4" />
Xóa ảnh
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="border-red-200 text-red-500 hover:bg-red-50"
onClick={() => set("image", null)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
className="w-full border-dashed border-[#063e8e]/25 text-[#063e8e]/60 hover:border-[#063e8e]/50 hover:text-[#063e8e]"
className="w-full border-[#063e8e]/15"
onClick={() => setImagePickerOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Chọn ảnh
Chọn ảnh đại diện
</Button>
)}
</div>
......@@ -397,7 +420,6 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
<AdminImagePicker
open={imagePickerOpen}
selectedId={form.image?.id ?? null}
onOpenChange={setImagePickerOpen}
onSelect={handleImageSelect}
/>
......
......@@ -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}>
......
"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-16 overflow-hidden rounded-lg border border-[#063e8e]/15">
<SafeImage
src={item.image.url}
alt={item.image.alt || item.name}
width={64}
{item.avatar_url ? (
<div className="mx-auto h-12 w-12 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-16 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-12 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
......
......@@ -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>
......
"use client";
import { useEffect, useMemo, 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.
</>
......
"use client";
import { useEffect, useMemo, 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 items.filter((item) => {
return members.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 && matchesField && matchesRegion;
return matchesKeyword && matchesBusiness && matchesPosition;
});
}, [items, search, fieldFilter, regionFilter]);
}, [members, search, businessFilter, positionFilter]);
const stats = useMemo(
() => [
{
label: "Tổng hội viên",
value: items.length,
value: members.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Số lĩnh vực",
value: fields.length,
label: "Số doanh nghiệp",
value: businesses.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Số khu vực",
value: regions.length,
label: "Số chức vụ",
value: positions.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={fields}
regions={regions}
fieldFilter={fieldFilter}
regionFilter={regionFilter}
onFieldFilterChange={setFieldFilter}
onRegionFilterChange={setRegionFilter}
fields={businesses}
regions={positions}
fieldFilter={businessFilter}
regionFilter={positionFilter}
onFieldFilterChange={setBusinessFilter}
onRegionFilterChange={setPositionFilter}
/>
}
>
......@@ -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-[200px] py-4 text-center text-white">Tên hội viên</TableHead>
<TableHead className="w-[120px] py-4 text-center text-white">Ảnh</TableHead>
<TableHead className="w-40 py-4 text-center text-white">Khu vực</TableHead>
<TableHead className="w-40 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-[180px] py-4 text-center text-white">Địa chỉ</TableHead>
<TableHead className="w-[220px] py-4 text-center text-white">Tên hội viên</TableHead>
<TableHead className="w-[160px] py-4 text-center text-white">Ảnh đại diện</TableHead>
<TableHead className="w-48 py-4 text-center text-white">Chức vụ</TableHead>
<TableHead className="w-48 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-[140px] 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.
</>
}
......
......@@ -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>
......
"use client";
import { useEffect, useMemo, 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.
</>
......
......@@ -17,7 +17,7 @@ import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminPostContentEditor } from "@/components/admin/post-content-editor";
import { PostHistoryViewer } from "./post-history-viewer";
import { AdminRichTextEditor } from "@/components/shared/rich-text-editor";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { PermissionGate } from "@/components/shared/permission-gate";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
......@@ -37,31 +37,33 @@ import {
type TagItem,
} from "@/api/vcci-news/types/tag";
import {
getApiV10Post,
getApiV10PostId,
postApiV10Post,
putApiV10PostId,
useGetApiV10Post,
useGetApiV10PostId,
usePostApiV10Post,
usePutApiV10PostId,
} from "@/api/vcci-news/endpoints/post";
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import { useGetApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import {
deleteApiV10PostTagPostId,
getApiV10PostTagPostId,
postApiV10PostTagPostIdBulk,
useDeleteApiV10PostTagPostId,
useGetApiV10PostTagPostId,
usePostApiV10PostTagPostIdBulk,
} from "@/api/vcci-news/endpoints/post-tag";
import {
getApiV10Tag,
postApiV10TagIds,
useGetApiV10Tag,
usePostApiV10TagIds,
} from "@/api/vcci-news/endpoints/tag";
import { useGetApiV10EventAddress } from "@/api/vcci-news/endpoints/event-address";
import {
ADMIN_NEWS_TYPE_OPTIONS,
cloneAdminNewsFormValues,
type AdminMediaItem,
type AdminNewsFormValues,
type AdminNewsItem,
} from "@/api/vcci-news/types/post";
import {
cloneAdminNewsFormValues,
resolveAdminNewsType,
slugifyAdminNews,
} from "@/mockdata/admin-news";
} from "@/lib/utils/admin-news";
import { normalizeUser } from "@/lib/utils/cms-user";
import { normalizeDateTimeInput } from "@/lib/utils/datetime";
import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content";
......@@ -91,6 +93,45 @@ import {
} from "./utils";
import useNewsDraftStore, { type NewsDraftData } from "@/store/useNewsDraftStore";
function mapPostRowToAdminNewsItem(item: any): AdminNewsItem {
const structuredContent = parsePostContent(item.content_structure);
const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(item.content);
const categories = Array.isArray(item.categories) ? item.categories : [];
const primaryCategory = categories[0] ?? null;
const primaryCategoryType = primaryCategory?.type ?? null;
return {
id: item.id ?? "",
title: item.title ?? "",
slug: item.slug ?? "",
summary: item.summary ?? "",
type: item.type === "page" || primaryCategoryType === "post" || primaryCategoryType === "page" ? "baiviettrang" : "tintuc",
header_category_id: primaryCategory?.id ?? "",
category_ids: categories.map((c: any) => c.id),
tagsearch_values: [],
is_featured: Boolean(item.is_featured),
thumbnail: item.thumbnail?.id ? {
id: item.thumbnail.id,
name: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
alt: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
url: links.resolveImageUrl(item.thumbnail.path),
} : null,
is_hidden: Boolean(item.is_hidden),
created_at: item.created_at ?? "",
updated_at: item.updated_at ?? "",
published_at: normalizeDateTimeInput(item.published_at ?? item.release_at),
expired_at: normalizeDateTimeInput(item.expired_at),
started_at: normalizeDateTimeInput(item.started_at),
ended_at: normalizeDateTimeInput(item.ended_at),
registration_deadline: normalizeDateTimeInput(item.registration_deadline),
location: item.location ?? "",
participation_fee: item.participation_fee ?? "",
event_dates: Array.isArray(item.event_dates) ? item.event_dates.filter((d: any): d is string => typeof d === "string") : [],
post_content: postContent,
creator: normalizeUser(item.creator),
editor: normalizeUser(item.editor),
};
}
export function AdminNewsFormContent() {
const params = useParams();
const searchParams = useSearchParams();
......@@ -112,6 +153,7 @@ export function AdminNewsFormContent() {
const [useEventDates, setUseEventDates] = useState(false);
const [isAddressManagerOpen, setIsAddressManagerOpen] = useState(false);
const [isAddressSuggestOpen, setIsAddressSuggestOpen] = useState(false);
const [postTagNames, setPostTagNames] = useState<string[]>([]);
const eventAddressesQuery = useGetApiV10EventAddress({
page: 1,
......@@ -120,6 +162,35 @@ export function AdminNewsFormContent() {
sortOrder: "asc",
});
// Orval hooks
const { data: treeResponse } = useGetApiV10CategoryTree();
const { data: tagsResponse } = useGetApiV10Tag({
page: 1,
pageSize: 10,
sortField: "name",
sortOrder: "asc",
});
const postsListQuery = useGetApiV10Post({
page: 1,
pageSize: 10,
filters: !isCreate && newsId ? `id==${newsId}` : undefined,
});
const postDetailQuery = useGetApiV10PostId(newsId || "", {
query: { enabled: !isCreate && Boolean(newsId) },
});
const postTagsQuery = useGetApiV10PostTagPostId(
!isCreate && newsId ? newsId : "",
undefined,
{
query: { enabled: !isCreate && Boolean(newsId) },
},
);
const { mutateAsync: fetchTagsByIds } = usePostApiV10TagIds();
const { mutateAsync: createPost, isPending: isCreatingPost } = usePostApiV10Post();
const { mutateAsync: updatePost, isPending: isUpdatingPost } = usePutApiV10PostId();
const { mutateAsync: deletePostTag } = useDeleteApiV10PostTagPostId();
const { mutateAsync: bulkAddPostTags } = usePostApiV10PostTagPostIdBulk();
const addressOptions = useMemo(() => {
const rows = eventAddressesQuery.data?.responseData?.rows ?? [];
const values = Array.from(
......@@ -151,78 +222,57 @@ export function AdminNewsFormContent() {
const [pendingDraft, setPendingDraft] = useState<NewsDraftData | null>(null);
const draftRestoredRef = useRef(false);
// Sync header tree from orval query
useEffect(() => {
let cancelled = false;
const tree = (treeResponse?.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
setHeaderTree(tree);
}, [treeResponse]);
const load = async () => {
setIsLoadingInitialData(true);
setIsMissingPost(false);
setForm(isCreate ? cloneAdminNewsFormValues() : null);
// Sync tag list from orval query
useEffect(() => {
const nextTagsResult = (tagsResponse?.responseData ?? {}) as { rows?: TagItem[] };
setAllTags(nextTagsResult.rows ?? []);
}, [tagsResponse]);
// Sync post rows from orval query
useEffect(() => {
if (isCreate) {
setItems([]);
return;
}
const result = (postsListQuery.data?.responseData ?? {}) as { rows?: any[] };
setItems((result.rows ?? []).map(mapPostRowToAdminNewsItem));
}, [isCreate, postsListQuery.data]);
// Resolve tag names for current post
useEffect(() => {
if (isCreate || !newsId) {
setPostTagNames([]);
return;
}
const tagsResult = (postTagsQuery.data?.responseData ?? {}) as { rows?: { tag_id?: string }[] };
const tagIds = (tagsResult.rows ?? []).map(r => r.tag_id).filter(Boolean) as string[];
if (tagIds.length === 0) {
setPostTagNames([]);
return;
}
let cancelled = false;
void (async () => {
try {
const [treeResponse, nextTagsResponse] = await Promise.all([
getApiV10CategoryTree(),
getApiV10Tag({ page: 1, pageSize: 10, sortField: "name", sortOrder: "asc" }),
]);
const nextTagsResult = (nextTagsResponse.responseData ?? {}) as { rows?: TagItem[] };
const nextTags = nextTagsResult.rows ?? [];
const tree = (treeResponse.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
const nextHeaderTree = tree;
const nextNewsItems = isCreate
? []
: await (async () => {
const response = await getApiV10Post({
page: 1,
pageSize: 10,
filters: newsId ? `id==${newsId}` : undefined,
});
const result = (response.responseData ?? {}) as { rows?: any[] };
return (result.rows ?? []).map((item: any): AdminNewsItem => {
const structuredContent = parsePostContent(item.content_structure);
const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(item.content);
const categories = Array.isArray(item.categories) ? item.categories : [];
const primaryCategory = categories[0] ?? null;
const primaryCategoryType = primaryCategory?.type ?? null;
return {
id: item.id ?? "",
title: item.title ?? "",
slug: item.slug ?? "",
summary: item.summary ?? "",
type: item.type === "page" || primaryCategoryType === "post" || primaryCategoryType === "page" ? "baiviettrang" : "tintuc",
header_category_id: primaryCategory?.id ?? "",
category_ids: categories.map((c: any) => c.id),
tagsearch_values: [],
is_featured: Boolean(item.is_featured),
thumbnail: item.thumbnail?.id ? {
id: item.thumbnail.id,
name: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
alt: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
url: links.resolveImageUrl(item.thumbnail.path),
} : null,
is_hidden: Boolean(item.is_hidden),
created_at: item.created_at ?? "",
updated_at: item.updated_at ?? "",
published_at: normalizeDateTimeInput(item.published_at ?? item.release_at),
expired_at: normalizeDateTimeInput(item.expired_at),
started_at: normalizeDateTimeInput(item.started_at),
ended_at: normalizeDateTimeInput(item.ended_at),
registration_deadline: normalizeDateTimeInput(item.registration_deadline),
location: item.location ?? "",
participation_fee: item.participation_fee ?? "",
event_dates: Array.isArray(item.event_dates) ? item.event_dates.filter((d: any): d is string => typeof d === "string") : [],
post_content: postContent,
creator: normalizeUser(item.creator),
editor: normalizeUser(item.editor),
};
});
const response = await fetchTagsByIds({ data: { tag_ids: tagIds } });
const tags = (response.responseData ?? []) as TagItem[];
if (!cancelled) setPostTagNames(tags.map((t) => t.name));
} catch {
if (!cancelled) setPostTagNames([]);
}
})();
return () => {
cancelled = true;
};
}, [isCreate, newsId, postTagsQuery.data, fetchTagsByIds]);
if (cancelled) return;
setItems(nextNewsItems);
setHeaderTree(nextHeaderTree);
setAllTags(nextTags);
// Build form from post detail query
useEffect(() => {
if (isCreate) {
const now = new Date().toISOString();
setForm({
......@@ -234,33 +284,27 @@ export function AdminNewsFormContent() {
updated_at: now,
});
setUseEventDates(false);
setIsMissingPost(false);
setIsLoadingInitialData(false);
return;
}
const existingItem = nextNewsItems.find((item) => item.id === newsId);
const currentItem = existingItem
? existingItem
: newsId
? await (async () => {
const response = await getApiV10PostId(newsId);
const post = (response.responseData ?? {}) as any;
// Fetch tags for post
const tagsRes = await getApiV10PostTagPostId(newsId);
const tagsResult = (tagsRes.responseData ?? {}) as { rows?: { tag_id?: string }[] };
const tagIds = (tagsResult.rows ?? []).map(r => r.tag_id).filter(Boolean) as string[];
let tags: TagItem[] = [];
if (tagIds.length > 0) {
const tagsResponse = await postApiV10TagIds({ tag_ids: tagIds });
tags = (tagsResponse.responseData ?? []) as TagItem[];
if (!newsId || postDetailQuery.isLoading || !postDetailQuery.data) return;
const post = ((postDetailQuery.data as { responseData?: unknown })?.responseData ?? null) as any;
if (!post) {
setIsMissingPost(true);
setForm(null);
setIsLoadingInitialData(false);
return;
}
const tagMap = new Map<string, TagItem[]>([[newsId, tags]]);
const tagItems = tagMap.get(newsId) ?? [];
const structuredContent = parsePostContent(post.content_structure);
const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(post.content);
const categories = Array.isArray(post.categories) ? post.categories : [];
const primaryCategory = categories[0] ?? null;
const primaryCategoryType = primaryCategory?.type ?? null;
return {
const currentItem = {
id: post.id ?? "",
title: post.title ?? "",
slug: post.slug ?? "",
......@@ -268,7 +312,7 @@ export function AdminNewsFormContent() {
type: post.type === "page" || primaryCategoryType === "post" || primaryCategoryType === "page" ? "baiviettrang" : "tintuc",
header_category_id: primaryCategory?.id ?? "",
category_ids: categories.map((c: any) => c.id),
tagsearch_values: tagItems.map((t) => t.name),
tagsearch_values: postTagNames,
is_featured: Boolean(post.is_featured),
thumbnail: post.thumbnail?.id ? {
id: post.thumbnail.id,
......@@ -291,39 +335,13 @@ export function AdminNewsFormContent() {
creator: normalizeUser(post.creator),
editor: normalizeUser(post.editor),
} as AdminNewsItem;
})()
: null;
if (cancelled) return;
if (!currentItem) {
setIsMissingPost(true);
setForm(null);
return;
}
setIsMissingPost(false);
const nextForm = cloneAdminNewsFormValues(currentItem);
setForm(nextForm);
setUseEventDates((nextForm.event_dates ?? []).length > 0);
} catch (error) {
if (cancelled) return;
toast.error(error instanceof Error ? error.message : "Không thể tải bài viết");
setIsMissingPost(!isCreate);
setForm(isCreate ? cloneAdminNewsFormValues() : null);
} finally {
if (!cancelled) {
setIsLoadingInitialData(false);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [isCreate, newsId]);
}, [isCreate, newsId, postDetailQuery.data, postDetailQuery.isLoading, postTagNames]);
// Check draft sau khi load xong + store hydrated — chỉ hỏi 1 lần
useEffect(() => {
......@@ -536,7 +554,7 @@ export function AdminNewsFormContent() {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!form || isSubmitting) return;
if (!form || isSubmitting || isCreatingPost || isUpdatingPost) return;
if (!form.title.trim()) {
toast.error("Tiêu đề bài viết là bắt buộc");
......@@ -608,20 +626,20 @@ export function AdminNewsFormContent() {
};
if (isCreate) {
const response = await postApiV10Post(payload as any);
const response = await createPost({ data: payload as any });
const created = (response.responseData ?? {}) as { id?: string };
if (created.id && tagIds.length > 0) {
await postApiV10PostTagPostIdBulk(created.id, { tag_ids: tagIds });
await bulkAddPostTags({ postId: created.id, data: { tag_ids: tagIds } });
}
} else if (newsId) {
await putApiV10PostId(newsId, payload as any);
await updatePost({ id: newsId, data: payload as any });
// Xóa tags cũ rồi bulk add tags mới
const currentRes = await getApiV10PostTagPostId(newsId);
const currentResult = (currentRes.responseData ?? {}) as { rows?: { tag_id?: string }[] };
const currentRes = await postTagsQuery.refetch();
const currentResult = (currentRes.data?.responseData ?? {}) as { rows?: { tag_id?: string }[] };
const currentTagIds = (currentResult.rows ?? []).map(r => r.tag_id).filter(Boolean) as string[];
await Promise.all(currentTagIds.map(tagId => deleteApiV10PostTagPostId(newsId, { tag_id: tagId })));
await Promise.all(currentTagIds.map(tagId => deletePostTag({ postId: newsId, params: { tag_id: tagId } })));
if (tagIds.length > 0) {
await postApiV10PostTagPostIdBulk(newsId, { tag_ids: tagIds });
await bulkAddPostTags({ postId: newsId, data: { tag_ids: tagIds } });
}
}
......@@ -748,7 +766,7 @@ export function AdminNewsFormContent() {
<div className="relative overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white">
<div className="relative aspect-[16/11]">
{form.thumbnail ? (
<SafeImage
<Image
src={form.thumbnail.url}
alt={form.thumbnail.alt || form.thumbnail.name}
fill
......
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 };
......
......@@ -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 ? (
<SafeImage
<Image
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
......
......@@ -198,25 +198,25 @@ export default function PasswordResetRequestsPage() {
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-10 py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
<TableHead className="w-16 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-500">
<TableCell colSpan={7} className="text-center py-12 text-gray-400">
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="text-sm font-medium text-[#163b73]">
<Mail className="h-4 w-4 text-gray-400 shrink-0" />
<span className="truncate 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="text-sm text-slate-600">
<span className="truncate 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>
......
......@@ -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-500">
<TableCell colSpan={8} className="text-center py-12 text-gray-400">
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="text-sm text-slate-700 truncate 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-400">@{user.username}</p>
<p className="text-xs text-gray-500">@{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>
......
......@@ -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, postApiV10FileUpload } from "@/api/vcci-news/endpoints/file";
import { useGetApiV10File, usePostApiV10FileUpload } 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]">
<SafeImage
<Image
src={item.url}
alt={item.alt || item.name}
fill
......
......@@ -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]"
>
<SafeImage
<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)}
>
<SafeImage
<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
/>
......
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]">
<SafeImage
<Image
src={src}
alt="Quảng cáo VCCI HCM"
fill
......
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]">
<SafeImage
<Image
src={src}
fallbackSrc={fallbackSrc}
alt={item.alt || item.name}
fill
sizes="(max-width: 768px) 100vw, 300px"
......
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 : [];
}
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;
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 },
})),
})),
};
}
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;
}
}
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