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