Commit 051d8a8b authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

fix

parent 080821e5
This diff is collapsed.
This diff is collapsed.
"use client";
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
type MembershipApplicationItem,
persistMembershipApplications,
readMembershipApplications,
} from "@/mockdata/contact-management";
function formatDateTime(value: string) {
return dayjs(value).format("DD/MM/YYYY HH:mm");
}
export default function AdminMembershipApplicationsPage() {
const [items, setItems] = React.useState<MembershipApplicationItem[]>([]);
const [search, setSearch] = React.useState("");
const [ready, setReady] = React.useState(false);
const [detailTarget, setDetailTarget] = React.useState<MembershipApplicationItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MembershipApplicationItem | null>(null);
React.useEffect(() => {
setItems(readMembershipApplications());
setReady(true);
}, []);
const filteredItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter(
(item) =>
item.id.toLowerCase().includes(keyword) ||
item.organizationName.toLowerCase().includes(keyword) ||
item.contactName.toLowerCase().includes(keyword) ||
item.contactEmail.toLowerCase().includes(keyword) ||
item.businessField.toLowerCase().includes(keyword) ||
item.membershipType.toLowerCase().includes(keyword),
);
}, [items, search]);
const handleDelete = () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistMembershipApplications(nextItems);
toast.success("Đã xóa đơn đăng ký hội viên");
setDeleteTarget(null);
};
return (
<div className="space-y-8">
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm đơn đăng ký hội viên..."
actionMeta={
<div className="text-sm font-medium text-gray-700">
Tổng bản ghi: <span className="font-semibold text-[#063e8e]">{filteredItems.length}</span>
</div>
}
onSearchChange={setSearch}
>
<div className="scrollbar overflow-x-auto">
<Table className="min-w-[1080px]">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-16 py-4 text-center text-white">STT</TableHead>
<TableHead className="py-4 text-center text-white">Tên công ty / tổ chức</TableHead>
<TableHead className="w-[210px] py-4 text-center text-white">Người liên hệ</TableHead>
<TableHead className="w-[180px] py-4 text-center text-white">Loại hội viên</TableHead>
<TableHead className="w-[220px] py-4 text-center text-white">Email</TableHead>
<TableHead className="w-[170px] py-4 text-center text-white">Ngày gửi</TableHead>
<TableHead className="w-[130px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!ready ? (
Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell colSpan={7} className="px-4 py-4">
<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 đăng ký hội viên nào
</TableCell>
</TableRow>
) : (
filteredItems.map((item, index) => (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell className="py-3 text-center text-sm text-gray-500">
{index + 1}
</TableCell>
<TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1">
<div className="font-semibold">{item.organizationName}</div>
<div className="text-gray-600">{item.businessField}</div>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1">
<div className="font-medium text-gray-800">{item.contactName}</div>
<div>{item.contactPhone}</div>
</div>
</TableCell>
<TableCell className="py-3 text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.membershipType}
</Badge>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">{item.contactEmail}</TableCell>
<TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</AdminTableLayout>
<ContactManagementDetailDialog
open={!!detailTarget}
title="Chi tiết đơn đăng ký hội viên"
description="Biểu mẫu mẫu phục vụ duyệt giao diện quản trị đơn đăng ký hội viên."
badge={
detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.membershipType}
</Badge>
) : null
}
sections={
detailTarget
? [
{
title: "Thông tin chung",
fields: [
{ label: "Loại hội viên", value: detailTarget.membershipType },
{ label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
],
},
{
title: "Thông tin doanh nghiệp",
fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField },
{ label: "Địa chỉ", value: detailTarget.address, fullWidth: true },
{ label: "Website", value: detailTarget.website },
],
},
{
title: "Thông tin người liên hệ",
fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName },
{ label: "Chức vụ", value: detailTarget.contactPosition },
{ label: "Email người liên hệ", value: detailTarget.contactEmail },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone },
{ label: "Ghi chú", value: detailTarget.note, fullWidth: true },
],
},
]
: []
}
onOpenChange={(open) => !open && setDetailTarget(null)}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa đơn đăng ký hội viên"
description={
<>
Bạn có chắc muốn xóa đơn đăng ký của{" "}
<span className="font-semibold">{deleteTarget?.organizationName}</span>? Hành động này không
thể hoàn tác.
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={handleDelete}
/>
</div>
);
}
"use client";
import * as React from "react";
import dayjs from "dayjs";
import { Eye, Mail, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { ContactManagementDetailDialog } from "@/components/admin/contact-management-detail-dialog";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
type NewsletterSubscriptionItem,
persistNewsletterSubscriptions,
readNewsletterSubscriptions,
} from "@/mockdata/contact-management";
function formatDateTime(value: string) {
return dayjs(value).format("DD/MM/YYYY HH:mm");
}
export default function AdminNewsletterEmailsPage() {
const [items, setItems] = React.useState<NewsletterSubscriptionItem[]>([]);
const [search, setSearch] = React.useState("");
const [ready, setReady] = React.useState(false);
const [detailTarget, setDetailTarget] = React.useState<NewsletterSubscriptionItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<NewsletterSubscriptionItem | null>(null);
React.useEffect(() => {
setItems(readNewsletterSubscriptions());
setReady(true);
}, []);
const filteredItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter(
(item) =>
item.email.toLowerCase().includes(keyword) ||
item.id.toLowerCase().includes(keyword) ||
formatDateTime(item.submittedAt).toLowerCase().includes(keyword),
);
}, [items, search]);
const handleDelete = () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistNewsletterSubscriptions(nextItems);
toast.success("Đã xóa email đăng ký nhận thông tin");
setDeleteTarget(null);
};
return (
<div className="space-y-8">
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm email đăng ký..."
actionMeta={
<div className="text-sm font-medium text-gray-700">
Tổng bản ghi: <span className="font-semibold text-[#063e8e]">{filteredItems.length}</span>
</div>
}
onSearchChange={setSearch}
>
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-16 py-4 text-center text-white">STT</TableHead>
<TableHead className="py-4 text-center text-white">Email</TableHead>
<TableHead className="w-[190px] py-4 text-center text-white">Ngày gửi</TableHead>
<TableHead className="w-[130px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!ready ? (
Array.from({ length: 4 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell colSpan={4} className="px-4 py-4">
<div className="h-10 animate-pulse rounded-xl bg-[#063e8e]/10" />
</TableCell>
</TableRow>
))
) : filteredItems.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-16 text-center text-gray-400">
Không có email đăng ký nào
</TableCell>
</TableRow>
) : (
filteredItems.map((item, index) => (
<TableRow
key={item.id}
className={index % 2 === 0 ? "bg-white" : "bg-[#063e8e]/3"}
>
<TableCell className="py-3 text-center text-sm text-gray-500">
{index + 1}
</TableCell>
<TableCell className="py-3 text-sm font-medium text-gray-800">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#063e8e]/10 text-[#063e8e]">
<Mail className="h-4 w-4" />
</div>
<span>{item.email}</span>
</div>
</TableCell>
<TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)}
</TableCell>
<TableCell className="py-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
onClick={() => setDetailTarget(item)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-50 hover:text-red-600"
onClick={() => setDeleteTarget(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</AdminTableLayout>
<ContactManagementDetailDialog
open={!!detailTarget}
title="Chi tiết email đăng ký nhận thông tin"
description="Thông tin chi tiết của bản ghi email đăng ký nhận tin từ biểu mẫu website."
badge={null}
sections={
detailTarget
? [
{
title: "Thông tin đăng ký",
fields: [
{ label: "Email", value: detailTarget.email },
{ label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
],
},
]
: []
}
onOpenChange={(open) => !open && setDetailTarget(null)}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa email đăng ký"
description={
<>
Bạn có chắc muốn xóa email <span className="font-semibold">{deleteTarget?.email}</span>?
Hành động này không thể hoàn tác.
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={handleDelete}
/>
</div>
);
}
import { redirect } from "next/navigation";
export default function ContactManagementPage() {
redirect("/admin/contact-management/newsletter-emails");
}
This diff is collapsed.
......@@ -254,7 +254,7 @@ export default function HeaderCategoryPostsPage() {
</div>
}
>
<div className="overflow-x-auto">
<div className="scrollbar overflow-x-auto">
<Table className="min-w-[980px] table-fixed">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
......
......@@ -57,10 +57,10 @@ const TYPE_OPTIONS: Array<{ value: HeaderCategoryType; label: string }> = [
];
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
const selectTriggerClassName =
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
......@@ -104,7 +104,7 @@ export function HeaderCategoryFormDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto border-[#063e8e]/15 bg-white text-gray-700 shadow-xl">
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto rounded-3xl border-[#063e8e]/15 bg-white text-gray-700 shadow-xl">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">{title}</DialogTitle>
<DialogDescription className="text-gray-700">
......
......@@ -15,7 +15,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div
className={cn(
'transition-all duration-300',
isOpen ? 'pl-56' : 'pl-20',
isOpen ? 'pl-72' : 'pl-24',
)}
>
<AdminHeader />
......
This diff is collapsed.
......@@ -30,7 +30,7 @@ import {
} from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface FieldFormDialogProps {
open: boolean;
......@@ -57,7 +57,7 @@ function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialo
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogContent className="max-w-md rounded-3xl border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa lĩnh vực" : "Thêm lĩnh vực mới"}
......@@ -163,8 +163,8 @@ export default function AdminMemberFieldsPage() {
actionLabel="Thêm lĩnh vực"
actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionMeta={
<div className="text-sm font-medium text-gray-700">
Tổng lĩnh vực: <span className="font-semibold text-[#063e8e]">{items.length}</span>
<div className="rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]">
Tổng lĩnh vực: {items.length}
</div>
}
onSearchChange={setSearch}
......
"use client";
import * as React from "react";
import { Edit, MoreHorizontal, Plus, Trash2, Users } from "lucide-react";
import { Edit, MoreHorizontal, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
......@@ -43,7 +42,7 @@ import {
} from "@/mockdata/members";
const selectTriggerClassName =
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[200px]";
"w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[200px]";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
......@@ -97,55 +96,38 @@ export default function AdminMembersPage() {
});
}, [items, search, fieldFilter, regionFilter]);
const stats = React.useMemo(
() => [
{
label: "Tổng hội viên",
value: items.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Số lĩnh vực",
value: fields.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />,
},
{
label: "Số khu vực",
value: regions.length,
icon: <Users className="h-4 w-4 text-[#063e8e]" />,
},
],
[items, fields, regions],
);
const fieldMap = React.useMemo(
() => Object.fromEntries(fields.map((f) => [f.id, f.name])),
() => Object.fromEntries(fields.map((field) => [field.id, field.name])),
[fields],
);
const regionMap = React.useMemo(
() => Object.fromEntries(regions.map((r) => [r.id, r.name])),
() => Object.fromEntries(regions.map((region) => [region.id, region.name])),
[regions],
);
const handleDelete = () => {
if (!deleteTarget) return;
const next = items.filter((m) => m.id !== deleteTarget.id);
setItems(next);
persistMembers(next);
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistMembers(nextItems);
toast.success("Đã xóa hội viên");
setDeleteTarget(null);
};
return (
<div className="space-y-8">
<AdminStatsGrid items={stats} />
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm hội viên..."
actionLabel="Thêm hội viên"
actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionMeta={
<div className="rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]">
Tổng số hội viên: {items.length}
</div>
}
onSearchChange={setSearch}
onActionClick={() => router.push("/admin/members/new")}
filters={
......@@ -158,9 +140,9 @@ export default function AdminMembersPage() {
<SelectItem value="all" className={selectItemClassName}>
Tất cả lĩnh vực
</SelectItem>
{fields.map((f) => (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}>
{f.name}
{fields.map((field) => (
<SelectItem key={field.id} value={field.id} className={selectItemClassName}>
{field.name}
</SelectItem>
))}
</SelectContent>
......@@ -174,9 +156,9 @@ export default function AdminMembersPage() {
<SelectItem value="all" className={selectItemClassName}>
Tất cả khu vực
</SelectItem>
{regions.map((r) => (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}>
{r.name}
{regions.map((region) => (
<SelectItem key={region.id} value={region.id} className={selectItemClassName}>
{region.name}
</SelectItem>
))}
</SelectContent>
......@@ -184,7 +166,7 @@ export default function AdminMembersPage() {
</div>
}
>
<div className="overflow-x-auto">
<div className="scrollbar overflow-x-auto">
<Table className="min-w-[900px] table-fixed">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
......@@ -197,6 +179,7 @@ export default function AdminMembersPage() {
<TableHead className="w-[100px] py-4 text-center text-white">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!ready ? (
<MemberTableLoading />
......@@ -225,6 +208,7 @@ export default function AdminMembersPage() {
) : null}
</div>
</TableCell>
<TableCell className="px-4 py-3 text-center">
{item.image ? (
<div className="mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15">
......@@ -242,21 +226,26 @@ export default function AdminMembersPage() {
</div>
)}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{regionMap[item.region_id] ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{fieldMap[item.field_id] ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{item.phone && <div>{item.phone}</div>}
{item.email && (
<div className="truncate text-xs text-[#063e8e]">{item.email}</div>
)}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
<span className="line-clamp-2">{item.address || "—"}</span>
</TableCell>
<TableCell className="px-4 py-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
......
......@@ -30,7 +30,7 @@ import {
} from "@/mockdata/members";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface RegionFormDialogProps {
open: boolean;
......@@ -57,7 +57,7 @@ function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDia
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogContent className="max-w-md rounded-3xl border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa khu vực" : "Thêm khu vực mới"}
......@@ -163,8 +163,8 @@ export default function AdminMemberRegionsPage() {
actionLabel="Thêm khu vực"
actionIcon={<Plus className="mr-2 h-4 w-4" />}
actionMeta={
<div className="text-sm font-medium text-gray-700">
Tổng khu vực: <span className="font-semibold text-[#063e8e]">{items.length}</span>
<div className="rounded-xl border border-[#063e8e]/15 bg-[#f8fbff] px-4 py-2 text-sm font-semibold text-[#163b73]">
Tổng khu vực: {items.length}
</div>
}
onSearchChange={setSearch}
......
......@@ -57,7 +57,7 @@ import {
} from "@/mockdata/header-config";
const selectTriggerClassName =
"w-full border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[180px]";
"w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[180px]";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
......@@ -268,7 +268,7 @@ export default function AdminNewsPage() {
</div>
}
>
<div className="overflow-x-auto">
<div className="scrollbar overflow-x-auto">
<Table className="min-w-[1250px] table-fixed">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
......
import { redirect } from 'next/navigation';
export default function AdminPage() {
redirect('/admin/dashboard');
redirect('/admin/base-config');
}
......@@ -33,7 +33,7 @@ import {
} from "@/mockdata/videos";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface VideoFormDialogProps {
open: boolean;
......@@ -83,7 +83,7 @@ function VideoFormDialog({ open, initial, onOpenChange, onSave }: VideoFormDialo
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg border-[#063e8e]/15 bg-white">
<DialogContent className="max-w-lg rounded-3xl border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa video" : "Thêm video mới"}
......
......@@ -38,7 +38,7 @@ export function AdminTableLayout({
value={searchValue}
placeholder={searchPlaceholder}
onChange={(event) => onSearchChange(event.target.value)}
className="max-w-sm border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
className="max-w-sm rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700"
/>
{filters}
</div>
......
"use client";
import * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface DetailField {
label: string;
value: React.ReactNode;
fullWidth?: boolean;
}
interface DetailSection {
title: string;
fields: DetailField[];
}
interface ContactManagementDetailDialogProps {
open: boolean;
title: string;
description?: string;
badge?: React.ReactNode;
sections: DetailSection[];
onOpenChange: (open: boolean) => void;
}
export function ContactManagementDetailDialog({
open,
title,
description,
badge,
sections,
onOpenChange,
}: ContactManagementDetailDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl rounded-3xl border-[#063e8e]/15 bg-white">
<DialogHeader className="space-y-3">
<div className="flex flex-col gap-3 pr-12 sm:flex-row sm:items-center sm:justify-between sm:pr-14">
<DialogTitle className="text-xl text-[#063e8e]">{title}</DialogTitle>
{badge}
</div>
{description ? (
<DialogDescription className="text-sm leading-6 text-gray-600">
{description}
</DialogDescription>
) : null}
</DialogHeader>
<div className="max-h-[70vh] space-y-6 overflow-y-auto pr-1">
{sections.map((section) => (
<section key={section.title} className="space-y-3">
<div className="border-b border-[#063e8e]/12 pb-2">
<h3 className="text-sm font-semibold uppercase tracking-[0.14em] text-[#063e8e]">
{section.title}
</h3>
</div>
<div className="grid gap-4 md:grid-cols-2">
{section.fields.map((field) => (
<div
key={`${section.title}-${field.label}`}
className={cn(
"rounded-2xl border border-[#063e8e]/12 bg-[#063e8e]/[0.03] p-4",
field.fullWidth && "md:col-span-2",
)}
>
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500">
{field.label}
</div>
<div className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-gray-800">
{field.value || "—"}
</div>
</div>
))}
</div>
</section>
))}
</div>
</DialogContent>
</Dialog>
);
}
......@@ -93,7 +93,7 @@ export function AdminImagePicker({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] max-w-5xl overflow-hidden border-[#063e8e]/15 bg-white p-0">
<DialogContent className="max-h-[88vh] max-w-5xl overflow-hidden rounded-3xl border-[#063e8e]/15 bg-white p-0">
<DialogHeader className="border-b border-[#063e8e]/10 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
......
......@@ -29,6 +29,7 @@ import {
type MemberImageRef,
type MemberItem,
type MemberRegion,
type MemberSocialItem,
EMPTY_MEMBER_FORM,
cloneMemberFormValues,
createMemberId,
......@@ -43,10 +44,10 @@ interface AdminMemberFormProps {
}
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
const selectTriggerClassName =
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
......@@ -98,6 +99,13 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
set("introduction", sections);
};
const handleSocialChange = (socialId: string, value: string) => {
set(
"socials",
form.socials.map((item) => (item.id === socialId ? { ...item, url: value } : item)),
);
};
const handleSave = () => {
if (!form.name.trim()) {
toast.error("Vui lòng nhập tên hội viên");
......@@ -123,6 +131,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
fax: form.fax,
email: form.email,
website: form.website,
socials: form.socials,
introduction: form.introduction,
created_at: now,
updated_at: now,
......@@ -144,6 +153,7 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
fax: form.fax,
email: form.email,
website: form.website,
socials: form.socials,
introduction: form.introduction,
updated_at: now,
} satisfies MemberItem;
......@@ -295,6 +305,29 @@ export function AdminMemberForm({ memberId }: AdminMemberFormProps) {
</div>
</div>
<div className="space-y-3 rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.02] p-4">
<div>
<p className="text-sm font-medium text-gray-700">Mạng xã hội</p>
<p className="mt-1 text-sm text-gray-500">
Nhập link mạng xã hội cho hội viên nếu có.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{form.socials.map((social: MemberSocialItem) => (
<div key={social.id} className="space-y-1.5">
<Label className="text-gray-700">{social.label}</Label>
<Input
value={social.url}
onChange={(e) => handleSocialChange(social.id, e.target.value)}
placeholder={`Nhập link ${social.label}...`}
className={fieldClassName}
/>
</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>
......
......@@ -53,13 +53,13 @@ interface AdminNewsFormProps {
}
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
const readOnlyFieldClassName =
"border-[#063e8e]/10 bg-[#063e8e]/[0.03] text-gray-700 placeholder:text-gray-700";
"rounded-xl border-[#063e8e]/10 bg-[#063e8e]/[0.03] text-gray-700 placeholder:text-gray-700";
const selectTriggerClassName =
"border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
......
......@@ -2,21 +2,28 @@
import React from 'react';
import { usePathname } from 'next/navigation';
import { Menu } from 'lucide-react';
import { Menu, ShieldCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSidebarStore } from '@/hooks/use-admin-sidebar';
const routeLabels: Record<string, string> = {
'/admin/dashboard': 'Dashboard',
'/admin/header-config': 'Cấu hình Danh mục',
'/admin/base-config': 'Cấu hình chung',
'/admin/header-config': 'Cấu hình danh mục',
'/admin/news': 'Quản lý bài viết',
'/admin/media': 'Quản lý ảnh',
'/admin/videos': 'Quản lý video',
'/admin/contact-management': 'Quản lý liên hệ',
'/admin/contact-management/newsletter-emails': 'Quản lý Email đăng ký nhận thông tin',
'/admin/contact-management/contact-requests': 'Quản lý Đơn liên hệ',
'/admin/contact-management/membership-applications': 'Quản lý Đơn đăng ký hội viên',
'/admin/members': 'Quản lý Hội viên',
'/admin/partners': 'Quản lý Đối tác',
'/admin/emails': 'Email nhận thông tin',
'/admin/website-config': 'Thông tin website',
};
const currentUserRoleLabel = 'Quản trị viên';
function getTitle(pathname: string): string {
if (routeLabels[pathname]) return routeLabels[pathname];
......@@ -48,8 +55,9 @@ export function AdminHeader() {
<h1 className="text-xl font-bold text-[#063e8e]">{title}</h1>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
Cập nhật: {new Date().toLocaleDateString('vi-VN')}
<div className="flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-[#f8fbff] px-3 py-1.5 text-sm font-medium text-[#163b73]">
<ShieldCheck className="h-4 w-4 text-[#063e8e]" />
<span>{currentUserRoleLabel}</span>
</div>
</div>
</header>
......
This diff is collapsed.
......@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-3xl",
className
)}
{...props}
......
......@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-3xl",
className
)}
{...props}
......
......@@ -440,7 +440,7 @@ export function readAdminMediaItems() {
try {
const parsed = JSON.parse(raw) as AdminMediaItem[];
if (!Array.isArray(parsed) || parsed.length === 0) {
if (!Array.isArray(parsed)) {
return getAdminMediaSeed();
}
......
"use client";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { readAdminMediaItems } from "@/mockdata/admin-news";
export const BASE_CONFIG_STORAGE_KEY = "vcci-news.admin-base-config.data.v1";
export interface BaseConfigLogoItem {
id: string;
name: string;
imageId: string;
isActive: boolean;
}
export interface BaseConfigBannerItem {
id: string;
name: string;
imageId: string;
isActive: boolean;
displayTimeSeconds: number;
sortOrder: number;
}
export interface BaseConfigBranchItem {
id: string;
branchName: string;
address: string;
hotline: string;
email: string;
fax: string;
mapsEmbedUrl: string;
}
export interface BaseConfigSocialItem {
id: string;
label: string;
url: string;
isVisible: boolean;
sortOrder: number;
}
export interface BaseConfigData {
logo: BaseConfigLogoItem | null;
banners: BaseConfigBannerItem[];
websiteName: string;
websiteLink: string;
socials: BaseConfigSocialItem[];
branches: BaseConfigBranchItem[];
}
export const EMPTY_BASE_CONFIG_BRANCH: BaseConfigBranchItem = {
id: "",
branchName: "",
address: "",
hotline: "",
email: "",
fax: "",
mapsEmbedUrl: "",
};
export const BASE_CONFIG_SOCIAL_SEED: BaseConfigSocialItem[] = [
{ id: "facebook", label: "Facebook", url: "", isVisible: true, sortOrder: 1 },
{ id: "zalo", label: "Zalo", url: "", isVisible: true, sortOrder: 2 },
{ id: "twitter", label: "Twitter", url: "", isVisible: false, sortOrder: 3 },
{ id: "youtube", label: "Youtube", url: "", isVisible: true, sortOrder: 4 },
{ id: "linkedin", label: "Linkedin", url: "", isVisible: false, sortOrder: 5 },
];
const BASE_CONFIG_SEED: BaseConfigData = {
logo: {
id: "base-logo-001",
name: "Logo chính VCCI News",
imageId: "media-thumbnail",
isActive: true,
},
websiteName: "VCCI News",
websiteLink: "https://vccinews.vn",
socials: [
{
id: "facebook",
label: "Facebook",
url: "https://facebook.com/vccinews",
isVisible: true,
sortOrder: 1,
},
{
id: "zalo",
label: "Zalo",
url: "https://zalo.me/vccinews",
isVisible: true,
sortOrder: 2,
},
{
id: "twitter",
label: "Twitter",
url: "",
isVisible: false,
sortOrder: 3,
},
{
id: "youtube",
label: "Youtube",
url: "https://youtube.com/@vccinews",
isVisible: true,
sortOrder: 4,
},
{
id: "linkedin",
label: "Linkedin",
url: "",
isVisible: false,
sortOrder: 5,
},
],
banners: [
{
id: "base-banner-001",
name: "Banner trang chủ 01",
imageId: "media-banner",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
},
{
id: "base-banner-002",
name: "Banner hoạt động hội viên",
imageId: "media-home-01",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 2,
},
{
id: "base-banner-003",
name: "Banner sự kiện nổi bật",
imageId: "media-home-02",
isActive: false,
displayTimeSeconds: 5,
sortOrder: 3,
},
],
branches: [
{
id: "base-branch-001",
branchName: "Trụ sở chính VCCI News",
address: "171 Võ Thị Sáu, Phường Võ Thị Sáu, Quận 3, TP.HCM",
hotline: "028 3932 6598",
email: "info@vccinews.vn",
fax: "028 3932 5789",
mapsEmbedUrl: "https://maps.google.com/?q=171+Vo+Thi+Sau+Quan+3+TPHCM",
},
{
id: "base-branch-002",
branchName: "Chi nhánh Hà Nội",
address: "9 Đào Duy Anh, Phường Phương Mai, Quận Đống Đa, Hà Nội",
hotline: "024 3577 0632",
email: "hanoi@vccinews.vn",
fax: "024 3574 2020",
mapsEmbedUrl: "https://maps.google.com/?q=9+Dao+Duy+Anh+Dong+Da+Ha+Noi",
},
],
};
export function createBaseConfigItemId(prefix: "logo" | "banner" | "branch") {
return `base-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function cloneBaseConfigData(data: BaseConfigData): BaseConfigData {
return {
logo: data.logo ? { ...data.logo } : null,
banners: data.banners.map((item) => ({ ...item })),
websiteName: data.websiteName,
websiteLink: data.websiteLink,
socials: data.socials.map((item) => ({ ...item })),
branches: data.branches.map((item) => ({ ...item })),
};
}
export function readBaseConfig(): BaseConfigData {
if (typeof window === "undefined") return cloneBaseConfigData(BASE_CONFIG_SEED);
const raw = window.localStorage.getItem(BASE_CONFIG_STORAGE_KEY);
if (!raw) return cloneBaseConfigData(BASE_CONFIG_SEED);
try {
const parsed = JSON.parse(raw) as BaseConfigData & {
logos?: BaseConfigLogoItem[];
contactInfo?: Record<string, string>;
};
if (!parsed || typeof parsed !== "object") {
return cloneBaseConfigData(BASE_CONFIG_SEED);
}
const fallbackBranchFromLegacyContact =
parsed.contactInfo && typeof parsed.contactInfo === "object"
? [
{
id: createBaseConfigItemId("branch"),
branchName: parsed.contactInfo.officeName || "Chi nhánh mặc định",
address: parsed.contactInfo.address || "",
hotline: parsed.contactInfo.hotline || "",
email: parsed.contactInfo.email || "",
fax: parsed.contactInfo.fax || "",
mapsEmbedUrl: parsed.contactInfo.mapsEmbedUrl || "",
},
]
: BASE_CONFIG_SEED.branches;
return {
logo:
parsed.logo && typeof parsed.logo === "object"
? { ...parsed.logo }
: Array.isArray(parsed.logos) && parsed.logos[0]
? { ...parsed.logos[0] }
: BASE_CONFIG_SEED.logo
? { ...BASE_CONFIG_SEED.logo }
: null,
banners: Array.isArray(parsed.banners)
? parsed.banners.map((item, index) => ({
...item,
sortOrder:
typeof (item as BaseConfigBannerItem & { sortOrder?: number }).sortOrder === "number"
? (item as BaseConfigBannerItem & { sortOrder?: number }).sortOrder ?? index + 1
: index + 1,
}))
: [],
websiteName:
typeof (parsed as BaseConfigData & { websiteName?: string }).websiteName === "string"
? (parsed as BaseConfigData & { websiteName?: string }).websiteName ?? ""
: BASE_CONFIG_SEED.websiteName,
websiteLink:
typeof (parsed as BaseConfigData & { websiteLink?: string }).websiteLink === "string"
? (parsed as BaseConfigData & { websiteLink?: string }).websiteLink ?? ""
: BASE_CONFIG_SEED.websiteLink,
socials: Array.isArray((parsed as BaseConfigData & { socials?: BaseConfigSocialItem[] }).socials)
? BASE_CONFIG_SOCIAL_SEED.map((seedItem, index) => {
const matchedItem = (
(parsed as BaseConfigData & { socials?: BaseConfigSocialItem[] }).socials ?? []
).find((item) => item?.id === seedItem.id);
return {
...seedItem,
...matchedItem,
url: typeof matchedItem?.url === "string" ? matchedItem.url : seedItem.url,
isVisible:
typeof matchedItem?.isVisible === "boolean"
? matchedItem.isVisible
: seedItem.isVisible,
sortOrder:
typeof matchedItem?.sortOrder === "number"
? matchedItem.sortOrder
: index + 1,
};
})
: BASE_CONFIG_SOCIAL_SEED.map((item) => ({ ...item })),
branches: Array.isArray(parsed.branches)
? parsed.branches.map((item) => ({
...EMPTY_BASE_CONFIG_BRANCH,
...item,
fax:
typeof (item as BaseConfigBranchItem & { fax?: string }).fax === "string"
? (item as BaseConfigBranchItem & { fax?: string }).fax ?? ""
: "",
}))
: fallbackBranchFromLegacyContact,
};
} catch {
return cloneBaseConfigData(BASE_CONFIG_SEED);
}
}
export function persistBaseConfig(data: BaseConfigData): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(BASE_CONFIG_STORAGE_KEY, JSON.stringify(data));
}
export function getMediaMap(items?: AdminMediaItem[]) {
const mediaItems = items ?? readAdminMediaItems();
return new Map(mediaItems.map((item) => [item.id, item]));
}
export function sortBaseConfigBanners(items: BaseConfigBannerItem[]) {
return [...items].sort((first, second) => {
if (first.sortOrder !== second.sortOrder) return first.sortOrder - second.sortOrder;
return first.name.localeCompare(second.name, "vi");
});
}
export function sortBaseConfigSocials(items: BaseConfigSocialItem[]) {
return [...items].sort((first, second) => {
if (first.sortOrder !== second.sortOrder) return first.sortOrder - second.sortOrder;
return first.label.localeCompare(second.label, "vi");
});
}
"use client";
export const NEWSLETTER_SUBSCRIPTIONS_STORAGE_KEY =
"vcci-news.admin-contact-management.newsletter-subscriptions.v1";
export const CONTACT_REQUESTS_STORAGE_KEY =
"vcci-news.admin-contact-management.contact-requests.v1";
export const MEMBERSHIP_APPLICATIONS_STORAGE_KEY =
"vcci-news.admin-contact-management.membership-applications.v1";
export const CONTACT_PURPOSE_OPTIONS = [
"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",
] as const;
export type ContactPurpose = (typeof CONTACT_PURPOSE_OPTIONS)[number];
export interface NewsletterSubscriptionItem {
id: string;
email: string;
submittedAt: string;
}
export interface ContactRequestItem {
id: string;
purpose: ContactPurpose;
contactName: string;
contactPosition: string;
contactEmail: string;
contactPhone: string;
message: string;
organizationName: string;
businessField: string;
email: string;
website: string;
submittedAt: string;
}
export interface MembershipApplicationItem {
id: string;
organizationName: string;
membershipType: string;
contactName: string;
contactPosition: string;
contactEmail: string;
contactPhone: string;
address: string;
businessField: string;
website: string;
note: string;
submittedAt: string;
}
const NEWSLETTER_SUBSCRIPTION_SEED: NewsletterSubscriptionItem[] = [
{
id: "newsletter-001",
email: "banthuongtruc@saigreen.vn",
submittedAt: "2026-05-10T08:15:00+07:00",
},
{
id: "newsletter-002",
email: "marketing@thienphuoclogistics.vn",
submittedAt: "2026-05-10T14:45:00+07:00",
},
{
id: "newsletter-003",
email: "ceo@mekongfoods.com.vn",
submittedAt: "2026-05-11T09:22:00+07:00",
},
{
id: "newsletter-004",
email: "office@vinalinktech.vn",
submittedAt: "2026-05-11T16:05:00+07:00",
},
];
const CONTACT_REQUEST_SEED: ContactRequestItem[] = [
{
id: "contact-001",
purpose: "Hội viên VCCI",
contactName: "Nguyễn Thị Hồng Nhung",
contactPosition: "Trưởng phòng đối ngoại",
contactEmail: "nhung.nguyen@daianholdings.vn",
contactPhone: "0903456781",
message:
"Chúng tôi cần được tư vấn về điều kiện tham gia mạng lưới hội viên và quy trình cập nhật hồ sơ doanh nghiệp trên cổng thông tin.",
organizationName: "Công ty Cổ phần Đại An Holdings",
businessField: "Đầu tư thương mại và dịch vụ",
email: "contact@daianholdings.vn",
website: "https://daianholdings.vn",
submittedAt: "2026-05-10T10:18:00+07:00",
},
{
id: "contact-002",
purpose: "Xuất xứ hàng hóa C/O",
contactName: "Trần Quốc Bảo",
contactPosition: "Chuyên viên xuất nhập khẩu",
contactEmail: "bao.tran@saigonexport.vn",
contactPhone: "0911223344",
message:
"Doanh nghiệp cần hướng dẫn hồ sơ xin cấp C/O cho lô hàng xuất khẩu sang thị trường EU trong tháng này.",
organizationName: "Công ty TNHH Saigon Export Hub",
businessField: "Xuất nhập khẩu hàng tiêu dùng",
email: "info@saigonexport.vn",
website: "https://saigonexport.vn",
submittedAt: "2026-05-10T15:42:00+07:00",
},
{
id: "contact-003",
purpose: "Xúc tiến thương mại",
contactName: "Phạm Minh Quân",
contactPosition: "Giám đốc kinh doanh",
contactEmail: "quan.pham@newhorizon.vn",
contactPhone: "0988112233",
message:
"Đề nghị kết nối doanh nghiệp với các chương trình xúc tiến thương mại tại Nhật Bản và Hàn Quốc trong quý III.",
organizationName: "New Horizon Manufacturing",
businessField: "Sản xuất công nghiệp hỗ trợ",
email: "sales@newhorizon.vn",
website: "https://newhorizon.vn",
submittedAt: "2026-05-11T08:35:00+07:00",
},
{
id: "contact-004",
purpose: "Quảng cáo",
contactName: "Lê Diễm My",
contactPosition: "Marketing Manager",
contactEmail: "my.le@bluepeakmedia.vn",
contactPhone: "0933778899",
message:
"Chúng tôi muốn tìm hiểu gói quảng cáo banner và bài PR trên chuyên trang VCCI News trong tháng 6.",
organizationName: "Blue Peak Media",
businessField: "Truyền thông và quảng cáo",
email: "hello@bluepeakmedia.vn",
website: "https://bluepeakmedia.vn",
submittedAt: "2026-05-11T11:10:00+07:00",
},
{
id: "contact-005",
purpose: "Mục đích khác",
contactName: "Đỗ Thanh Bình",
contactPosition: "Phó tổng giám đốc",
contactEmail: "binh.do@greenriver.org.vn",
contactPhone: "0977554433",
message:
"Mong muốn làm việc với ban biên tập để chia sẻ thông tin về chương trình đào tạo ESG dành cho doanh nghiệp hội viên.",
organizationName: "Green River Advisory",
businessField: "Tư vấn phát triển bền vững",
email: "office@greenriver.org.vn",
website: "https://greenriver.org.vn",
submittedAt: "2026-05-11T13:50:00+07:00",
},
];
const MEMBERSHIP_APPLICATION_SEED: MembershipApplicationItem[] = [
{
id: "member-app-001",
organizationName: "Công ty Cổ phần Công nghệ Vạn Phúc",
membershipType: "Hội viên chính thức",
contactName: "Ngô Hoàng Long",
contactPosition: "Giám đốc điều hành",
contactEmail: "long.ngo@vanphuctech.vn",
contactPhone: "0909988776",
address: "25 Nguyễn Thị Minh Khai, Quận 1, TP.HCM",
businessField: "Công nghệ thông tin và chuyển đổi số",
website: "https://vanphuctech.vn",
note:
"Doanh nghiệp mong muốn tham gia để kết nối đối tác và nhận thông tin các chương trình xúc tiến thương mại.",
submittedAt: "2026-05-09T16:30:00+07:00",
},
{
id: "member-app-002",
organizationName: "Công ty TNHH Thực phẩm An Khang",
membershipType: "Hội viên liên kết",
contactName: "Đặng Khánh Linh",
contactPosition: "Trưởng phòng hành chính",
contactEmail: "linh.dang@ankhangfoods.vn",
contactPhone: "0912345670",
address: "18 Võ Văn Kiệt, Quận Ninh Kiều, Cần Thơ",
businessField: "Sản xuất và phân phối thực phẩm",
website: "https://ankhangfoods.vn",
note:
"Cần hỗ trợ tìm hiểu quyền lợi hội viên và các đầu mối phụ trách khu vực miền Tây.",
submittedAt: "2026-05-10T09:05:00+07:00",
},
{
id: "member-app-003",
organizationName: "Công ty Cổ phần Logistics Ánh Dương",
membershipType: "Hội viên chính thức",
contactName: "Phan Gia Huy",
contactPosition: "Giám đốc phát triển thị trường",
contactEmail: "huy.phan@anhduonglogistics.vn",
contactPhone: "0987445566",
address: "99 Điện Biên Phủ, Quận Hải Châu, Đà Nẵng",
businessField: "Logistics và kho vận",
website: "https://anhduonglogistics.vn",
note:
"Quan tâm đến các chương trình kết nối doanh nghiệp và hoạt động cộng đồng của VCCI.",
submittedAt: "2026-05-11T10:25:00+07:00",
},
];
function readStorage<T>(storageKey: string, seed: T[]): T[] {
if (typeof window === "undefined") return seed;
const raw = window.localStorage.getItem(storageKey);
if (!raw) return seed;
try {
const parsed = JSON.parse(raw) as T[];
return Array.isArray(parsed) ? parsed : seed;
} catch {
return seed;
}
}
function persistStorage<T>(storageKey: string, items: T[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(storageKey, JSON.stringify(items));
}
export function readNewsletterSubscriptions(): NewsletterSubscriptionItem[] {
return readStorage(NEWSLETTER_SUBSCRIPTIONS_STORAGE_KEY, NEWSLETTER_SUBSCRIPTION_SEED);
}
export function persistNewsletterSubscriptions(items: NewsletterSubscriptionItem[]): void {
persistStorage(NEWSLETTER_SUBSCRIPTIONS_STORAGE_KEY, items);
}
export function readContactRequests(): ContactRequestItem[] {
return readStorage(CONTACT_REQUESTS_STORAGE_KEY, CONTACT_REQUEST_SEED);
}
export function persistContactRequests(items: ContactRequestItem[]): void {
persistStorage(CONTACT_REQUESTS_STORAGE_KEY, items);
}
export function readMembershipApplications(): MembershipApplicationItem[] {
return readStorage(MEMBERSHIP_APPLICATIONS_STORAGE_KEY, MEMBERSHIP_APPLICATION_SEED);
}
export function persistMembershipApplications(items: MembershipApplicationItem[]): void {
persistStorage(MEMBERSHIP_APPLICATIONS_STORAGE_KEY, items);
}
......@@ -29,6 +29,12 @@ export interface MemberImageRef {
url: string;
}
export interface MemberSocialItem {
id: string;
label: string;
url: string;
}
import type { AdminNewsContentSection } from "@/mockdata/admin-news";
export interface MemberItem {
......@@ -43,6 +49,7 @@ export interface MemberItem {
fax: string;
email: string;
website: string;
socials: MemberSocialItem[];
introduction: AdminNewsContentSection[];
created_at: string;
updated_at: string;
......@@ -60,6 +67,7 @@ export interface MemberFormValues {
fax: string;
email: string;
website: string;
socials: MemberSocialItem[];
introduction: AdminNewsContentSection[];
}
......@@ -99,6 +107,14 @@ const SEED_REGIONS: MemberRegion[] = [
{ id: "region-5", name: "Bình Dương" },
];
const MEMBER_SOCIAL_SEED: MemberSocialItem[] = [
{ id: "facebook", label: "Facebook", url: "" },
{ id: "zalo", label: "Zalo", url: "" },
{ id: "twitter", label: "Twitter", url: "" },
{ id: "youtube", label: "Youtube", url: "" },
{ id: "linkedin", label: "Linkedin", url: "" },
];
const SEED_MEMBERS: MemberItem[] = [
{
id: "member-1",
......@@ -112,12 +128,35 @@ const SEED_MEMBERS: MemberItem[] = [
fax: "028 1234 5679",
email: "contact@abc.vn",
website: "https://abc.vn",
socials: [
{ id: "facebook", label: "Facebook", url: "https://facebook.com/abc" },
{ id: "zalo", label: "Zalo", url: "https://zalo.me/abc" },
{ id: "twitter", label: "Twitter", url: "" },
{ id: "youtube", label: "Youtube", url: "" },
{ id: "linkedin", label: "Linkedin", url: "https://linkedin.com/company/abc" },
],
introduction: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
export function getMemberSocialSeed(): MemberSocialItem[] {
return MEMBER_SOCIAL_SEED.map((item) => ({ ...item }));
}
function normalizeMemberSocials(socials?: MemberSocialItem[]): MemberSocialItem[] {
return MEMBER_SOCIAL_SEED.map((seedItem) => {
const matchedItem = socials?.find((item) => item?.id === seedItem.id);
return {
...seedItem,
...matchedItem,
url: typeof matchedItem?.url === "string" ? matchedItem.url : seedItem.url,
};
});
}
// ---------------------------------------------------------------------------
// Field helpers
// ---------------------------------------------------------------------------
......@@ -182,7 +221,12 @@ export function readMembers(): MemberItem[] {
if (!raw) return getMemberSeed();
try {
const parsed = JSON.parse(raw) as MemberItem[];
return Array.isArray(parsed) && parsed.length > 0 ? parsed : getMemberSeed();
return Array.isArray(parsed) && parsed.length > 0
? parsed.map((item) => ({
...item,
socials: normalizeMemberSocials(item.socials),
}))
: getMemberSeed();
} catch {
return getMemberSeed();
}
......@@ -206,6 +250,7 @@ export function cloneMemberFormValues(item: MemberItem): MemberFormValues {
fax: item.fax,
email: item.email,
website: item.website,
socials: normalizeMemberSocials(item.socials),
introduction: item.introduction.map((section) => ({
...section,
images: section.images.map((img) => ({ ...img })),
......@@ -224,5 +269,6 @@ export const EMPTY_MEMBER_FORM: MemberFormValues = {
fax: "",
email: "",
website: "",
socials: getMemberSocialSeed(),
introduction: [],
};
......@@ -15,7 +15,9 @@
/* Scrollbar */
.scrollbar {
@apply [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-400/60 hover:[&::-webkit-scrollbar-thumb]:bg-neutral-400 [&::-webkit-scrollbar-track]:bg-transparent;
scrollbar-color: rgba(6, 62, 142, 0.38) rgba(219, 232, 255, 0.38);
scrollbar-width: thin;
@apply [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-corner]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-[2px] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-[#063e8e]/35 [&::-webkit-scrollbar-thumb]:bg-clip-padding hover:[&::-webkit-scrollbar-thumb]:bg-[#063e8e]/50 [&::-webkit-scrollbar-track]:rounded-full [&::-webkit-scrollbar-track]:bg-[#dbe8ff]/55;
}
.list-screen-filter-container {
......
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