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({ ...@@ -374,7 +374,6 @@ export function AdvertisementList({
alt={ad.alt || ad.name} alt={ad.alt || ad.name}
fill fill
className="object-cover" className="object-cover"
unoptimized={isGif}
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
...@@ -519,7 +518,6 @@ export function AdvertisementList({ ...@@ -519,7 +518,6 @@ export function AdvertisementList({
alt="Preview" alt="Preview"
fill fill
className="object-cover" className="object-cover"
unoptimized={form.filePreviewUrl.toLowerCase().endsWith(".gif")}
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
......
...@@ -247,17 +247,23 @@ export function BannerTab() { ...@@ -247,17 +247,23 @@ export function BannerTab() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> {banners.length === 0 ? (
{banners.map((item, index) => ( <div className="py-12 text-center text-sm text-gray-500">
<ConfigItemPreview Không có data banner trang chủ
key={item.id} </div>
title={`Banner ${index + 1}`} ) : (
item={item} <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
current={index === currentIndex} {banners.map((item, index) => (
onSelect={() => setCurrentIndex(index)} <ConfigItemPreview
/> key={item.id}
))} title={`Banner ${index + 1}`}
</div> item={item}
current={index === currentIndex}
onSelect={() => setCurrentIndex(index)}
/>
))}
</div>
)}
{current ? ( {current ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div> <div>
......
...@@ -66,7 +66,6 @@ export function ContactTab() { ...@@ -66,7 +66,6 @@ export function ContactTab() {
); );
useEffect(() => { useEffect(() => {
if (!apiBranches.length) return;
const next = apiBranches.map(mapApiBranchToConfig); const next = apiBranches.map(mapApiBranchToConfig);
setBranches(next); setBranches(next);
setCurrentId((previous) => setCurrentId((previous) =>
...@@ -193,15 +192,21 @@ export function ContactTab() { ...@@ -193,15 +192,21 @@ export function ContactTab() {
<div className="text-sm font-semibold uppercase text-[#4b74b8]"> <div className="text-sm font-semibold uppercase text-[#4b74b8]">
Danh sách chi nhánh Danh sách chi nhánh
</div> </div>
{branches.map((branch) => ( {branches.length === 0 ? (
<BranchCard <div className="py-8 text-center text-sm text-slate-500">
key={branch.id} Không có data chi nhánh
branch={branch} </div>
current={current?.id === branch.id} ) : (
onSelect={() => setCurrentId(branch.id)} branches.map((branch) => (
onDelete={() => remove(branch.id)} <BranchCard
/> key={branch.id}
))} branch={branch}
current={current?.id === branch.id}
onSelect={() => setCurrentId(branch.id)}
onDelete={() => remove(branch.id)}
/>
))
)}
</div> </div>
<div className="space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"> <div className="space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
{current ? ( {current ? (
......
...@@ -97,56 +97,62 @@ export function SocialTab() { ...@@ -97,56 +97,62 @@ export function SocialTab() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 px-4 sm:px-6"> <CardContent className="space-y-4 px-4 sm:px-6">
{socials.map((item) => ( {socials.length === 0 ? (
<div <div className="py-12 text-center text-sm text-gray-500">
key={item.id} Không có data mạng xã hội
className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5" </div>
> ) : (
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end"> socials.map((item) => (
<div className="flex items-center gap-3 rounded-2xl border bg-white px-4 py-4"> <div
<Checkbox key={item.id}
checked={item.isVisible} className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
onCheckedChange={(checked) => >
change(item.id, "isVisible", checked === true) <div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end">
} <div className="flex items-center gap-3 rounded-2xl border bg-white px-4 py-4">
/> <Checkbox
<div> checked={item.isVisible}
<div className="font-semibold">{item.label}</div> onCheckedChange={(checked) =>
<div className="text-sm text-slate-500"> change(item.id, "isVisible", checked === true)
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"} }
/>
<div>
<div className="font-semibold">{item.label}</div>
<div className="text-sm text-slate-500">
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
</div> </div>
</div> </div>
</div> <div className="space-y-2">
<div className="space-y-2"> <Label>Link URL</Label>
<Label>Link URL</Label> <Input
<Input value={item.url}
value={item.url} onChange={(event) =>
onChange={(event) => change(item.id, "url", event.target.value)
change(item.id, "url", event.target.value) }
} placeholder={`Nhập link ${item.label}...`}
placeholder={`Nhập link ${item.label}...`} className={fieldClassName}
className={fieldClassName} />
/> </div>
</div> <div className="space-y-2">
<div className="space-y-2"> <Label>Thứ tự hiển thị</Label>
<Label>Thứ tự hiển thị</Label> <Input
<Input type="number"
type="number" min={1}
min={1} value={item.sortOrder}
value={item.sortOrder} onChange={(event) =>
onChange={(event) => change(
change( item.id,
item.id, "sortOrder",
"sortOrder", Number(event.target.value || 1),
Number(event.target.value || 1), )
) }
} className={fieldClassName}
className={fieldClassName} />
/> </div>
</div> </div>
</div> </div>
</div> ))
))} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
...@@ -24,12 +23,15 @@ import { ...@@ -24,12 +23,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useQueryClient } from "@tanstack/react-query";
import { useGetApiV10Contact, useDeleteApiV10ContactId } from "@/api/vcci-news/endpoints/contact";
import { import {
CONTACT_PURPOSE_OPTIONS, CONTACT_PURPOSE_OPTIONS,
type ContactRequestItem, type AdminContactRequestRow,
persistContactRequests, extractRows,
readContactRequests, mapApiRowToAdminContactRequest,
} from "@/mockdata/contact-management"; } from "@/lib/utils/admin-contact";
import { useMemo, useState } from "react";
const selectTriggerClassName = 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]"; "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"; ...@@ -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]"; const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function formatDateTime(value: string) { function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm"); return dayjs(value).format("DD/MM/YYYY HH:mm");
} }
export default function AdminContactRequestsPage() { export default function AdminContactRequestsPage() {
const [items, setItems] = React.useState<ContactRequestItem[]>([]); const queryClient = useQueryClient();
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);
React.useEffect(() => { const [search, setSearch] = useState("");
setItems(readContactRequests()); const [purposeFilter, setPurposeFilter] = useState("all");
setReady(true); 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(); const keyword = search.trim().toLowerCase();
return items.filter((item) => { return items.filter((item) => {
...@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() { ...@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() {
}); });
}, [items, purposeFilter, search]); }, [items, purposeFilter, search]);
const handleDelete = () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id); try {
setItems(nextItems); await deleteContact.mutateAsync({ id: deleteTarget.id });
persistContactRequests(nextItems); toast.success("Đã xóa đơn liên hệ");
toast.success("Đã xóa đơn liên hệ"); queryClient.invalidateQueries({ queryKey: ["getApiV10Contact"] });
setDeleteTarget(null); } 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 ( return (
...@@ -127,50 +147,50 @@ export default function AdminContactRequestsPage() { ...@@ -127,50 +147,50 @@ export default function AdminContactRequestsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!ready ? ( {contactsQuery.isLoading ? (
Array.from({ length: 4 }).map((_, index) => ( Array.from({ length: 4 }).map((_, index) => (
<TableRow key={`loading-${index}`}> <TableRow key={`loading-${index}`}>
<TableCell colSpan={7} className="px-4 py-4"> <TableCell colSpan={7} className="px-4 py-4">
<div className="h-10 animate-pulse rounded-xl bg-[#063e8e]/10" /> <div className="h-10 animate-pulse rounded-xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
))
) : filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center text-gray-400">
Không có đơn liên hệ nào
</TableCell> </TableCell>
</TableRow> </TableRow>
))
) : filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center text-gray-400">
Không có đơn liên hệ nào
</TableCell>
</TableRow>
) : ( ) : (
filteredItems.map((item, index) => ( filteredItems.map((item, index) => (
<TableRow <TableRow
key={item.id} key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"} className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
> >
<TableCell className="py-3 text-center text-sm text-gray-500"> <TableCell className="py-3 text-center text-sm text-gray-500">
{index + 1} {index + 1}
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.purpose} {item.purpose || "—"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-800"> <TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-semibold">{item.contactName}</div> <div className="font-semibold">{item.contactName || "—"}</div>
<div className="text-gray-600">{item.contactPhone}</div> <div className="text-gray-600">{item.contactPhone || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-700"> <TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-gray-800">{item.organizationName}</div> <div className="font-medium text-gray-800">{item.organizationName || "—"}</div>
<div>{item.businessField}</div> <div>{item.businessField || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-700"> <TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1"> <div className="space-y-1">
<div>{item.email}</div> <div>{item.email || "—"}</div>
<div className="text-gray-500">{item.contactEmail}</div> <div className="text-gray-500">{item.contactEmail || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-center text-sm text-gray-700"> <TableCell className="py-3 text-center text-sm text-gray-700">
...@@ -199,40 +219,40 @@ export default function AdminContactRequestsPage() { ...@@ -199,40 +219,40 @@ export default function AdminContactRequestsPage() {
badge={ badge={
detailTarget ? ( detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.purpose} {detailTarget.purpose || "—"}
</Badge> </Badge>
) : null ) : null
} }
sections={ sections={
detailTarget detailTarget
? [ ? [
{ {
title: "Thông tin chung", title: "Thông tin chung",
fields: [ 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) }, { label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
], ],
}, },
{ {
title: "Người liên hệ", title: "Người liên hệ",
fields: [ fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName }, { label: "Họ tên người liên hệ", value: detailTarget.contactName || "—" },
{ label: "Chức vụ", value: detailTarget.contactPosition }, { label: "Chức vụ", value: detailTarget.contactPosition || "—" },
{ label: "Email người liên hệ", value: detailTarget.contactEmail }, { label: "Email người liên hệ", value: detailTarget.contactEmail || "—" },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone }, { 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: "Nội dung liên hệ", value: detailTarget.message || "—", fullWidth: true },
], ],
}, },
{ {
title: "Thông tin công ty / tổ chức", title: "Thông tin công ty / tổ chức",
fields: [ fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName }, { label: "Tên công ty / tổ chức", value: detailTarget.organizationName || "—" },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField }, { label: "Lĩnh vực hoạt động", value: detailTarget.businessField || "—" },
{ label: "Email", value: detailTarget.email }, { label: "Email", value: detailTarget.email || "—" },
{ label: "Website", value: detailTarget.website }, { label: "Website", value: detailTarget.website || "—" },
], ],
}, },
] ]
: [] : []
} }
onOpenChange={(open) => !open && setDetailTarget(null)} onOpenChange={(open) => !open && setDetailTarget(null)}
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import * as React from "react"; import * as React from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
...@@ -17,29 +16,72 @@ import { ...@@ -17,29 +16,72 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { useQueryClient } from "@tanstack/react-query";
type MembershipApplicationItem, import { useGetApiV10Contact, useDeleteApiV10ContactId } from "@/api/vcci-news/endpoints/contact";
persistMembershipApplications, import { extractRows } from "@/lib/utils/admin-contact";
readMembershipApplications, import { useMemo, useState } from "react";
} from "@/mockdata/contact-management";
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) { function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm"); return dayjs(value).format("DD/MM/YYYY HH:mm");
} }
export default function AdminMembershipApplicationsPage() { export default function AdminMembershipApplicationsPage() {
const [items, setItems] = React.useState<MembershipApplicationItem[]>([]); const queryClient = useQueryClient();
const [search, setSearch] = React.useState("");
const [ready, setReady] = React.useState(false); const [search, setSearch] = useState("");
const [detailTarget, setDetailTarget] = React.useState<MembershipApplicationItem | null>(null); const [detailTarget, setDetailTarget] = useState<AdminMembershipApplicationRow | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MembershipApplicationItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<AdminMembershipApplicationRow | null>(null);
React.useEffect(() => { const contactsQuery = useGetApiV10Contact({
setItems(readMembershipApplications()); page: 1,
setReady(true); pageSize: 200,
}, []); sortField: "created_at",
sortOrder: "desc",
});
const deleteContact = useDeleteApiV10ContactId();
const filteredItems = React.useMemo(() => { const items = useMemo(
() =>
extractRows((contactsQuery.data as any)?.responseData).map(
mapApiRowToMembershipApplication,
),
[contactsQuery.data],
);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
if (!keyword) return items; if (!keyword) return items;
...@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() { ...@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() {
); );
}, [items, search]); }, [items, search]);
const handleDelete = () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id); try {
setItems(nextItems); await deleteContact.mutateAsync({ id: deleteTarget.id });
persistMembershipApplications(nextItems); toast.success("Đã xóa đơn đăng ký hội viên");
toast.success("Đã xóa đơn đăng ký hội viên"); queryClient.invalidateQueries({ queryKey: ["getApiV10Contact"] });
setDeleteTarget(null); } 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<AdminTableLayout <AdminTableLayout
searchValue={search} searchValue={search}
searchPlaceholder="Tìm kiếm đơn đăng ký hội viên..." searchPlaceholder="Tìm kiếm đơn đăng ký hội viên..."
...@@ -91,9 +138,9 @@ export default function AdminMembershipApplicationsPage() { ...@@ -91,9 +138,9 @@ export default function AdminMembershipApplicationsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!ready ? ( {contactsQuery.isLoading ? (
Array.from({ length: 3 }).map((_, index) => ( Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}> <TableRow key={`loading-${index}`}>
<TableCell colSpan={7} className="px-4 py-4"> <TableCell colSpan={7} className="px-4 py-4">
<div className="h-10 animate-pulse rounded-xl bg-[#063e8e]/10" /> <div className="h-10 animate-pulse rounded-xl bg-[#063e8e]/10" />
</TableCell> </TableCell>
...@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() { ...@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() {
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-800"> <TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-semibold">{item.organizationName}</div> <div className="font-semibold">{item.organizationName || "—"}</div>
<div className="text-gray-600">{item.businessField}</div> <div className="text-gray-600">{item.businessField || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-sm text-gray-700"> <TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium text-gray-800">{item.contactName}</div> <div className="font-medium text-gray-800">{item.contactName || "—"}</div>
<div>{item.contactPhone}</div> <div>{item.contactPhone || "—"}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.membershipType} {item.membershipType || "—"}
</Badge> </Badge>
</TableCell> </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"> <TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)} {formatDateTime(item.submittedAt)}
</TableCell> </TableCell>
...@@ -158,44 +205,44 @@ export default function AdminMembershipApplicationsPage() { ...@@ -158,44 +205,44 @@ export default function AdminMembershipApplicationsPage() {
<ContactManagementDetailDialog <ContactManagementDetailDialog
open={!!detailTarget} open={!!detailTarget}
title="Chi tiết đơn đăng ký hội viên" 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={ badge={
detailTarget ? ( detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]"> <Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.membershipType} {detailTarget.membershipType || "—"}
</Badge> </Badge>
) : null ) : null
} }
sections={ sections={
detailTarget detailTarget
? [ ? [
{ {
title: "Thông tin chung", title: "Thông tin chung",
fields: [ 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) }, { label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
], ],
}, },
{ {
title: "Thông tin doanh nghiệp", title: "Thông tin doanh nghiệp",
fields: [ fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName }, { label: "Tên công ty / tổ chức", value: detailTarget.organizationName || "—" },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField }, { label: "Lĩnh vực hoạt động", value: detailTarget.businessField || "—" },
{ label: "Địa chỉ", value: detailTarget.address, fullWidth: true }, { label: "Địa chỉ", value: detailTarget.address || "—", fullWidth: true },
{ label: "Website", value: detailTarget.website }, { label: "Website", value: detailTarget.website || "—" },
], ],
}, },
{ {
title: "Thông tin người liên hệ", title: "Thông tin người liên hệ",
fields: [ fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName }, { label: "Họ tên người liên hệ", value: detailTarget.contactName || "—" },
{ label: "Chức vụ", value: detailTarget.contactPosition }, { label: "Chức vụ", value: detailTarget.contactPosition || "—" },
{ label: "Email người liên hệ", value: detailTarget.contactEmail }, { label: "Email người liên hệ", value: detailTarget.contactEmail || "—" },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone }, { label: "Điện thoại người liên hệ", value: detailTarget.contactPhone || "—" },
{ label: "Ghi chú", value: detailTarget.note, fullWidth: true }, { label: "Ghi chú", value: detailTarget.note || "—", fullWidth: true },
], ],
}, },
] ]
: [] : []
} }
onOpenChange={(open) => !open && setDetailTarget(null)} onOpenChange={(open) => !open && setDetailTarget(null)}
......
"use client"; "use client";
import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
...@@ -16,49 +15,44 @@ import { ...@@ -16,49 +15,44 @@ import {
Sparkles, Sparkles,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
type AdminMediaItem, useGetApiV10Post,
type AdminNewsItem, useGetApiV10PostId,
readAdminMediaItems, } from "@/api/vcci-news/endpoints/post";
readAdminNewsItems, import { useGetApiV10File, useGetApiV10FileId } from "@/api/vcci-news/endpoints/file";
} from "@/mockdata/admin-news"; import { useGetApiV10Video } from "@/api/vcci-news/endpoints/video";
import { type BaseConfigData, readBaseConfig } from "@/mockdata/base-config"; import { useGetApiV10Member } from "@/api/vcci-news/endpoints/member";
import { import { useGetApiV10Business } from "@/api/vcci-news/endpoints/business";
type ContactRequestItem, import { useGetApiV10Position } from "@/api/vcci-news/endpoints/position";
type MembershipApplicationItem, import { useGetApiV10Contact } from "@/api/vcci-news/endpoints/contact";
type NewsletterSubscriptionItem, import { useGetApiV10NewsletterSubscription } from "@/api/vcci-news/endpoints/newsletter-subscription";
readContactRequests, import { useGetApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
readMembershipApplications, import { useGetApiV10Banner } from "@/api/vcci-news/endpoints/banner";
readNewsletterSubscriptions, import { useGetApiV10SiteInformation } from "@/api/vcci-news/endpoints/site-information";
} from "@/mockdata/contact-management"; import { resolveCmsFileUrl, toAdminMediaItem } from "@/lib/utils/file";
import { import type { AdminMediaItem } from "@/mockdata/admin-news";
type HeaderCategoryPostItem, import { ComponentType, useMemo } from "react";
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";
function formatDateTime(value: string) { function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm"); 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 = { type DashboardMetric = {
title: string; title: string;
value: string; value: string;
description: string; description: string;
icon: React.ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
href: string; href: string;
}; };
...@@ -66,7 +60,7 @@ type DashboardShortcut = { ...@@ -66,7 +60,7 @@ type DashboardShortcut = {
title: string; title: string;
description: string; description: string;
href: string; href: string;
icon: React.ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
}; };
type ActivityItem = { type ActivityItem = {
...@@ -78,47 +72,167 @@ type ActivityItem = { ...@@ -78,47 +72,167 @@ type ActivityItem = {
href: string; 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() { export default function AdminDashboardPage() {
const [ready, setReady] = React.useState(false); const postsQuery = useGetApiV10Post({
const [newsItems, setNewsItems] = React.useState<AdminNewsItem[]>([]); page: 1,
const [mediaItems, setMediaItems] = React.useState<AdminMediaItem[]>([]); pageSize: 200,
const [videos, setVideos] = React.useState<VideoItem[]>([]); sortField: "updated_at",
const [members, setMembers] = React.useState<MemberItem[]>([]); sortOrder: "desc",
const [memberFields, setMemberFields] = React.useState<MemberField[]>([]); });
const [memberRegions, setMemberRegions] = React.useState<MemberRegion[]>([]); const filesQuery = useGetApiV10File({ page: 1, pageSize: 200 });
const [newsletterItems, setNewsletterItems] = React.useState<NewsletterSubscriptionItem[]>([]); const videosQuery = useGetApiV10Video({
const [contactRequests, setContactRequests] = React.useState<ContactRequestItem[]>([]); page: 1,
const [membershipApplications, setMembershipApplications] = React.useState< pageSize: 200,
MembershipApplicationItem[] sortField: "created_at",
>([]); sortOrder: "desc",
const [baseConfig, setBaseConfig] = React.useState<BaseConfigData>(() => readBaseConfig()); });
const membersQuery = useGetApiV10Member({
React.useEffect(() => { page: 1,
setNewsItems(readAdminNewsItems()); pageSize: 200,
setMediaItems(readAdminMediaItems()); sortField: "created_at",
setVideos(readVideos()); sortOrder: "desc",
setMembers(readMembers()); });
setMemberFields(readMemberFields()); const businessesQuery = useGetApiV10Business({
setMemberRegions(readMemberRegions()); page: 1,
setNewsletterItems(readNewsletterSubscriptions()); pageSize: 200,
setContactRequests(readContactRequests()); sortField: "created_at",
setMembershipApplications(readMembershipApplications()); sortOrder: "desc",
setBaseConfig(readBaseConfig()); });
setReady(true); const positionsQuery = useGetApiV10Position({
}, []); page: 1,
pageSize: 200,
const headerCategories = React.useMemo<HeaderCategoryItem[]>(() => getHeaderCategorySeed(), []); sortField: "created_at",
const headerPosts = React.useMemo<HeaderCategoryPostItem[]>(() => getHeaderCategoryPostSeed(), []); sortOrder: "desc",
});
const metrics = React.useMemo<DashboardMetric[]>(() => { const contactsQuery = useGetApiV10Contact({
const totalContactForms = page: 1,
newsletterItems.length + contactRequests.length + membershipApplications.length; 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 [ return [
{ {
title: "Bài viết nội dung", title: "Bài viết nội dung",
value: String(newsItems.length + headerPosts.length), value: String(newsItems.length),
description: `${newsItems.length} bài admin, ${headerPosts.length} bài danh mục`, description: `${newsItems.length} bài viết đang quản lý`,
icon: Newspaper, icon: Newspaper,
href: "/admin/news", href: "/admin/news",
}, },
...@@ -130,9 +244,9 @@ export default function AdminDashboardPage() { ...@@ -130,9 +244,9 @@ export default function AdminDashboardPage() {
href: "/admin/media", href: "/admin/media",
}, },
{ {
title: "Hội viên & biểu mẫu", title: "Hội viên",
value: String(members.length + membershipApplications.length), value: String(members.length),
description: `${members.length} hội viên, ${membershipApplications.length} đơn chờ xử lý`, description: `${members.length} hội viên, ${businesses.length} doanh nghiệp, ${positions.length} chức vụ`,
icon: Users, icon: Users,
href: "/admin/members", href: "/admin/members",
}, },
...@@ -145,17 +259,17 @@ export default function AdminDashboardPage() { ...@@ -145,17 +259,17 @@ export default function AdminDashboardPage() {
}, },
]; ];
}, [ }, [
businesses.length,
contactRequests.length, contactRequests.length,
headerPosts.length,
mediaItems.length, mediaItems.length,
members.length, members.length,
membershipApplications.length,
newsletterItems.length, newsletterItems.length,
newsItems.length, newsItems.length,
positions.length,
videos.length, videos.length,
]); ]);
const shortcuts = React.useMemo<DashboardShortcut[]>( const shortcuts = useMemo<DashboardShortcut[]>(
() => [ () => [
{ {
title: "Cấu hình chung", title: "Cấu hình chung",
...@@ -185,13 +299,13 @@ export default function AdminDashboardPage() { ...@@ -185,13 +299,13 @@ export default function AdminDashboardPage() {
[], [],
); );
const recentActivities = React.useMemo<ActivityItem[]>(() => { const recentActivities = useMemo<ActivityItem[]>(() => {
const items: ActivityItem[] = [ const items: ActivityItem[] = [
...newsItems.map((item) => ({ ...newsItems.map((item: any) => ({
id: `news-${item.id}`, id: `news-${item.id}`,
title: item.title, title: item.title ?? "",
description: "Cập nhật trong Quản lý bài viết", 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", badge: "Bài viết",
href: "/admin/news", href: "/admin/news",
})), })),
...@@ -203,40 +317,40 @@ export default function AdminDashboardPage() { ...@@ -203,40 +317,40 @@ export default function AdminDashboardPage() {
badge: "Ảnh", badge: "Ảnh",
href: "/admin/media", href: "/admin/media",
})), })),
...membershipApplications.map((item) => ({ ...contactRequests.map((item: any) => ({
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) => ({
id: `contact-${item.id}`, id: `contact-${item.id}`,
title: item.contactName, title: item.fullname ?? "",
description: item.purpose, description: item.title ?? "Đơn liên hệ từ website",
time: item.submittedAt, time: item.created_at ?? "",
badge: "Liên hệ", badge: "Liên hệ",
href: "/admin/contact-management/contact-requests", href: "/admin/contact-management/contact-requests",
})), })),
]; ];
return items return items
.filter((item) => item.id)
.sort((left, right) => dayjs(right.time).valueOf() - dayjs(left.time).valueOf()) .sort((left, right) => dayjs(right.time).valueOf() - dayjs(left.time).valueOf())
.slice(0, 6); .slice(0, 6);
}, [contactRequests, mediaItems, membershipApplications, newsItems]); }, [contactRequests, mediaItems, newsItems]);
const spotlightNews = React.useMemo(() => newsItems.slice(0, 3), [newsItems]); const spotlightNews = useMemo<SpotlightPostItem[]>(
const visibleSocials = React.useMemo( () =>
() => baseConfig.socials.filter((item) => item.isVisible).sort((a, b) => a.sortOrder - b.sortOrder), newsItems.slice(0, 3).map((item: any) => ({
[baseConfig.socials], 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), const activeBanners = useMemo(
[baseConfig.banners], () => banners.filter((item: any) => item?.status === "ACTIVE" || item?.status === 1),
[banners],
); );
if (!ready) { if (isLoading) {
return ( return (
<div className="grid gap-5 lg:grid-cols-4"> <div className="grid gap-5 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => ( {Array.from({ length: 8 }).map((_, index) => (
...@@ -284,7 +398,7 @@ export default function AdminDashboardPage() { ...@@ -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="text-xs uppercase tracking-[0.16em] text-slate-400">Phản hồi</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]"> <div className="mt-2 text-2xl font-semibold text-[#163b73]">
{contactRequests.length + membershipApplications.length} {contactRequests.length}
</div> </div>
<div className="mt-1 text-sm text-slate-500">đơn đang cần theo dõi</div> <div className="mt-1 text-sm text-slate-500">đơn đang cần theo dõi</div>
</Link> </Link>
...@@ -303,8 +417,12 @@ export default function AdminDashboardPage() { ...@@ -303,8 +417,12 @@ export default function AdminDashboardPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-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="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-2 text-lg font-semibold text-[#163b73]">
<div className="mt-1 truncate text-sm text-slate-500">{baseConfig.websiteLink}</div> {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>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
...@@ -313,16 +431,18 @@ export default function AdminDashboardPage() { ...@@ -313,16 +431,18 @@ export default function AdminDashboardPage() {
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div> <div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div>
</div> </div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4"> <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="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]">{visibleSocials.length}</div> <div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerCategoryCount}</div>
</div> </div>
</div> </div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4"> <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="text-xs uppercase tracking-[0.14em] text-slate-400">Liên hệ</div>
<div className="mt-2 text-lg font-semibold text-[#163b73]">{baseConfig.branches.length} địa điểm</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"> <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>
</div> </div>
</CardContent> </CardContent>
...@@ -366,27 +486,33 @@ export default function AdminDashboardPage() { ...@@ -366,27 +486,33 @@ export default function AdminDashboardPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{recentActivities.map((item) => ( {recentActivities.length === 0 ? (
<Link <div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-6 text-center text-sm text-slate-500">
key={item.id} Không có dữ liệu hoạt động
href={item.href} </div>
className="flex items-start justify-between gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]" ) : (
> recentActivities.map((item) => (
<div className="min-w-0"> <Link
<div className="flex items-center gap-2"> key={item.id}
<Badge variant="outline" className="border-[#063e8e]/15 text-[#063e8e]"> href={item.href}
{item.badge} className="flex items-start justify-between gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]"
</Badge> >
<span className="text-xs text-slate-400">{formatDateTime(item.time)}</span> <div className="min-w-0">
</div> <div className="flex items-center gap-2">
<div className="mt-2 line-clamp-1 text-sm font-semibold text-[#163b73]"> <Badge variant="outline" className="border-[#063e8e]/15 text-[#063e8e]">
{item.title} {item.badge}
</Badge>
<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 || "—"}
</div>
<div className="mt-1 line-clamp-2 text-sm text-slate-500">{item.description}</div>
</div> </div>
<div className="mt-1 line-clamp-2 text-sm text-slate-500">{item.description}</div> <ArrowRight className="mt-1 h-4 w-4 shrink-0 text-slate-400" />
</div> </Link>
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-slate-400" /> ))
</Link> )}
))}
</CardContent> </CardContent>
</Card> </Card>
...@@ -431,34 +557,31 @@ export default function AdminDashboardPage() { ...@@ -431,34 +557,31 @@ export default function AdminDashboardPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{spotlightNews.map((item) => ( {spotlightNews.length === 0 ? (
<Link <div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-6 text-center text-sm text-slate-500">
key={item.id} Không có dữ liệu bài viết
href={`/admin/news/${item.id}`} </div>
className="flex gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]" ) : (
> spotlightNews.map((item) => (
<div className="relative h-20 w-28 shrink-0 overflow-hidden rounded-2xl bg-[#eef4ff]"> <Link
{item.thumbnail ? ( key={item.id}
<SafeImage href={`/admin/news/${item.id}`}
src={item.thumbnail.url} className="flex gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]"
alt={item.thumbnail.alt || item.thumbnail.name} >
fill <div className="relative h-20 w-28 shrink-0 overflow-hidden rounded-2xl bg-[#eef4ff]">
className="object-cover" <SpotlightThumbnail fileId={item.thumbnail_id} />
/> </div>
) : ( <div className="min-w-0">
<div className="flex h-full items-center justify-center text-[#063e8e]"> <div className="line-clamp-2 text-sm font-semibold text-[#163b73]">
<Newspaper className="h-5 w-5" /> {item.title || "—"}
</div>
<div className="mt-2 text-xs text-slate-400">
{formatDateTime(item.updated_at || item.created_at || "")}
</div> </div>
)}
</div>
<div className="min-w-0">
<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)}
</div> </div>
</div> </Link>
</Link> ))
))} )}
</CardContent> </CardContent>
</Card> </Card>
...@@ -473,12 +596,12 @@ export default function AdminDashboardPage() { ...@@ -473,12 +596,12 @@ export default function AdminDashboardPage() {
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4"> <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="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 className="mt-1 text-sm text-slate-500">mục điều hướng</div>
</div> </div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4"> <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="text-xs uppercase tracking-[0.14em] text-slate-400">Bài viết</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerPosts.length}</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 className="mt-1 text-sm text-slate-500">bản ghi nội dung</div>
</div> </div>
</div> </div>
...@@ -491,16 +614,22 @@ export default function AdminDashboardPage() { ...@@ -491,16 +614,22 @@ export default function AdminDashboardPage() {
</Badge> </Badge>
</div> </div>
<div className="mt-4 grid grid-cols-3 gap-3"> <div className="mt-4 grid grid-cols-3 gap-3">
{mediaItems.slice(0, 3).map((item) => ( {mediaItems.length === 0 ? (
<div key={item.id} className="relative aspect-square overflow-hidden rounded-2xl bg-[#eef4ff]"> <div className="col-span-3 py-6 text-center text-sm text-slate-500">
<SafeImage Không có dữ liệu ảnh
src={item.url}
alt={item.alt || item.name}
fill
className="object-cover"
/>
</div> </div>
))} ) : (
mediaItems.slice(0, 3).map((item) => (
<div key={item.id} className="relative aspect-square overflow-hidden rounded-2xl bg-[#eef4ff]">
<Image
src={item.url}
alt={item.alt || item.name}
fill
className="object-cover"
/>
</div>
))
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
...@@ -516,9 +645,9 @@ export default function AdminDashboardPage() { ...@@ -516,9 +645,9 @@ export default function AdminDashboardPage() {
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{[ {[
{ label: "Video", value: videos.length, icon: MonitorPlay }, { label: "Video", value: videos.length, icon: MonitorPlay },
{ label: "Lĩnh vực hội viên", value: memberFields.length, icon: FolderTree }, { label: "Doanh nghiệp hội viên", value: businesses.length, icon: FolderTree },
{ label: "Khu vực hội viên", value: memberRegions.length, icon: MapPin }, { label: "Chức vụ hội viên", value: positions.length, icon: MapPin },
{ label: "Chi nhánh liên hệ", value: baseConfig.branches.length, icon: Globe }, { label: "Banner", value: banners.length, icon: Globe },
].map((item) => ( ].map((item) => (
<div <div
key={item.label} key={item.label}
......
...@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions"; ...@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid"; import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Pagination } from "@/components/base/pagination"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -26,10 +26,15 @@ import { ...@@ -26,10 +26,15 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post"; import {
import { deleteApiV10PostId } from "@/api/vcci-news/endpoints/post"; useDeleteApiV10PostId,
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category"; useGetApiV10Post,
import { ADMIN_NEWS_TYPE_LABELS, type AdminNewsItem } from "@/mockdata/admin-news"; } 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 { normalizeUser } from "@/lib/utils/cms-user";
import { normalizeDateTimeInput } from "@/lib/utils/datetime"; import { normalizeDateTimeInput } from "@/lib/utils/datetime";
import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content"; import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content";
...@@ -48,18 +53,84 @@ export default function HeaderCategoryPostsPage() { ...@@ -48,18 +53,84 @@ export default function HeaderCategoryPostsPage() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const categoryId = String(params.categoryId ?? ""); const categoryId = String(params.categoryId ?? "");
const [items, setItems] = useState<AdminNewsItem[]>([]); const { data: treeResponse, isLoading: isTreeLoading } = useGetApiV10CategoryTree();
const [headerTree, setHeaderTree] = useState<HeaderCategoryTreeItem[]>([]); const { mutateAsync: deletePost, isPending: isDeletingPost } = useDeleteApiV10PostId();
const [search, setSearch] = useState(() => searchParams.get("q") ?? ""); const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
const [ready, setReady] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AdminNewsItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<AdminNewsItem | null>(null);
const didMountRef = useRef(false); const didMountRef = useRef(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => { const [page, setPage] = useState(() => {
const parsedPage = Number(searchParams.get("page") ?? 1); const parsedPage = Number(searchParams.get("page") ?? 1);
return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1; return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1;
}); });
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 postsQuery = useGetApiV10Post({
page,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
filters: apiFilters,
});
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 : [];
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),
};
}),
[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 listQueryString = useMemo(() => {
const nextParams = new URLSearchParams(); const nextParams = new URLSearchParams();
...@@ -79,87 +150,6 @@ export default function HeaderCategoryPostsPage() { ...@@ -79,87 +150,6 @@ export default function HeaderCategoryPostsPage() {
[listQueryString, pathname], [listQueryString, pathname],
); );
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const keyword = search.trim();
const filters = [
`category.id==${categoryId}`,
keyword ? `title@=${keyword}|slug@=${keyword}` : "",
].filter(Boolean).join(",");
const [newsResponse, treeResponse] = await Promise.all([
getApiV10Post({
page,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
filters: filters.trim() || undefined,
}),
getApiV10CategoryTree(),
]);
if (cancelled) return;
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 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),
};
}));
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);
}
};
void load();
return () => {
cancelled = true;
};
}, [categoryId, page, search]);
const flatCategories = useMemo(() => { const flatCategories = useMemo(() => {
return flattenTree(headerTree); return flattenTree(headerTree);
}, [headerTree]); }, [headerTree]);
...@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() { ...@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() {
}, [items, total]); }, [items, total]);
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget || isDeletingPost) return;
try { try {
await deleteApiV10PostId(deleteTarget.id); await deletePost({ id: deleteTarget.id });
setItems((current) => current.filter((item) => item.id !== deleteTarget.id));
setTotal((current) => Math.max(0, current - 1));
toast.success("Đã xóa bài viết"); toast.success("Đã xóa bài viết");
setDeleteTarget(null); setDeleteTarget(null);
postsQuery.refetch();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể xóa bài viết"); toast.error(error instanceof Error ? error.message : "Không thể xóa bài viết");
} }
...@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() { ...@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() {
<TableCell className="text-center"> <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]"> <div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]">
{item.thumbnail ? ( {item.thumbnail ? (
<SafeImage <Image
src={item.thumbnail.url} src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name} alt={item.thumbnail.alt || item.thumbnail.name}
fill fill
......
...@@ -20,11 +20,11 @@ import { ...@@ -20,11 +20,11 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { toSlug } from "@/lib/utils/header-category";
type HeaderCategoryTreeItem, import type {
type HeaderCategoryType, HeaderCategoryTreeItem,
toSlug, HeaderCategoryType,
} from "@/mockdata/header-config"; } from "@/api/vcci-news/types/header-config";
export type HeaderCategoryFormMode = "create" | "edit"; export type HeaderCategoryFormMode = "create" | "edit";
......
...@@ -20,10 +20,8 @@ import { ...@@ -20,10 +20,8 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { getHeaderCategoryTypeLabel } from "@/lib/utils/header-category";
type HeaderCategoryTreeItem, import type { HeaderCategoryTreeItem } from "@/api/vcci-news/types/header-config";
getHeaderCategoryTypeLabel,
} from "@/mockdata/header-config";
export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & { export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
depth: number; depth: number;
......
"use client"; "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 { toast } from "sonner";
import { import {
HeaderCategoryDeleteDialog, HeaderCategoryDeleteDialog,
...@@ -12,17 +18,11 @@ import { ...@@ -12,17 +18,11 @@ import {
HeaderCategoryTable, HeaderCategoryTable,
} from "./components"; } from "./components";
import { buildStaticLink } from "@/lib/utils/header-link"; import { buildStaticLink } from "@/lib/utils/header-link";
import { import { toSlug } from "@/lib/utils/header-category";
deleteApiV10CategoryId, import type {
getApiV10CategoryTree,
postApiV10Category,
putApiV10CategoryId,
} from "@/api/vcci-news/endpoints/category";
import {
HeaderCategoryItem, HeaderCategoryItem,
HeaderCategoryTreeItem, HeaderCategoryTreeItem,
toSlug, } from "@/api/vcci-news/types/header-config";
} from "@/mockdata/header-config";
const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = { const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
name: "", name: "",
...@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue ...@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
} }
function useHeaderConfigModule() { function useHeaderConfigModule() {
const {
data: treeResponse,
isLoading,
error,
refetch: refetchTree,
} = useGetApiV10CategoryTree();
const [tree, setTree] = useState<HeaderCategoryTreeItem[]>([]); const [tree, setTree] = useState<HeaderCategoryTreeItem[]>([]);
const [rootStaticLink, setRootStaticLink] = useState("/"); 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); setTree(treeData);
setRootStaticLink("/"); setRootStaticLink("/");
setIsReady(true); }, [treeResponse]);
}, []);
useEffect(() => { 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"); toast.error(error instanceof Error ? error.message : "Không thể tải cấu hình danh mục");
setIsReady(true); }
}); }, [error]);
}, [load]);
return { return {
tree, tree,
rootStaticLink, rootStaticLink,
isReady, isReady: !isLoading,
reload: load, reload: refetchTree,
}; };
} }
export default function HeaderConfigPage() { export default function HeaderConfigPage() {
const { tree, rootStaticLink, isReady, reload } = useHeaderConfigModule(); 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 [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
...@@ -221,7 +226,7 @@ export default function HeaderConfigPage() { ...@@ -221,7 +226,7 @@ export default function HeaderConfigPage() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) return; if (isSubmitting || isMutating) return;
if (isProtectedHomeCategory(formValues.id)) { if (isProtectedHomeCategory(formValues.id)) {
return; return;
...@@ -277,10 +282,10 @@ export default function HeaderConfigPage() { ...@@ -277,10 +282,10 @@ export default function HeaderConfigPage() {
}; };
if (formMode === "create") { if (formMode === "create") {
await postApiV10Category(payload); await createCategory({ data: payload });
toast.success("Tạo danh mục thành công"); toast.success("Tạo danh mục thành công");
} else if (formValues.id) { } 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"); toast.success("Cập nhật danh mục thành công");
} }
...@@ -295,7 +300,7 @@ export default function HeaderConfigPage() { ...@@ -295,7 +300,7 @@ export default function HeaderConfigPage() {
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget || isSubmitting) return; if (!deleteTarget || isSubmitting || isMutating) return;
if (isProtectedHomeCategory(deleteTarget.id)) { if (isProtectedHomeCategory(deleteTarget.id)) {
setDeleteTarget(null); setDeleteTarget(null);
...@@ -305,7 +310,7 @@ export default function HeaderConfigPage() { ...@@ -305,7 +310,7 @@ export default function HeaderConfigPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await deleteApiV10CategoryId(deleteTarget.id); await deleteCategory({ id: deleteTarget.id });
toast.success("Xóa danh mục thành công"); toast.success("Xóa danh mục thành công");
setDeleteTarget(null); setDeleteTarget(null);
await reload(); await reload();
......
...@@ -8,7 +8,7 @@ import { ...@@ -8,7 +8,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
...@@ -103,7 +103,7 @@ export function MediaFormDialog({ ...@@ -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="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%)]"> <div className="relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]">
{form.previewUrl ? ( {form.previewUrl ? (
<SafeImage <Image
src={form.previewUrl} src={form.previewUrl}
alt={form.name} alt={form.name}
fill fill
......
...@@ -9,7 +9,7 @@ import { ...@@ -9,7 +9,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; 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 { Pagination } from "@/components/base/pagination";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -161,7 +161,7 @@ export default function AdminMediaPage() { ...@@ -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)]" 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%)]"> <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)} src={resolveCmsFileUrl(item.path)}
alt={item.original ?? ""} alt={item.original ?? ""}
fill fill
......
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Save, Upload, X } from "lucide-react"; import { ArrowLeft, Save, Upload, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminImagePicker } from "@/components/admin/image-picker"; import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminPostContentEditor } from "@/components/admin/post-content-editor"; import Image from "next/image";
import { SafeImage } from "@/components/shared/safe-image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { import {
Select, Select,
SelectContent, SelectContent,
...@@ -19,24 +18,19 @@ import { ...@@ -19,24 +18,19 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { import {
type AdminMediaItem, useGetApiV10MemberId,
type AdminNewsContentSection, usePostApiV10Member,
} from "@/mockdata/admin-news"; usePutApiV10MemberId,
} from "@/api/vcci-news/endpoints/member";
import { useGetApiV10Business } from "@/api/vcci-news/endpoints/business";
import { useGetApiV10Position } from "@/api/vcci-news/endpoints/position";
import { import {
type MemberField, type AdminMemberFormValues,
type MemberFormValues,
type MemberImageRef,
type MemberItem,
type MemberRegion,
EMPTY_MEMBER_FORM, EMPTY_MEMBER_FORM,
cloneMemberFormValues, extractRows,
createMemberId, } from "@/lib/utils/admin-member";
persistMembers,
readMemberFields,
readMemberRegions,
readMembers,
} from "@/mockdata/members";
interface AdminMemberFormProps { interface AdminMemberFormProps {
memberId?: string; memberId?: string;
...@@ -53,112 +47,144 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700"; ...@@ -53,112 +47,144 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]"; "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) { export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const isNew = !memberId || memberId === "new"; const isNew = !memberId || memberId === "new";
const [form, setForm] = useState<MemberFormValues>(EMPTY_MEMBER_FORM); const [form, setForm] = useState<AdminMemberFormValues>(EMPTY_MEMBER_FORM);
const [fields, setFields] = useState<MemberField[]>([]);
const [regions, setRegions] = useState<MemberRegion[]>([]);
const [imagePickerOpen, setImagePickerOpen] = useState(false); const [imagePickerOpen, setImagePickerOpen] = useState(false);
const [saving, setSaving] = 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(() => { useEffect(() => {
const allFields = readMemberFields(); if (isNew) {
const allRegions = readMemberRegions(); setForm(EMPTY_MEMBER_FORM);
setFields(allFields); setHydrated(true);
setRegions(allRegions); setNotFound(false);
return;
if (!isNew) { }
const all = readMembers(); const memberData = (memberQuery as any).data as any;
const found = all.find((m) => m.id === memberId); if (!memberData) return;
if (found) {
setForm(cloneMemberFormValues(found)); 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); setForm({
}, [isNew, memberId]); id: String(row.id ?? ""),
full_name: row.full_name ?? "",
const set = <K extends keyof MemberFormValues>(key: K, value: MemberFormValues[K]) => { 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 })); setForm((previous) => ({ ...previous, [key]: value }));
}; };
const handleImageSelect = (item: AdminMediaItem) => { const handleImageSelect = (item: AdminMediaItem) => {
set("image", toImageRef(item)); set("avatar_url", item.url);
setImagePickerOpen(false); setImagePickerOpen(false);
}; };
const handleIntroductionChange = (sections: AdminNewsContentSection[]) => { const handleSave = async () => {
set("introduction", sections); if (!form.full_name.trim()) {
};
const handleSave = () => {
if (!form.name.trim()) {
toast.error("Vui lòng nhập tên hội viên"); toast.error("Vui lòng nhập tên hội viên");
return; return;
} }
setSaving(true); setSaving(true);
try { try {
const now = new Date().toISOString(); const payload = {
const all = readMembers(); 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) { if (isNew) {
const newItem: MemberItem = { await createMember.mutateAsync({ data: payload });
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]);
toast.success("Đã thêm hội viên mới"); toast.success("Đã thêm hội viên mới");
} else { } else {
const nextAll = all.map((m) => { await updateMember.mutateAsync({ id: memberId!, data: payload });
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);
toast.success("Đã lưu thay đổi"); toast.success("Đã lưu thay đổi");
} }
queryClient.invalidateQueries({ queryKey: ["getApiV10Member"] });
router.push("/admin/members"); 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 { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (!ready) { if (!hydrated || (!isNew && memberQuery.isLoading)) {
return ( return (
<div className="flex h-64 items-center justify-center"> <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" /> <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) { ...@@ -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 ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/admin/members"> <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" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
...@@ -180,7 +238,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) { ...@@ -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"} {isNew ? "Thêm hội viên mới" : "Chỉnh sửa hội viên"}
</h1> </h1>
<p className="text-sm text-gray-500"> <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> </p>
</div> </div>
</div> </div>
...@@ -189,7 +247,9 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) { ...@@ -189,7 +247,9 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
{/* Main info */} {/* Main info */}
<div className="space-y-6 xl:col-span-2"> <div className="space-y-6 xl:col-span-2">
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"> <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"> <div className="space-y-4">
{/* Name */} {/* Name */}
<div className="space-y-1.5"> <div className="space-y-1.5">
...@@ -197,198 +257,161 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) { ...@@ -197,198 +257,161 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
Tên hội viên <span className="text-red-500">*</span> Tên hội viên <span className="text-red-500">*</span>
</Label> </Label>
<Input <Input
value={form.name} value={form.full_name}
onChange={(e) => set("name", e.target.value)} onChange={(e) => set("full_name", e.target.value)}
placeholder="Nhập tên hội viên..." placeholder="Nhập tên hội viên..."
className={fieldClassName} className={fieldClassName}
/> />
</div> </div>
{/* Region & Field */} {/* Business & Position */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700">Khu vực</Label> <Label className="text-gray-700">Doanh nghiệp</Label>
<Select value={form.region_id} onValueChange={(v) => set("region_id", v)}> <Select
value={form.business_id || undefined}
onValueChange={(v) => set("business_id", v)}
>
<SelectTrigger className={selectTriggerClassName}> <SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn khu vực" /> <SelectValue placeholder="Chọn doanh nghiệp" />
</SelectTrigger> </SelectTrigger>
<SelectContent className={selectContentClassName}> <SelectContent className={selectContentClassName}>
{regions.map((r) => ( {businesses.length === 0 ? (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}> <div className="px-3 py-6 text-center text-sm text-gray-700">
{r.name} Không có dữ liệu doanh nghiệp
</SelectItem> </div>
))} ) : (
businesses.map((b) => (
<SelectItem
key={b.id}
value={b.id}
className={selectItemClassName}
>
{b.name}
</SelectItem>
))
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700">Lĩnh vực</Label> <Label className="text-gray-700">Chức vụ</Label>
<Select value={form.field_id} onValueChange={(v) => set("field_id", v)}> <Select
value={form.position_id || undefined}
onValueChange={(v) => set("position_id", v)}
>
<SelectTrigger className={selectTriggerClassName}> <SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Chọn lĩnh vực" /> <SelectValue placeholder="Chọn chức vụ" />
</SelectTrigger> </SelectTrigger>
<SelectContent className={selectContentClassName}> <SelectContent className={selectContentClassName}>
{fields.map((f) => ( {positions.length === 0 ? (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}> <div className="px-3 py-6 text-center text-sm text-gray-700">
{f.name} Không có dữ liệu chức vụ
</SelectItem> </div>
))} ) : (
positions.map((p) => (
<SelectItem
key={p.id}
value={p.id}
className={selectItemClassName}
>
{p.name}
</SelectItem>
))
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
{/* Address */} {/* Job title & Birth date */}
<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 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700">Email</Label> <Label className="text-gray-700">Chức danh</Label>
<Input <Input
type="email" value={form.job_title ?? ""}
value={form.email} onChange={(e) => set("job_title", e.target.value)}
onChange={(e) => set("email", e.target.value)} placeholder="Nhập chức danh..."
placeholder="Nhập email..."
className={fieldClassName} className={fieldClassName}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700">Website</Label> <Label className="text-gray-700">Ngày sinh</Label>
<Input <Input
value={form.website} type="date"
onChange={(e) => set("website", e.target.value)} value={form.birth_date ?? ""}
placeholder="https://..." onChange={(e) => set("birth_date", e.target.value)}
className={fieldClassName} className={fieldClassName}
/> />
</div> </div>
</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>
</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> </div>
{/* Side panel */} {/* Side panel */}
<div className="space-y-6"> <div className="space-y-6">
{/* Actions */}
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"> <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"> <div className="flex flex-col gap-3">
<Button <Button
type="button" type="button"
disabled={saving}
onClick={handleSave} 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" /> <Save className="mr-2 h-4 w-4" />
{saving ? "Đang lưu..." : "Lưu hội viên"} {saving ? "Đang lưu..." : "Lưu hội viên"}
</Button> </Button>
<Link href="/admin/members" className="w-full"> <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 Hủy
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Image */}
<div className="rounded-2xl border border-[#063e8e]/15 bg-white p-6 shadow-sm"> <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> <h2 className="mb-4 text-base font-semibold text-[#063e8e]">
{form.image ? ( Ảnh đại diện
</h2>
{form.avatar_url ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-[#063e8e]/15"> <div className="relative aspect-square overflow-hidden rounded-full border border-[#063e8e]/15">
<SafeImage <Image
src={form.image.url} src={form.avatar_url}
alt={form.image.alt || form.image.name} alt={form.full_name}
fill fill
className="object-cover" className="object-cover"
/> />
</div> </div>
<div className="flex gap-2"> <Button
<Button type="button"
type="button" variant="outline"
variant="outline" className="w-full border-red-200 text-red-600 hover:bg-red-50"
size="sm" onClick={() => set("avatar_url", null)}
className="flex-1 border-[#063e8e]/15 text-xs" >
onClick={() => setImagePickerOpen(true)} <X className="mr-2 h-4 w-4" />
> Xóa ảnh
<Upload className="mr-1.5 h-3.5 w-3.5" /> </Button>
Đổi ả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> </div>
) : ( ) : (
<Button <Button
type="button" type="button"
variant="outline" 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)} onClick={() => setImagePickerOpen(true)}
> >
<Upload className="mr-2 h-4 w-4" /> <Upload className="mr-2 h-4 w-4" />
Chọn ảnh Chọn ảnh đại diện
</Button> </Button>
)} )}
</div> </div>
...@@ -397,7 +420,6 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) { ...@@ -397,7 +420,6 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
<AdminImagePicker <AdminImagePicker
open={imagePickerOpen} open={imagePickerOpen}
selectedId={form.image?.id ?? null}
onOpenChange={setImagePickerOpen} onOpenChange={setImagePickerOpen}
onSelect={handleImageSelect} onSelect={handleImageSelect}
/> />
......
...@@ -7,7 +7,6 @@ import { ...@@ -7,7 +7,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { MemberField, MemberRegion } from "@/api/vcci-news/types/member";
import { import {
selectContentClassName, selectContentClassName,
selectItemClassName, selectItemClassName,
...@@ -15,8 +14,8 @@ import { ...@@ -15,8 +14,8 @@ import {
} from "./constants"; } from "./constants";
interface MemberFiltersProps { interface MemberFiltersProps {
fields: MemberField[]; fields: { id: string; name: string }[];
regions: MemberRegion[]; regions: { id: string; name: string }[];
fieldFilter: string; fieldFilter: string;
regionFilter: string; regionFilter: string;
onFieldFilterChange: (value: string) => void; onFieldFilterChange: (value: string) => void;
...@@ -35,11 +34,11 @@ export function MemberFilters({ ...@@ -35,11 +34,11 @@ export function MemberFilters({
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Select value={fieldFilter} onValueChange={onFieldFilterChange}> <Select value={fieldFilter} onValueChange={onFieldFilterChange}>
<SelectTrigger className={selectTriggerClassName}> <SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Lĩnh vực" /> <SelectValue placeholder="Doanh nghiệp" />
</SelectTrigger> </SelectTrigger>
<SelectContent className={selectContentClassName}> <SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}> <SelectItem value="all" className={selectItemClassName}>
Tất cả lĩnh vực Tất cả doanh nghiệp
</SelectItem> </SelectItem>
{fields.map((f) => ( {fields.map((f) => (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}> <SelectItem key={f.id} value={f.id} className={selectItemClassName}>
...@@ -51,11 +50,11 @@ export function MemberFilters({ ...@@ -51,11 +50,11 @@ export function MemberFilters({
<Select value={regionFilter} onValueChange={onRegionFilterChange}> <Select value={regionFilter} onValueChange={onRegionFilterChange}>
<SelectTrigger className={selectTriggerClassName}> <SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Khu vực" /> <SelectValue placeholder="Chức vụ" />
</SelectTrigger> </SelectTrigger>
<SelectContent className={selectContentClassName}> <SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}> <SelectItem value="all" className={selectItemClassName}>
Tất cả khu vực Tất cả chức vụ
</SelectItem> </SelectItem>
{regions.map((r) => ( {regions.map((r) => (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}> <SelectItem key={r.id} value={r.id} className={selectItemClassName}>
......
"use client"; "use client";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; 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 { Badge } from "@/components/ui/badge";
import { import {
TableCell, TableCell,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { MemberItem } from "@/api/vcci-news/types/member"; import type { AdminMemberRow } from "@/lib/utils/admin-member";
interface MemberRowProps { interface MemberRowProps {
item: MemberItem; item: AdminMemberRow;
index: number; index: number;
fieldName: string | undefined; fieldName: string | undefined;
regionName: string | undefined; regionName: string | undefined;
...@@ -33,30 +33,30 @@ export function MemberRow({ ...@@ -33,30 +33,30 @@ export function MemberRow({
> >
<TableCell className="px-4 py-3 text-sm font-medium text-gray-800"> <TableCell className="px-4 py-3 text-sm font-medium text-gray-800">
<div className="space-y-1"> <div className="space-y-1">
<div>{item.name}</div> <div>{item.full_name}</div>
{item.is_featured ? ( {item.job_title ? (
<Badge <Badge
variant="outline" variant="outline"
className="border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]" className="border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
> >
Hội viên tiêu biểu {item.job_title}
</Badge> </Badge>
) : null} ) : null}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center"> <TableCell className="px-4 py-3 text-center">
{item.image ? ( {item.avatar_url ? (
<div className="mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15"> <div className="mx-auto h-12 w-12 overflow-hidden rounded-full border border-[#063e8e]/15">
<SafeImage <Image
src={item.image.url} src={item.avatar_url}
alt={item.image.alt || item.name} alt={item.full_name}
width={64} width={48}
height={48} height={48}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
</div> </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ó Chưa có
</div> </div>
)} )}
...@@ -68,13 +68,10 @@ export function MemberRow({ ...@@ -68,13 +68,10 @@ export function MemberRow({
{fieldName ?? "—"} {fieldName ?? "—"}
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600"> <TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{item.phone && <div>{item.phone}</div>} <span className="line-clamp-2">{item.job_title || "—"}</span>
{item.email && (
<div className="truncate text-xs text-[#063e8e]">{item.email}</div>
)}
</TableCell> </TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600"> <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>
<TableCell className="px-4 py-3 text-center"> <TableCell className="px-4 py-3 text-center">
<AdminRowActions <AdminRowActions
......
...@@ -12,19 +12,18 @@ import { ...@@ -12,19 +12,18 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { MemberField } from "@/api/vcci-news/types/member";
const fieldClassName = const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"; "border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface FieldFormDialogProps { interface FieldFormDialogProps {
open: boolean; open: boolean;
initial: MemberField | null; initial: { id: string; name: string } | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => 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(""); const [name, setName] = useState("");
useEffect(() => { useEffect(() => {
...@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo ...@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
const handleSave = () => { const handleSave = () => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { 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; return;
} }
onSave({ id: initial?.id, name: trimmed }); onSave({ id: initial?.id, name: trimmed });
...@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo ...@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white"> <DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-[#063e8e]"> <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> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700"> <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> </Label>
<Input <Input
value={name} value={name}
onChange={(event) => setName(event.target.value)} 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} className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()} onKeyDown={(event) => event.key === "Enter" && !saving && handleSave()}
disabled={saving}
/> />
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
...@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo ...@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
type="button" type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90" className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave} onClick={handleSave}
disabled={saving}
> >
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Lưu {saving ? "Đang lưu..." : "Lưu"}
</Button> </Button>
</div> </div>
</div> </div>
......
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner"; 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 { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
...@@ -14,26 +21,34 @@ import { ...@@ -14,26 +21,34 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { extractRows } from "@/lib/utils/admin-member";
type MemberField,
createMemberFieldId,
persistMemberFields,
readMemberFields,
} from "@/mockdata/members";
import { FieldFormDialog } from "./_components/field-form-dialog"; import { FieldFormDialog } from "./_components/field-form-dialog";
export default function AdminMemberFieldsPage() { export default function AdminMemberFieldsPage() {
const [items, setItems] = useState<MemberField[]>([]); const queryClient = useQueryClient();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editTarget, setEditTarget] = useState<MemberField | null>(null); const [editTarget, setEditTarget] = useState<{ id: string; name: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<MemberField | null>(null); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [ready, setReady] = useState(false);
const businessesQuery = useGetApiV10Business({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const { mutateAsync: createBusiness, isPending: isCreating } = usePostApiV10Business();
const { mutateAsync: updateBusiness, isPending: isUpdating } = usePutApiV10BusinessId();
const { mutateAsync: deleteBusiness, isPending: isDeleting } = useDeleteApiV10BusinessId();
useEffect(() => { const items = useMemo(() => {
setItems(readMemberFields()); const rows = extractRows((businessesQuery.data as any)?.responseData);
setReady(true); return rows.map((row: any) => ({
}, []); id: String(row.id ?? ""),
name: String(row.name ?? ""),
}));
}, [businessesQuery.data]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
...@@ -46,46 +61,54 @@ export default function AdminMemberFieldsPage() { ...@@ -46,46 +61,54 @@ export default function AdminMemberFieldsPage() {
setDialogOpen(true); setDialogOpen(true);
}; };
const openEdit = (item: MemberField) => { const openEdit = (item: { id: string; name: string }) => {
setEditTarget(item); setEditTarget(item);
setDialogOpen(true); setDialogOpen(true);
}; };
const handleSave = (data: { id?: string; name: string }) => { const handleSave = async (data: { id?: string; name: string }) => {
let next: MemberField[]; try {
if (data.id) {
if (data.id) { await updateBusiness({ id: data.id, data: { name: data.name } });
next = items.map((item) => (item.id === data.id ? { ...item, name: data.name } : item)); toast.success("Đã cập nhật doanh nghiệp");
toast.success("Đã cập nhật lĩnh vực"); } else {
} else { await createBusiness({ data: { name: data.name } });
next = [...items, { id: createMemberFieldId(), name: data.name }]; toast.success("Đã thêm doanh nghiệp mới");
toast.success("Đã thêm lĩnh vực mới"); }
queryClient.invalidateQueries();
setDialogOpen(false);
} catch (error) {
console.error(error);
toast.error("Không lưu được doanh nghiệp");
} }
setItems(next);
persistMemberFields(next);
setDialogOpen(false);
}; };
const handleDelete = () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const next = items.filter((item) => item.id !== deleteTarget.id); try {
setItems(next); await deleteBusiness({ id: deleteTarget.id });
persistMemberFields(next); queryClient.invalidateQueries();
toast.success("Đã xóa lĩnh vực"); toast.success("Đã xóa doanh nghiệp");
setDeleteTarget(null); 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<AdminTableLayout <AdminTableLayout
searchValue={search} searchValue={search}
searchPlaceholder="Tìm kiếm lĩnh vực..." searchPlaceholder="Tìm kiếm doanh nghiệp..."
actionLabel="Thêm lĩnh vực" actionLabel="Thêm doanh nghiệp"
actionIcon={<Plus className="mr-2 h-4 w-4" />} actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionMeta={ actionMeta={
<div className="text-sm font-medium text-gray-700"> <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> </div>
} }
onSearchChange={setSearch} onSearchChange={setSearch}
...@@ -95,12 +118,12 @@ export default function AdminMemberFieldsPage() { ...@@ -95,12 +118,12 @@ export default function AdminMemberFieldsPage() {
<TableHeader> <TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]"> <TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-16 py-4 text-center text-white">STT</TableHead> <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> <TableHead className="w-[120px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!ready ? ( {!hasLoaded ? (
Array.from({ length: 3 }).map((_, index) => ( Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}> <TableRow key={`loading-${index}`}>
<TableCell colSpan={3} className="px-4 py-4"> <TableCell colSpan={3} className="px-4 py-4">
...@@ -111,7 +134,7 @@ export default function AdminMemberFieldsPage() { ...@@ -111,7 +134,7 @@ export default function AdminMemberFieldsPage() {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="py-16 text-center text-gray-400"> <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> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
...@@ -129,8 +152,8 @@ export default function AdminMemberFieldsPage() { ...@@ -129,8 +152,8 @@ export default function AdminMemberFieldsPage() {
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<AdminRowActions <AdminRowActions
actions={[ actions={[
{ kind: "edit", label: "Chỉnh sửa lĩnh vực", onClick: () => openEdit(item) }, { kind: "edit", label: "Chỉnh sửa doanh nghiệp", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa lĩnh vực", onClick: () => setDeleteTarget(item) }, { kind: "delete", label: "Xóa doanh nghiệp", onClick: () => setDeleteTarget(item) },
]} ]}
/> />
</TableCell> </TableCell>
...@@ -146,14 +169,15 @@ export default function AdminMemberFieldsPage() { ...@@ -146,14 +169,15 @@ export default function AdminMemberFieldsPage() {
initial={editTarget} initial={editTarget}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
onSave={handleSave} onSave={handleSave}
saving={saving || isDeleting}
/> />
<AdminDeleteDialog <AdminDeleteDialog
open={!!deleteTarget} open={!!deleteTarget}
title="Xóa lĩnh vực" title="Xóa doanh nghiệp"
description={ 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ể <span className="font-semibold">{deleteTarget?.name}</span>? Hành động này không thể
hoàn tác. hoàn tác.
</> </>
......
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Plus, Users } from "lucide-react"; import { Plus, Users } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; 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 { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid"; import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
...@@ -16,93 +20,125 @@ import { ...@@ -16,93 +20,125 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import {
type MemberField, extractRows,
type MemberItem, mapApiRowToAdminMemberRow,
type MemberRegion, type AdminMemberRow,
persistMembers, } from "@/lib/utils/admin-member";
readMemberFields,
readMemberRegions,
readMembers,
} from "@/mockdata/members";
import { MemberFilters } from "./_components/member-filters"; import { MemberFilters } from "./_components/member-filters";
import { MemberRow } from "./_components/member-row"; import { MemberRow } from "./_components/member-row";
import { MemberTableLoading } from "./_components/member-table-loading"; import { MemberTableLoading } from "./_components/member-table-loading";
import { useGetApiV10Member } from "@/api/vcci-news/endpoints/member";
export default function AdminMembersPage() { export default function AdminMembersPage() {
const router = useRouter(); const router = useRouter();
const [items, setItems] = useState<MemberItem[]>([]); const queryClient = useQueryClient();
const [fields, setFields] = useState<MemberField[]>([]);
const [regions, setRegions] = useState<MemberRegion[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [fieldFilter, setFieldFilter] = useState("all"); const [businessFilter, setBusinessFilter] = useState("all");
const [regionFilter, setRegionFilter] = useState("all"); const [positionFilter, setPositionFilter] = useState("all");
const [deleteTarget, setDeleteTarget] = useState<MemberItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<AdminMemberRow | null>(null);
const [ready, setReady] = useState(false);
const membersQuery = useGetApiV10Member({
useEffect(() => { page: 1,
setItems(readMembers()); pageSize: 200,
setFields(readMemberFields()); sortField: "created_at",
setRegions(readMemberRegions()); sortOrder: "desc",
setReady(true); });
}, []); 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 filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
return items.filter((item) => { return members.filter((item) => {
const matchesKeyword = const matchesKeyword =
!keyword || !keyword ||
item.name.toLowerCase().includes(keyword) || item.full_name.toLowerCase().includes(keyword) ||
item.email.toLowerCase().includes(keyword) || (item.job_title ?? "").toLowerCase().includes(keyword);
item.address.toLowerCase().includes(keyword);
const matchesField = fieldFilter === "all" || item.field_id === fieldFilter; const matchesBusiness =
const matchesRegion = regionFilter === "all" || item.region_id === regionFilter; 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( const stats = useMemo(
() => [ () => [
{ {
label: "Tổng hội viên", label: "Tổng hội viên",
value: items.length, value: members.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />, icon: <Users className="h-4 w-4 text-[#063e8e]" />,
}, },
{ {
label: "Số lĩnh vực", label: "Số doanh nghiệp",
value: fields.length, value: businesses.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />, icon: <Users className="h-4 w-4 text-[#063e8e]" />,
}, },
{ {
label: "Số khu vực", label: "Số chức vụ",
value: regions.length, value: positions.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />, icon: <Users className="h-4 w-4 text-[#063e8e]" />,
}, },
], ],
[items, fields, regions], [members, businesses, positions],
);
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],
); );
const handleDelete = () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const next = items.filter((m) => m.id !== deleteTarget.id); try {
setItems(next); await deleteMember({ id: deleteTarget.id });
persistMembers(next); queryClient.invalidateQueries();
toast.success("Đã xóa hội viên"); toast.success("Đã xóa hội viên");
setDeleteTarget(null); 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<AdminStatsGrid items={stats} /> <AdminStatsGrid items={stats} />
...@@ -116,12 +152,12 @@ export default function AdminMembersPage() { ...@@ -116,12 +152,12 @@ export default function AdminMembersPage() {
onActionClick={() => router.push("/admin/members/new")} onActionClick={() => router.push("/admin/members/new")}
filters={ filters={
<MemberFilters <MemberFilters
fields={fields} fields={businesses}
regions={regions} regions={positions}
fieldFilter={fieldFilter} fieldFilter={businessFilter}
regionFilter={regionFilter} regionFilter={positionFilter}
onFieldFilterChange={setFieldFilter} onFieldFilterChange={setBusinessFilter}
onRegionFilterChange={setRegionFilter} onRegionFilterChange={setPositionFilter}
/> />
} }
> >
...@@ -129,17 +165,17 @@ export default function AdminMembersPage() { ...@@ -129,17 +165,17 @@ export default function AdminMembersPage() {
<Table className="min-w-[900px] table-fixed"> <Table className="min-w-[900px] table-fixed">
<TableHeader> <TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]"> <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-[220px] 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-[160px] py-4 text-center text-white">Ảnh đại diện</TableHead>
<TableHead className="w-40 py-4 text-center text-white">Khu vực</TableHead> <TableHead className="w-48 py-4 text-center text-white">Chức vụ</TableHead>
<TableHead className="w-40 py-4 text-center text-white">Lĩnh vực</TableHead> <TableHead className="w-48 py-4 text-center text-white">Doanh nghiệp</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">Chức danh</TableHead>
<TableHead className="w-[180px] py-4 text-center text-white">Địa chỉ</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> <TableHead className="w-[100px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!ready ? ( {!hasLoaded || !ready ? (
<MemberTableLoading /> <MemberTableLoading />
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<TableRow> <TableRow>
...@@ -153,8 +189,8 @@ export default function AdminMembersPage() { ...@@ -153,8 +189,8 @@ export default function AdminMembersPage() {
key={item.id} key={item.id}
item={item} item={item}
index={index} index={index}
fieldName={fieldMap[item.field_id]} fieldName={item.business_id ? businessMap[item.business_id] : undefined}
regionName={regionMap[item.region_id]} regionName={item.position_id ? positionMap[item.position_id] : undefined}
onEdit={() => router.push(`/admin/members/${item.id}`)} onEdit={() => router.push(`/admin/members/${item.id}`)}
onDelete={() => setDeleteTarget(item)} onDelete={() => setDeleteTarget(item)}
/> />
...@@ -171,7 +207,7 @@ export default function AdminMembersPage() { ...@@ -171,7 +207,7 @@ export default function AdminMembersPage() {
description={ description={
<> <>
Bạn có chắc muốn xóa hội viên{" "} 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. hoàn tác.
</> </>
} }
......
...@@ -12,19 +12,18 @@ import { ...@@ -12,19 +12,18 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { MemberRegion } from "@/api/vcci-news/types/member";
const fieldClassName = const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30"; "border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface RegionFormDialogProps { interface RegionFormDialogProps {
open: boolean; open: boolean;
initial: MemberRegion | null; initial: { id: string; name: string } | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => 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(""); const [name, setName] = useState("");
useEffect(() => { useEffect(() => {
...@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region ...@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
const handleSave = () => { const handleSave = () => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { 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; return;
} }
onSave({ id: initial?.id, name: trimmed }); onSave({ id: initial?.id, name: trimmed });
...@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region ...@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white"> <DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-[#063e8e]"> <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> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 pt-2"> <div className="space-y-4 pt-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-gray-700"> <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> </Label>
<Input <Input
value={name} value={name}
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên khu vực..." placeholder="Nhập tên chức vụ..."
className={fieldClassName} className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()} onKeyDown={(event) => event.key === "Enter" && !saving && handleSave()}
disabled={saving}
/> />
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
...@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region ...@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
type="button" type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90" className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave} onClick={handleSave}
disabled={saving}
> >
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Lưu {saving ? "Đang lưu..." : "Lưu"}
</Button> </Button>
</div> </div>
</div> </div>
......
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { toast } from "sonner"; 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 { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; import { AdminTableLayout } from "@/components/admin/admin-table-layout";
...@@ -14,26 +21,34 @@ import { ...@@ -14,26 +21,34 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import { extractRows } from "@/lib/utils/admin-member";
type MemberRegion,
createMemberRegionId,
persistMemberRegions,
readMemberRegions,
} from "@/mockdata/members";
import { RegionFormDialog } from "./_components/region-form-dialog"; import { RegionFormDialog } from "./_components/region-form-dialog";
export default function AdminMemberRegionsPage() { export default function AdminMemberRegionsPage() {
const [items, setItems] = useState<MemberRegion[]>([]); const queryClient = useQueryClient();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editTarget, setEditTarget] = useState<MemberRegion | null>(null); const [editTarget, setEditTarget] = useState<{ id: string; name: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<MemberRegion | null>(null); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [ready, setReady] = useState(false);
const positionsQuery = useGetApiV10Position({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const { mutateAsync: createPosition, isPending: isCreating } = usePostApiV10Position();
const { mutateAsync: updatePosition, isPending: isUpdating } = usePutApiV10PositionId();
const { mutateAsync: deletePosition, isPending: isDeleting } = useDeleteApiV10PositionId();
useEffect(() => { const items = useMemo(() => {
setItems(readMemberRegions()); const rows = extractRows((positionsQuery.data as any)?.responseData);
setReady(true); return rows.map((row: any) => ({
}, []); id: String(row.id ?? ""),
name: String(row.name ?? ""),
}));
}, [positionsQuery.data]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase(); const keyword = search.trim().toLowerCase();
...@@ -46,46 +61,54 @@ export default function AdminMemberRegionsPage() { ...@@ -46,46 +61,54 @@ export default function AdminMemberRegionsPage() {
setDialogOpen(true); setDialogOpen(true);
}; };
const openEdit = (item: MemberRegion) => { const openEdit = (item: { id: string; name: string }) => {
setEditTarget(item); setEditTarget(item);
setDialogOpen(true); setDialogOpen(true);
}; };
const handleSave = (data: { id?: string; name: string }) => { const handleSave = async (data: { id?: string; name: string }) => {
let next: MemberRegion[]; try {
if (data.id) {
if (data.id) { await updatePosition({ id: data.id, data: { name: data.name } });
next = items.map((item) => (item.id === data.id ? { ...item, name: data.name } : item)); toast.success("Đã cập nhật chức vụ");
toast.success("Đã cập nhật khu vực"); } else {
} else { await createPosition({ data: { name: data.name } });
next = [...items, { id: createMemberRegionId(), name: data.name }]; toast.success("Đã thêm chức vụ mới");
toast.success("Đã thêm khu vực mới"); }
queryClient.invalidateQueries();
setDialogOpen(false);
} catch (error) {
console.error(error);
toast.error("Không lưu được chức vụ");
} }
setItems(next);
persistMemberRegions(next);
setDialogOpen(false);
}; };
const handleDelete = () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const next = items.filter((item) => item.id !== deleteTarget.id); try {
setItems(next); await deletePosition({ id: deleteTarget.id });
persistMemberRegions(next); queryClient.invalidateQueries();
toast.success("Đã xóa khu vực"); toast.success("Đã xóa chức vụ");
setDeleteTarget(null); 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<AdminTableLayout <AdminTableLayout
searchValue={search} searchValue={search}
searchPlaceholder="Tìm kiếm khu vực..." searchPlaceholder="Tìm kiếm chức vụ..."
actionLabel="Thêm khu vực" actionLabel="Thêm chức vụ"
actionIcon={<Plus className="mr-2 h-4 w-4" />} actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionMeta={ actionMeta={
<div className="text-sm font-medium text-gray-700"> <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> </div>
} }
onSearchChange={setSearch} onSearchChange={setSearch}
...@@ -95,12 +118,12 @@ export default function AdminMemberRegionsPage() { ...@@ -95,12 +118,12 @@ export default function AdminMemberRegionsPage() {
<TableHeader> <TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]"> <TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-16 py-4 text-center text-white">STT</TableHead> <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> <TableHead className="w-[120px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{!ready ? ( {!hasLoaded ? (
Array.from({ length: 3 }).map((_, index) => ( Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}> <TableRow key={`loading-${index}`}>
<TableCell colSpan={3} className="px-4 py-4"> <TableCell colSpan={3} className="px-4 py-4">
...@@ -111,7 +134,7 @@ export default function AdminMemberRegionsPage() { ...@@ -111,7 +134,7 @@ export default function AdminMemberRegionsPage() {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="py-16 text-center text-gray-400"> <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> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
...@@ -129,8 +152,8 @@ export default function AdminMemberRegionsPage() { ...@@ -129,8 +152,8 @@ export default function AdminMemberRegionsPage() {
<TableCell className="py-3 text-center"> <TableCell className="py-3 text-center">
<AdminRowActions <AdminRowActions
actions={[ actions={[
{ kind: "edit", label: "Chỉnh sửa khu vực", onClick: () => openEdit(item) }, { kind: "edit", label: "Chỉnh sửa chức vụ", onClick: () => openEdit(item) },
{ kind: "delete", label: "Xóa khu vực", onClick: () => setDeleteTarget(item) }, { kind: "delete", label: "Xóa chức vụ", onClick: () => setDeleteTarget(item) },
]} ]}
/> />
</TableCell> </TableCell>
...@@ -146,14 +169,15 @@ export default function AdminMemberRegionsPage() { ...@@ -146,14 +169,15 @@ export default function AdminMemberRegionsPage() {
initial={editTarget} initial={editTarget}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
onSave={handleSave} onSave={handleSave}
saving={saving || isDeleting}
/> />
<AdminDeleteDialog <AdminDeleteDialog
open={!!deleteTarget} open={!!deleteTarget}
title="Xóa khu vực" title="Xóa chức vụ"
description={ 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ể <span className="font-semibold">{deleteTarget?.name}</span>? Hành động này không thể
hoàn tác. hoàn tác.
</> </>
......
...@@ -17,7 +17,7 @@ import { AdminImagePicker } from "@/components/admin/image-picker"; ...@@ -17,7 +17,7 @@ import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminPostContentEditor } from "@/components/admin/post-content-editor"; import { AdminPostContentEditor } from "@/components/admin/post-content-editor";
import { PostHistoryViewer } from "./post-history-viewer"; import { PostHistoryViewer } from "./post-history-viewer";
import { AdminRichTextEditor } from "@/components/shared/rich-text-editor"; 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 { PermissionGate } from "@/components/shared/permission-gate";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
...@@ -37,31 +37,33 @@ import { ...@@ -37,31 +37,33 @@ import {
type TagItem, type TagItem,
} from "@/api/vcci-news/types/tag"; } from "@/api/vcci-news/types/tag";
import { import {
getApiV10Post, useGetApiV10Post,
getApiV10PostId, useGetApiV10PostId,
postApiV10Post, usePostApiV10Post,
putApiV10PostId, usePutApiV10PostId,
} from "@/api/vcci-news/endpoints/post"; } from "@/api/vcci-news/endpoints/post";
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category"; import { useGetApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import { import {
deleteApiV10PostTagPostId, useDeleteApiV10PostTagPostId,
getApiV10PostTagPostId, useGetApiV10PostTagPostId,
postApiV10PostTagPostIdBulk, usePostApiV10PostTagPostIdBulk,
} from "@/api/vcci-news/endpoints/post-tag"; } from "@/api/vcci-news/endpoints/post-tag";
import { import {
getApiV10Tag, useGetApiV10Tag,
postApiV10TagIds, usePostApiV10TagIds,
} from "@/api/vcci-news/endpoints/tag"; } from "@/api/vcci-news/endpoints/tag";
import { useGetApiV10EventAddress } from "@/api/vcci-news/endpoints/event-address"; import { useGetApiV10EventAddress } from "@/api/vcci-news/endpoints/event-address";
import { import {
ADMIN_NEWS_TYPE_OPTIONS, ADMIN_NEWS_TYPE_OPTIONS,
cloneAdminNewsFormValues,
type AdminMediaItem, type AdminMediaItem,
type AdminNewsFormValues, type AdminNewsFormValues,
type AdminNewsItem, type AdminNewsItem,
} from "@/api/vcci-news/types/post";
import {
cloneAdminNewsFormValues,
resolveAdminNewsType, resolveAdminNewsType,
slugifyAdminNews, slugifyAdminNews,
} from "@/mockdata/admin-news"; } from "@/lib/utils/admin-news";
import { normalizeUser } from "@/lib/utils/cms-user"; import { normalizeUser } from "@/lib/utils/cms-user";
import { normalizeDateTimeInput } from "@/lib/utils/datetime"; import { normalizeDateTimeInput } from "@/lib/utils/datetime";
import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content"; import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content";
...@@ -91,6 +93,45 @@ import { ...@@ -91,6 +93,45 @@ import {
} from "./utils"; } from "./utils";
import useNewsDraftStore, { type NewsDraftData } from "@/store/useNewsDraftStore"; 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() { export function AdminNewsFormContent() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
...@@ -112,6 +153,7 @@ export function AdminNewsFormContent() { ...@@ -112,6 +153,7 @@ export function AdminNewsFormContent() {
const [useEventDates, setUseEventDates] = useState(false); const [useEventDates, setUseEventDates] = useState(false);
const [isAddressManagerOpen, setIsAddressManagerOpen] = useState(false); const [isAddressManagerOpen, setIsAddressManagerOpen] = useState(false);
const [isAddressSuggestOpen, setIsAddressSuggestOpen] = useState(false); const [isAddressSuggestOpen, setIsAddressSuggestOpen] = useState(false);
const [postTagNames, setPostTagNames] = useState<string[]>([]);
const eventAddressesQuery = useGetApiV10EventAddress({ const eventAddressesQuery = useGetApiV10EventAddress({
page: 1, page: 1,
...@@ -120,6 +162,35 @@ export function AdminNewsFormContent() { ...@@ -120,6 +162,35 @@ export function AdminNewsFormContent() {
sortOrder: "asc", 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 addressOptions = useMemo(() => {
const rows = eventAddressesQuery.data?.responseData?.rows ?? []; const rows = eventAddressesQuery.data?.responseData?.rows ?? [];
const values = Array.from( const values = Array.from(
...@@ -151,179 +222,126 @@ export function AdminNewsFormContent() { ...@@ -151,179 +222,126 @@ export function AdminNewsFormContent() {
const [pendingDraft, setPendingDraft] = useState<NewsDraftData | null>(null); const [pendingDraft, setPendingDraft] = useState<NewsDraftData | null>(null);
const draftRestoredRef = useRef(false); const draftRestoredRef = useRef(false);
// Sync header tree from orval query
useEffect(() => { useEffect(() => {
let cancelled = false; const tree = (treeResponse?.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
setHeaderTree(tree);
}, [treeResponse]);
const load = async () => { // Sync tag list from orval query
setIsLoadingInitialData(true); useEffect(() => {
setIsMissingPost(false); const nextTagsResult = (tagsResponse?.responseData ?? {}) as { rows?: TagItem[] };
setForm(isCreate ? cloneAdminNewsFormValues() : null); setAllTags(nextTagsResult.rows ?? []);
}, [tagsResponse]);
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),
};
});
})();
if (cancelled) return;
setItems(nextNewsItems);
setHeaderTree(nextHeaderTree);
setAllTags(nextTags);
if (isCreate) {
const now = new Date().toISOString();
setForm({
...cloneAdminNewsFormValues(),
type: "tintuc",
header_category_id: "",
category_ids: [],
created_at: now,
updated_at: now,
});
setUseEventDates(false);
return;
}
const existingItem = nextNewsItems.find((item) => item.id === newsId); // Sync post rows from orval query
const currentItem = existingItem useEffect(() => {
? existingItem if (isCreate) {
: newsId setItems([]);
? await (async () => { return;
const response = await getApiV10PostId(newsId); }
const post = (response.responseData ?? {}) as any; const result = (postsListQuery.data?.responseData ?? {}) as { rows?: any[] };
// Fetch tags for post setItems((result.rows ?? []).map(mapPostRowToAdminNewsItem));
const tagsRes = await getApiV10PostTagPostId(newsId); }, [isCreate, postsListQuery.data]);
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[];
}
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 {
id: post.id ?? "",
title: post.title ?? "",
slug: post.slug ?? "",
summary: post.summary ?? "",
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),
is_featured: Boolean(post.is_featured),
thumbnail: post.thumbnail?.id ? {
id: post.thumbnail.id,
name: post.thumbnail.original ?? post.thumbnail.path ?? "thumbnail",
alt: post.thumbnail.original ?? post.thumbnail.path ?? "thumbnail",
url: links.resolveImageUrl(post.thumbnail.path),
} : null,
is_hidden: Boolean(post.is_hidden),
created_at: post.created_at ?? "",
updated_at: post.updated_at ?? "",
published_at: normalizeDateTimeInput(post.published_at ?? post.release_at),
expired_at: normalizeDateTimeInput(post.expired_at),
started_at: normalizeDateTimeInput(post.started_at),
ended_at: normalizeDateTimeInput(post.ended_at),
registration_deadline: normalizeDateTimeInput(post.registration_deadline),
location: post.location ?? "",
participation_fee: post.participation_fee ?? "",
event_dates: Array.isArray(post.event_dates) ? post.event_dates.filter((d: any): d is string => typeof d === "string") : [],
post_content: postContent,
creator: normalizeUser(post.creator),
editor: normalizeUser(post.editor),
} as AdminNewsItem;
})()
: null;
if (cancelled) return;
if (!currentItem) {
setIsMissingPost(true);
setForm(null);
return;
}
setIsMissingPost(false); // Resolve tag names for current post
const nextForm = cloneAdminNewsFormValues(currentItem); useEffect(() => {
setForm(nextForm); if (isCreate || !newsId) {
setUseEventDates((nextForm.event_dates ?? []).length > 0); setPostTagNames([]);
} catch (error) { return;
if (cancelled) return; }
toast.error(error instanceof Error ? error.message : "Không thể tải bài viết"); const tagsResult = (postTagsQuery.data?.responseData ?? {}) as { rows?: { tag_id?: string }[] };
setIsMissingPost(!isCreate); const tagIds = (tagsResult.rows ?? []).map(r => r.tag_id).filter(Boolean) as string[];
setForm(isCreate ? cloneAdminNewsFormValues() : null); if (tagIds.length === 0) {
} finally { setPostTagNames([]);
if (!cancelled) { return;
setIsLoadingInitialData(false); }
} let cancelled = false;
void (async () => {
try {
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([]);
} }
}; })();
void load();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isCreate, newsId]); }, [isCreate, newsId, postTagsQuery.data, fetchTagsByIds]);
// Build form from post detail query
useEffect(() => {
if (isCreate) {
const now = new Date().toISOString();
setForm({
...cloneAdminNewsFormValues(),
type: "tintuc",
header_category_id: "",
category_ids: [],
created_at: now,
updated_at: now,
});
setUseEventDates(false);
setIsMissingPost(false);
setIsLoadingInitialData(false);
return;
}
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 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;
const currentItem = {
id: post.id ?? "",
title: post.title ?? "",
slug: post.slug ?? "",
summary: post.summary ?? "",
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: postTagNames,
is_featured: Boolean(post.is_featured),
thumbnail: post.thumbnail?.id ? {
id: post.thumbnail.id,
name: post.thumbnail.original ?? post.thumbnail.path ?? "thumbnail",
alt: post.thumbnail.original ?? post.thumbnail.path ?? "thumbnail",
url: links.resolveImageUrl(post.thumbnail.path),
} : null,
is_hidden: Boolean(post.is_hidden),
created_at: post.created_at ?? "",
updated_at: post.updated_at ?? "",
published_at: normalizeDateTimeInput(post.published_at ?? post.release_at),
expired_at: normalizeDateTimeInput(post.expired_at),
started_at: normalizeDateTimeInput(post.started_at),
ended_at: normalizeDateTimeInput(post.ended_at),
registration_deadline: normalizeDateTimeInput(post.registration_deadline),
location: post.location ?? "",
participation_fee: post.participation_fee ?? "",
event_dates: Array.isArray(post.event_dates) ? post.event_dates.filter((d: any): d is string => typeof d === "string") : [],
post_content: postContent,
creator: normalizeUser(post.creator),
editor: normalizeUser(post.editor),
} as AdminNewsItem;
setIsMissingPost(false);
const nextForm = cloneAdminNewsFormValues(currentItem);
setForm(nextForm);
setUseEventDates((nextForm.event_dates ?? []).length > 0);
setIsLoadingInitialData(false);
}, [isCreate, newsId, postDetailQuery.data, postDetailQuery.isLoading, postTagNames]);
// Check draft sau khi load xong + store hydrated — chỉ hỏi 1 lần // Check draft sau khi load xong + store hydrated — chỉ hỏi 1 lần
useEffect(() => { useEffect(() => {
...@@ -536,7 +554,7 @@ export function AdminNewsFormContent() { ...@@ -536,7 +554,7 @@ export function AdminNewsFormContent() {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (!form || isSubmitting) return; if (!form || isSubmitting || isCreatingPost || isUpdatingPost) return;
if (!form.title.trim()) { if (!form.title.trim()) {
toast.error("Tiêu đề bài viết là bắt buộc"); toast.error("Tiêu đề bài viết là bắt buộc");
...@@ -608,20 +626,20 @@ export function AdminNewsFormContent() { ...@@ -608,20 +626,20 @@ export function AdminNewsFormContent() {
}; };
if (isCreate) { if (isCreate) {
const response = await postApiV10Post(payload as any); const response = await createPost({ data: payload as any });
const created = (response.responseData ?? {}) as { id?: string }; const created = (response.responseData ?? {}) as { id?: string };
if (created.id && tagIds.length > 0) { 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) { } 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 // Xóa tags cũ rồi bulk add tags mới
const currentRes = await getApiV10PostTagPostId(newsId); const currentRes = await postTagsQuery.refetch();
const currentResult = (currentRes.responseData ?? {}) as { rows?: { tag_id?: string }[] }; const currentResult = (currentRes.data?.responseData ?? {}) as { rows?: { tag_id?: string }[] };
const currentTagIds = (currentResult.rows ?? []).map(r => r.tag_id).filter(Boolean) as 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) { 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() { ...@@ -748,7 +766,7 @@ export function AdminNewsFormContent() {
<div className="relative overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white"> <div className="relative overflow-hidden rounded-2xl border border-[#063e8e]/15 bg-white">
<div className="relative aspect-[16/11]"> <div className="relative aspect-[16/11]">
{form.thumbnail ? ( {form.thumbnail ? (
<SafeImage <Image
src={form.thumbnail.url} src={form.thumbnail.url}
alt={form.thumbnail.alt || form.thumbnail.name} alt={form.thumbnail.alt || form.thumbnail.name}
fill fill
......
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import type { AdminNewsItem } from "@/api/vcci-news/types/post";
type AdminNewsItem, import type {
} from "@/mockdata/admin-news"; HeaderCategoryItem,
import { HeaderCategoryTreeItem,
type HeaderCategoryItem, } from "@/api/vcci-news/types/header-config";
type HeaderCategoryTreeItem,
} from "@/mockdata/header-config";
export type FlattenedHeaderCategory = HeaderCategoryItem & { depth: number }; export type FlattenedHeaderCategory = HeaderCategoryItem & { depth: number };
......
...@@ -15,7 +15,7 @@ import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog"; ...@@ -15,7 +15,7 @@ import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions"; import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid"; import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout"; 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -44,14 +44,12 @@ import { ...@@ -44,14 +44,12 @@ import {
ADMIN_NEWS_TYPE_LABELS, ADMIN_NEWS_TYPE_LABELS,
ADMIN_NEWS_TYPE_OPTIONS, ADMIN_NEWS_TYPE_OPTIONS,
type AdminNewsItem, type AdminNewsItem,
} from "@/mockdata/admin-news"; } from "@/api/vcci-news/types/post";
import { normalizeUser } from "@/lib/utils/cms-user"; import { normalizeUser } from "@/lib/utils/cms-user";
import { normalizeDateTimeInput } from "@/lib/utils/datetime"; import { normalizeDateTimeInput } from "@/lib/utils/datetime";
import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content"; import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content";
import links from "@/links"; import links from "@/links";
import { import type { HeaderCategoryTreeItem } from "@/api/vcci-news/types/header-config";
type HeaderCategoryTreeItem,
} from "@/mockdata/header-config";
import { AdminNewsTableLoading } from "./_components/admin-news-table-loading"; import { AdminNewsTableLoading } from "./_components/admin-news-table-loading";
import { CategoryFilterCombobox } from "./_components/category-filter-combobox"; import { CategoryFilterCombobox } from "./_components/category-filter-combobox";
import { import {
...@@ -468,7 +466,7 @@ export default function AdminNewsPage() { ...@@ -468,7 +466,7 @@ export default function AdminNewsPage() {
<TableCell className="text-center"> <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]"> <div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]">
{item.thumbnail ? ( {item.thumbnail ? (
<SafeImage <Image
src={item.thumbnail.url} src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name} alt={item.thumbnail.alt || item.thumbnail.name}
fill fill
......
...@@ -198,25 +198,25 @@ export default function PasswordResetRequestsPage() { ...@@ -198,25 +198,25 @@ export default function PasswordResetRequestsPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]"> <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 STT
</TableHead> </TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap"> <TableHead className="py-4 text-left text-white">
Email Email
</TableHead> </TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap"> <TableHead className="py-4 text-left text-white">
Ghi chú Ghi chú
</TableHead> </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 Trạng thái
</TableHead> </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 Thời gian
</TableHead> </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ý Người xử lý
</TableHead> </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 Thao tác
</TableHead> </TableHead>
</TableRow> </TableRow>
...@@ -224,7 +224,7 @@ export default function PasswordResetRequestsPage() { ...@@ -224,7 +224,7 @@ export default function PasswordResetRequestsPage() {
<TableBody> <TableBody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<TableRow> <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 Không có yêu cầu nào
</TableCell> </TableCell>
</TableRow> </TableRow>
...@@ -232,66 +232,62 @@ export default function PasswordResetRequestsPage() { ...@@ -232,66 +232,62 @@ export default function PasswordResetRequestsPage() {
rows.map((req, index) => ( rows.map((req, index) => (
<TableRow <TableRow
key={req.id} 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} {(currentPage - 1) * PAGE_SIZE + index + 1}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-sm text-gray-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-slate-400 shrink-0" /> <Mail className="h-4 w-4 text-gray-400 shrink-0" />
<span className="text-sm font-medium text-[#163b73]"> <span className="truncate max-w-[200px]" title={req.email}>
{req.email} {req.email}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-sm text-gray-800">
<p className="text-sm text-slate-600 truncate max-w-[200px]"> <p className="truncate max-w-[200px]">{req.note || "—"}</p>
{req.note || "-"}
</p>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="py-3 text-center">
{req.status === "PENDING" && ( {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" /> <Clock className="mr-1 h-4 w-4" />
Chờ xử lý Chờ xử lý
</Badge> </span>
)} )}
{req.status === "RESOLVED" && ( {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" /> <CheckCircle className="mr-1 h-4 w-4" />
Đã xử lý Đã xử lý
</Badge> </span>
)} )}
{req.status === "REJECTED" && ( {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" /> <XCircle className="mr-1 h-4 w-4" />
Từ chối Từ chối
</Badge> </span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-center text-sm text-gray-700">
<p className="text-sm text-slate-500 whitespace-nowrap"> {formatDate(req.created_at)}
{formatDate(req.created_at)}
</p>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-center text-sm text-gray-700">
{req.resolved_by_user ? ( {req.resolved_by_user ? (
<span className="text-sm text-slate-600"> <span className="truncate max-w-[160px] inline-block">
{req.resolved_by_user.email} {req.resolved_by_user.email}
</span> </span>
) : ( ) : (
<span className="text-sm text-slate-400">-</span> <span className="text-gray-400">—</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-center">
{req.status === "PENDING" && canWrite ? ( {req.status === "PENDING" && canWrite ? (
<div className="flex items-center gap-1"> <div className="flex items-center justify-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleOpenResolve(req)} 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" title="Reset mật khẩu"
> >
<KeyRound className="h-4 w-4" /> <KeyRound className="h-4 w-4" />
...@@ -300,14 +296,14 @@ export default function PasswordResetRequestsPage() { ...@@ -300,14 +296,14 @@ export default function PasswordResetRequestsPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleOpenReject(req)} 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" title="Từ chối"
> >
<XCircle className="h-4 w-4" /> <XCircle className="h-4 w-4" />
</Button> </Button>
</div> </div>
) : ( ) : (
<span className="text-sm text-slate-400">-</span> <span className="text-sm text-gray-400">—</span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
......
...@@ -60,14 +60,14 @@ export function UserTable({ ...@@ -60,14 +60,14 @@ export function UserTable({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]"> <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="w-16 py-4 text-center text-white">STT</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">Email</TableHead> <TableHead className="py-4 text-left text-white">Email</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">Họ tên</TableHead> <TableHead className="py-4 text-left text-white">Họ tên</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">Vai trò</TableHead> <TableHead className="py-4 text-center text-white">Vai trò</TableHead>
<TableHead className="py-3 text-center text-white text-sm font-semibold whitespace-nowrap">Trạng thái</TableHead> <TableHead className="w-[140px] py-4 text-center text-white">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="w-[170px] py-4 text-center text-white">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-[170px] py-4 text-center text-white">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-[130px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
...@@ -79,7 +79,7 @@ export function UserTable({ ...@@ -79,7 +79,7 @@ export function UserTable({
</TableRow> </TableRow>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<TableRow> <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 Không tìm thấy người dùng nào
</TableCell> </TableCell>
</TableRow> </TableRow>
...@@ -87,58 +87,58 @@ export function UserTable({ ...@@ -87,58 +87,58 @@ export function UserTable({
users.map((user, index) => ( users.map((user, index) => (
<TableRow <TableRow
key={user.id} 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} {(currentPage - 1) * PAGE_SIZE + index + 1}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-sm text-gray-800">
<p className="text-sm text-slate-700 truncate max-w-[180px]" title={user.email}> <p className="truncate max-w-[180px]" title={user.email}>
{user.email} {user.email}
</p> </p>
{user.username && ( {user.username && (
<p className="text-xs text-slate-400">@{user.username}</p> <p className="text-xs text-gray-500">@{user.username}</p>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-left text-sm text-gray-800">
<p className="text-sm font-medium text-[#163b73] whitespace-nowrap"> <p className="whitespace-nowrap">
{user.first_name || user.last_name ? `${user.first_name || ""} ${user.last_name || ""}`.trim() : "-"} {user.first_name || user.last_name ? `${user.first_name || ""} ${user.last_name || ""}`.trim() : "—"}
</p> </p>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-center">
<div className="flex flex-wrap gap-1 max-w-[120px]"> <div className="flex flex-wrap justify-center gap-1 max-w-[140px] mx-auto">
{user.roles?.slice(0, 2).map((roleName) => ( {user.roles?.slice(0, 2).map((roleName) => (
<Badge <Badge
key={roleName} key={roleName}
variant="secondary" variant="outline"
className="border border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5 whitespace-nowrap" className="border-[#063e8e]/25 text-[#063e8e] text-xs px-2 py-0.5 whitespace-nowrap font-normal"
> >
{roleName} {roleName}
</Badge> </Badge>
))} ))}
{(user.roles?.length || 0) > 2 && ( {(user.roles?.length || 0) > 2 && (
<Badge <Badge
variant="secondary" variant="outline"
className="border border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5" className="border-[#063e8e]/25 text-[#063e8e] text-xs px-2 py-0.5 font-normal"
> >
+{user.roles!.length - 2} +{user.roles!.length - 2}
</Badge> </Badge>
)} )}
{(!user.roles || user.roles.length === 0) && ( {(!user.roles || user.roles.length === 0) && (
<span className="text-sm text-slate-400">-</span> <span className="text-sm text-gray-500">—</span>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="py-3 text-center">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<Badge <span
variant="outline" className={
className={`text-sm whitespace-nowrap ${user.status === "active" user.status === "active"
? "border-green-200 bg-green-50 text-green-700" ? "inline-flex rounded-full bg-[#edf7ee] px-3 py-1 text-xs font-semibold text-[#16803d]"
: user.status === "pending_verification" : user.status === "pending_verification"
? "border-amber-200 bg-amber-50 text-amber-700" ? "inline-flex rounded-full bg-[#fff4e5] px-3 py-1 text-xs font-semibold text-[#c7760d]"
: "border-red-200 bg-red-50 text-red-700" : "inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-semibold text-red-700"
}`} }
> >
{user.status === "active" ? ( {user.status === "active" ? (
<CheckCircle className="mr-1 h-4 w-4" /> <CheckCircle className="mr-1 h-4 w-4" />
...@@ -150,35 +150,31 @@ export function UserTable({ ...@@ -150,35 +150,31 @@ export function UserTable({
{user.status === "active" ? "Hoạt động" : {user.status === "active" ? "Hoạt động" :
user.status === "pending_verification" ? "Chờ duyệt" : user.status === "pending_verification" ? "Chờ duyệt" :
user.status === "inactive" ? "Khóa" : user.status} user.status === "inactive" ? "Khóa" : user.status}
</Badge> </span>
{user.user_auth?.must_change_password && ( {user.user_auth?.must_change_password && (
<Badge <Badge
variant="outline" 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" /> <Key className="mr-0.5 h-3 w-3" />
</Badge> </Badge>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="py-3 text-center text-sm text-gray-700">
<p className="text-sm text-slate-500 whitespace-nowrap"> {user.created_at ? formatDate(user.created_at) : "—"}
{user.created_at ? formatDate(user.created_at) : "-"}
</p>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="py-3 text-center text-sm text-gray-700">
<p className="text-sm text-slate-500 whitespace-nowrap"> {user.updated_at ? formatDate(user.updated_at) : "—"}
{user.updated_at ? formatDate(user.updated_at) : "-"}
</p>
</TableCell> </TableCell>
<TableCell> <TableCell className="py-3 text-center">
<PermissionGate required="users:write"> <PermissionGate required="users:write">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { ImagePlus, Search, Upload, X } from "lucide-react"; import { ImagePlus, Search, Upload, X } from "lucide-react";
import Image from "next/image";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
...@@ -12,12 +13,12 @@ import { ...@@ -12,12 +13,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { SafeImage } from "@/components/shared/safe-image";
import type { AdminMediaItem } from "@/mockdata/admin-news"; import type { AdminMediaItem } from "@/mockdata/admin-news";
import { toAdminMediaItem } from "@/lib/utils/file"; 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 { Pagination } from "@/components/base/pagination";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChangeEvent, useEffect, useRef, useState } from "react";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
...@@ -41,68 +42,55 @@ export function AdminImagePicker({ ...@@ -41,68 +42,55 @@ export function AdminImagePicker({
onOpenChange, onOpenChange,
onSelect, onSelect,
}: AdminImagePickerProps) { }: AdminImagePickerProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [search, setSearch] = React.useState(""); const [search, setSearch] = useState("");
const [items, setItems] = React.useState<AdminMediaItem[]>([]); const [items, setItems] = useState<AdminMediaItem[]>([]);
const [page, setPage] = React.useState(1); const [page, setPage] = useState(1);
const [total, setTotal] = React.useState(0); const [total, setTotal] = useState(0);
const [ready, setReady] = React.useState(false); const fileQuery = useGetApiV10File(
const [uploading, setUploading] = React.useState(false); {
page,
const load = React.useCallback(async () => { pageSize: PAGE_SIZE,
if (!open) return; sortField: "created_at",
sortOrder: "desc",
setReady(false); filters: [
try {
const keyword = search.trim();
const filters = [
"mime@=image", "mime@=image",
keyword ? `original@=${keyword}|path@=${keyword}` : "", search.trim() ? `original@=${search.trim()}|path@=${search.trim()}` : "",
].filter(Boolean).join(","); ].filter(Boolean).join(","),
},
const response = await getApiV10File({ { query: { enabled: open } },
page, );
pageSize: PAGE_SIZE, const uploadMutation = usePostApiV10FileUpload();
sortField: "created_at", const ready = open && !fileQuery.isFetching;
sortOrder: "desc", const uploading = uploadMutation.isPending;
filters,
}); useEffect(() => {
const pageData = response.responseData ?? {}; const pageData = fileQuery.data?.responseData;
if (!pageData) return;
setItems((pageData.rows ?? []).map(toAdminMediaItem)); setItems((pageData.rows ?? []).map(toAdminMediaItem));
setTotal(pageData.count ?? 0); setTotal(pageData.count ?? 0);
} catch (error) { }, [fileQuery.data]);
toast.error(error instanceof Error ? error.message : "Không thể tải thư viện hình ảnh");
setItems([]); useEffect(() => {
setTotal(0); if (!fileQuery.error) return;
} finally { toast.error(fileQuery.error instanceof Error ? fileQuery.error.message : "Không thể tải thư viện hình ảnh");
setReady(true); setItems([]);
} setTotal(0);
}, [open, page, search]); }, [fileQuery.error]);
React.useEffect(() => { useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (!open) return; if (!open) return;
setPage(1); setPage(1);
}, [open, search]); }, [open, search]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); 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]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setUploading(true);
try { try {
const response = await postApiV10FileUpload({ const response = await uploadMutation.mutateAsync({ data: { file, original: file.name } });
file,
original: file.name,
});
const uploaded = response.responseData ?? null; const uploaded = response.responseData ?? null;
if (!uploaded) { if (!uploaded) {
...@@ -116,7 +104,6 @@ export function AdminImagePicker({ ...@@ -116,7 +104,6 @@ export function AdminImagePicker({
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể tải hình ảnh lên"); toast.error(error instanceof Error ? error.message : "Không thể tải hình ảnh lên");
} finally { } finally {
setUploading(false);
event.target.value = ""; event.target.value = "";
} }
}; };
...@@ -210,7 +197,7 @@ export function AdminImagePicker({ ...@@ -210,7 +197,7 @@ export function AdminImagePicker({
)} )}
> >
<div className="relative aspect-[4/3] overflow-hidden bg-[#063e8e]/[0.04]"> <div className="relative aspect-[4/3] overflow-hidden bg-[#063e8e]/[0.04]">
<SafeImage <Image
src={item.url} src={item.url}
alt={item.alt || item.name} alt={item.alt || item.name}
fill fill
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image";
import { Facebook, Linkedin, Menu, Twitter, X, Youtube } from "lucide-react"; import { Facebook, Linkedin, Menu, Twitter, X, Youtube } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Link from "next/link"; import Link from "next/link";
const fallbackLogo = "/logo.png"; const fallbackLogo = "/logo.png";
import { useGetApiV10Logo } from "@/api/vcci-news/endpoints/logo"; import { useGetApiV10Logo } from "@/api/vcci-news/endpoints/logo";
...@@ -336,12 +336,11 @@ function Header() { ...@@ -336,12 +336,11 @@ function Header() {
href="/" href="/"
className="flex w-[136px] shrink-0 items-center xl:w-[152px]" className="flex w-[136px] shrink-0 items-center xl:w-[152px]"
> >
<SafeImage <Image
width={108} width={108}
height={40} height={40}
className="h-auto max-h-10 w-[108px] object-contain" className="h-auto max-h-10 w-[108px] object-contain"
src={currentLogo?.logo_url ? links.resolveImageUrl(currentLogo.logo_url) : fallbackLogo} src={currentLogo?.logo_url ? links.resolveImageUrl(currentLogo.logo_url) : fallbackLogo}
fallbackSrc={fallbackLogo}
alt={currentLogo?.logo_name || "VCCI-HCM"} alt={currentLogo?.logo_name || "VCCI-HCM"}
priority priority
/> />
...@@ -391,12 +390,11 @@ function Header() { ...@@ -391,12 +390,11 @@ function Header() {
className="flex w-[136px] shrink-0 items-center" className="flex w-[136px] shrink-0 items-center"
onClick={() => setToggleMenu(false)} onClick={() => setToggleMenu(false)}
> >
<SafeImage <Image
width={108} width={108}
height={40} height={40}
className="h-auto max-h-10 w-[108px] object-contain" className="h-auto max-h-10 w-[108px] object-contain"
src={currentLogo?.logo_url ? links.resolveImageUrl(currentLogo.logo_url) : fallbackLogo} src={currentLogo?.logo_url ? links.resolveImageUrl(currentLogo.logo_url) : fallbackLogo}
fallbackSrc={fallbackLogo}
alt={currentLogo?.logo_name || "VCCI-HCM"} alt={currentLogo?.logo_name || "VCCI-HCM"}
priority priority
/> />
......
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
const FALLBACK_HREF = "https://vcci-hcm.org.vn"; const FALLBACK_HREF = "https://vcci-hcm.org.vn";
...@@ -13,7 +13,7 @@ export function FallbackSidebarAdItem({ src }: { src: string }) { ...@@ -13,7 +13,7 @@ export function FallbackSidebarAdItem({ src }: { src: string }) {
title="Quảng cáo VCCI HCM" title="Quảng cáo VCCI HCM"
> >
<div className="relative aspect-[16/10]"> <div className="relative aspect-[16/10]">
<SafeImage <Image
src={src} src={src}
alt="Quảng cáo VCCI HCM" alt="Quảng cáo VCCI HCM"
fill fill
......
import { SafeImage } from "@/components/shared/safe-image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import links from "@/links"; import links from "@/links";
import type { Advertisement } from "@/api/vcci-news/models/advertisement"; import type { Advertisement } from "@/api/vcci-news/models/advertisement";
...@@ -15,9 +15,8 @@ export function SidebarAdItem({ item, fallbackSrc }: { item: Advertisement; fall ...@@ -15,9 +15,8 @@ export function SidebarAdItem({ item, fallbackSrc }: { item: Advertisement; fall
title={item.name} title={item.name}
> >
<div className="relative aspect-[16/10]"> <div className="relative aspect-[16/10]">
<SafeImage <Image
src={src} src={src}
fallbackSrc={fallbackSrc}
alt={item.alt || item.name} alt={item.alt || item.name}
fill fill
sizes="(max-width: 768px) 100vw, 300px" 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