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 },
],
},
]
......
This diff is collapsed.
......@@ -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
......
......@@ -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.
</>
......
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