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

Merge branch 'feat/duck' into 'develop-news'

fix

See merge request !52
parents daa7768f 051d8a8b
"use client";
import * as React from "react";
import {
ChevronLeft,
ChevronRight,
Edit,
Globe,
ImagePlus,
Mail,
MapPin,
Phone,
Plus,
Save,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { readAdminMediaItems } from "@/mockdata/admin-news";
import {
type BaseConfigBannerItem,
type BaseConfigBranchItem,
type BaseConfigData,
type BaseConfigLogoItem,
type BaseConfigSocialItem,
EMPTY_BASE_CONFIG_BRANCH,
cloneBaseConfigData,
createBaseConfigItemId,
getMediaMap,
persistBaseConfig,
readBaseConfig,
sortBaseConfigBanners,
sortBaseConfigSocials,
} from "@/mockdata/base-config";
const fieldClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
type ConfigItemMode = "logo" | "banner";
type ConfigItemForm = {
name: string;
imageId: string;
isActive: boolean;
displayTimeSeconds: number;
sortOrder: number;
};
function emptyItemForm(): ConfigItemForm {
return {
name: "",
imageId: "",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
};
}
function resolveMediaItem(mediaMap: Map<string, AdminMediaItem>, imageId: string) {
return mediaMap.get(imageId) ?? null;
}
function ConfigItemPreview({
title,
item,
media,
current,
onSelect,
}: {
title: string;
item: BaseConfigBannerItem;
media: AdminMediaItem | null;
current: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={`overflow-hidden rounded-3xl border text-left transition-all ${
current
? "border-[#063e8e]/35 bg-[#edf4ff] shadow-[0_10px_24px_rgba(6,62,142,0.12)]"
: "border-[#063e8e]/10 bg-white hover:border-[#063e8e]/25 hover:shadow-sm"
}`}
>
<div className="relative aspect-[16/10] overflow-hidden bg-[#eef4ff]">
{media ? (
<SafeNextImage src={media.url} alt={media.alt || media.name} fill className="object-cover" />
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Chưa chọn hình ảnh
</div>
)}
</div>
<div className="space-y-2 px-4 py-3">
<div className="line-clamp-1 text-sm font-semibold text-[#163b73]">{title}</div>
<div className="line-clamp-2 text-sm text-gray-600">{item.name}</div>
</div>
</button>
);
}
function ConfigItemDialog({
open,
mode,
form,
previewMedia,
saving,
title,
description,
onOpenChange,
onChange,
onPickImage,
onSubmit,
}: {
open: boolean;
mode: ConfigItemMode;
form: ConfigItemForm;
previewMedia: AdminMediaItem | null;
saving: boolean;
title: string;
description: string;
onOpenChange: (open: boolean) => void;
onChange: <K extends keyof ConfigItemForm>(key: K, value: ConfigItemForm[K]) => void;
onPickImage: () => void;
onSubmit: () => void;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] max-w-xl flex-col overflow-hidden rounded-3xl border-[#063e8e]/15 bg-white p-0">
<DialogHeader>
<div className="border-b border-[#063e8e]/10 px-6 py-5">
<DialogTitle className="text-xl text-[#063e8e]">{title}</DialogTitle>
<DialogDescription className="mt-2 text-sm text-gray-600">{description}</DialogDescription>
</div>
</DialogHeader>
<div className="scrollbar min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-gray-700">Tên hiển thị</Label>
<Input
value={form.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder={mode === "logo" ? "Nhập tên logo..." : "Nhập tên banner..."}
className={fieldClassName}
/>
</div>
{mode === "banner" ? (
<div className="space-y-2">
<Label className="text-gray-700">Thời gian hiển thị (giây)</Label>
<Input
type="number"
min={1}
max={60}
value={form.displayTimeSeconds}
onChange={(event) =>
onChange("displayTimeSeconds", Number(event.target.value || 1))
}
className={fieldClassName}
/>
</div>
) : null}
{mode === "banner" ? (
<div className="space-y-2">
<Label className="text-gray-700">Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={form.sortOrder}
onChange={(event) => onChange("sortOrder", Number(event.target.value || 1))}
className={fieldClassName}
/>
</div>
) : null}
<div className="space-y-3">
<Label className="text-gray-700">Hình ảnh</Label>
<div className="overflow-hidden rounded-3xl border border-dashed border-[#063e8e]/20 bg-[#eef4ff]/60">
<div className="relative aspect-[16/9]">
{previewMedia ? (
<SafeNextImage
src={previewMedia.url}
alt={previewMedia.alt || previewMedia.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Chưa chọn hình ảnh
</div>
)}
</div>
</div>
<Button
type="button"
variant="outline"
onClick={onPickImage}
className="rounded-xl border-[#063e8e]/15 text-gray-700 hover:bg-[#edf4ff]"
>
<ImagePlus className="mr-2 h-4 w-4" />
Chọn từ thư viện
</Button>
</div>
{mode === "banner" ? (
<div className="flex items-center justify-between rounded-2xl border border-[#063e8e]/10 bg-[#f7faff] px-4 py-3">
<div>
<div className="text-sm font-medium text-[#163b73]">Trạng thái hiển thị</div>
<div className="text-xs text-gray-500">
{form.isActive ? "Đang bật hiển thị" : "Đang tắt hiển thị"}
</div>
</div>
<Switch checked={form.isActive} onCheckedChange={(value) => onChange("isActive", value)} />
</div>
) : null}
</div>
</div>
<DialogFooter className="border-t border-[#063e8e]/10 px-6 py-4">
<div className="flex w-full justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-xl border-[#063e8e]/15 text-gray-700"
>
Hủy
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={saving}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Save className="mr-2 h-4 w-4" />
{saving ? "Đang lưu..." : "Lưu cấu hình"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BranchCard({
branch,
current,
onSelect,
onDelete,
}: {
branch: BaseConfigBranchItem;
current: boolean;
onSelect: () => void;
onDelete: () => void;
}) {
return (
<div
className={`rounded-3xl border p-4 transition-all ${
current
? "border-[#063e8e]/30 bg-[#eef5ff] shadow-[0_10px_24px_rgba(6,62,142,0.1)]"
: "border-[#063e8e]/10 bg-white"
}`}
>
<button type="button" onClick={onSelect} className="w-full text-left">
<div className="text-sm font-semibold text-[#163b73]">{branch.branchName || "Chi nhánh mới"}</div>
<div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-600">
{branch.address || "Chưa cập nhật địa chỉ"}
</div>
</button>
<div className="mt-4 flex items-center justify-between">
<div className="text-xs text-slate-500">{branch.hotline || "Chưa có hotline"}</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onDelete}
className="h-8 w-8 text-red-600 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}
export default function AdminBaseConfigPage() {
const [config, setConfig] = React.useState<BaseConfigData | null>(null);
const [mediaItems, setMediaItems] = React.useState<AdminMediaItem[]>([]);
const [currentBannerIndex, setCurrentBannerIndex] = React.useState(0);
const [currentBranchIndex, setCurrentBranchIndex] = React.useState(0);
const [activeTab, setActiveTab] = React.useState("branding");
const [itemDialogOpen, setItemDialogOpen] = React.useState(false);
const [itemDialogMode, setItemDialogMode] = React.useState<ConfigItemMode>("logo");
const [editingItemId, setEditingItemId] = React.useState<string | null>(null);
const [itemForm, setItemForm] = React.useState<ConfigItemForm>(emptyItemForm());
const [imagePickerOpen, setImagePickerOpen] = React.useState(false);
const [savingItem, setSavingItem] = React.useState(false);
const [savingContact, setSavingContact] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{
mode: ConfigItemMode;
id: string;
name: string;
} | null>(null);
React.useEffect(() => {
setConfig(readBaseConfig());
setMediaItems(readAdminMediaItems());
}, []);
const mediaMap = React.useMemo(() => getMediaMap(mediaItems), [mediaItems]);
const sortedBanners = React.useMemo(
() => (config ? sortBaseConfigBanners(config.banners) : []),
[config],
);
const sortedSocials = React.useMemo(
() => (config ? sortBaseConfigSocials(config.socials) : []),
[config],
);
const currentLogo = config?.logo ?? null;
const currentBanner = sortedBanners[currentBannerIndex] ?? null;
const currentBranch = config?.branches[currentBranchIndex] ?? null;
const currentLogoMedia = currentLogo ? resolveMediaItem(mediaMap, currentLogo.imageId) : null;
const currentBannerMedia = currentBanner
? resolveMediaItem(mediaMap, currentBanner.imageId)
: null;
const previewMedia = resolveMediaItem(mediaMap, itemForm.imageId);
const saveConfig = React.useCallback((nextConfig: BaseConfigData) => {
setConfig(nextConfig);
persistBaseConfig(nextConfig);
}, []);
const openCreateDialog = (mode: ConfigItemMode) => {
setItemDialogMode(mode);
setEditingItemId(null);
setItemForm({
...emptyItemForm(),
sortOrder: mode === "banner" ? (config ? config.banners.length + 1 : 1) : 1,
});
setItemDialogOpen(true);
};
const openEditDialog = (mode: ConfigItemMode, item: BaseConfigLogoItem | BaseConfigBannerItem) => {
setItemDialogMode(mode);
setEditingItemId(item.id);
setItemForm({
name: item.name,
imageId: item.imageId,
isActive: item.isActive,
displayTimeSeconds: "displayTimeSeconds" in item ? item.displayTimeSeconds : 5,
sortOrder: "sortOrder" in item ? item.sortOrder : 1,
});
setItemDialogOpen(true);
};
const handleSubmitItem = () => {
if (!config) return;
const trimmedName = itemForm.name.trim();
if (!trimmedName) {
toast.error("Vui lòng nhập tên hiển thị");
return;
}
if (!itemForm.imageId) {
toast.error("Vui lòng chọn hình ảnh");
return;
}
setSavingItem(true);
const nextConfig = cloneBaseConfigData(config);
if (itemDialogMode === "logo") {
nextConfig.logo = {
id: editingItemId || currentLogo?.id || createBaseConfigItemId("logo"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: true,
};
} else {
if (editingItemId) {
nextConfig.banners = nextConfig.banners.map((item) =>
item.id === editingItemId
? {
...item,
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
}
: item,
);
} else {
nextConfig.banners.push({
id: createBaseConfigItemId("banner"),
name: trimmedName,
imageId: itemForm.imageId,
isActive: itemForm.isActive,
displayTimeSeconds: itemForm.displayTimeSeconds,
sortOrder: itemForm.sortOrder,
});
setCurrentBannerIndex(Math.max(nextConfig.banners.length - 1, 0));
}
}
saveConfig(nextConfig);
setSavingItem(false);
setItemDialogOpen(false);
toast.success(itemDialogMode === "logo" ? "Đã lưu cấu hình logo" : "Đã lưu cấu hình banner");
};
const handleDeleteItem = () => {
if (!config || !deleteTarget) return;
const nextConfig = cloneBaseConfigData(config);
if (deleteTarget.mode === "logo") {
nextConfig.logo = null;
} else {
nextConfig.banners = nextConfig.banners.filter((item) => item.id !== deleteTarget.id);
setCurrentBannerIndex((previous) =>
Math.max(0, Math.min(previous, nextConfig.banners.length - 1)),
);
}
saveConfig(nextConfig);
toast.success("Đã xóa cấu hình");
setDeleteTarget(null);
};
const handleBranchChange = <K extends keyof BaseConfigBranchItem>(
key: K,
value: BaseConfigBranchItem[K],
) => {
if (!config || !currentBranch) return;
setConfig((previous) =>
previous
? {
...previous,
branches: previous.branches.map((branch, index) =>
index === currentBranchIndex ? { ...branch, [key]: value } : branch,
),
}
: previous,
);
};
const handleAddBranch = () => {
if (!config) return;
const nextBranch: BaseConfigBranchItem = {
...EMPTY_BASE_CONFIG_BRANCH,
id: createBaseConfigItemId("branch"),
branchName: `Chi nhánh ${config.branches.length + 1}`,
};
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches.push(nextBranch);
saveConfig(nextConfig);
setCurrentBranchIndex(nextConfig.branches.length - 1);
toast.success("Đã thêm chi nhánh mới");
};
const handleDeleteBranch = (branchId: string) => {
if (!config) return;
const nextConfig = cloneBaseConfigData(config);
nextConfig.branches = nextConfig.branches.filter((branch) => branch.id !== branchId);
saveConfig(nextConfig);
setCurrentBranchIndex((previous) =>
Math.max(0, Math.min(previous, Math.max(nextConfig.branches.length - 1, 0))),
);
toast.success("Đã xóa chi nhánh");
};
const handleSaveBranches = () => {
if (!config) return;
setSavingContact(true);
persistBaseConfig(config);
setSavingContact(false);
toast.success("Đã lưu danh sách chi nhánh liên hệ");
};
const handleWebsiteInfoChange = (key: "websiteName" | "websiteLink", value: string) => {
setConfig((previous) => (previous ? { ...previous, [key]: value } : previous));
};
const handleSaveWebsiteInfo = () => {
if (!config) return;
saveConfig(config);
toast.success("Đã lưu thông tin website");
};
const handleSocialChange = <K extends keyof BaseConfigSocialItem>(
socialId: string,
key: K,
value: BaseConfigSocialItem[K],
) => {
setConfig((previous) =>
previous
? {
...previous,
socials: previous.socials.map((item) =>
item.id === socialId ? { ...item, [key]: value } : item,
),
}
: previous,
);
};
const handleSaveSocials = () => {
if (!config) return;
saveConfig(config);
toast.success("Đã lưu cấu hình mạng xã hội");
};
if (!config) {
return (
<div className="rounded-3xl border border-[#063e8e]/10 bg-white p-10 text-center text-gray-500">
Đang tải cấu hình chung...
</div>
);
}
return (
<div className="space-y-8">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-5">
<TabsList className="h-auto rounded-2xl bg-[#eaf2ff] p-1.5">
<TabsTrigger
value="branding"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Nhận diện thương hiệu
</TabsTrigger>
<TabsTrigger
value="banner"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Banner trang chủ
</TabsTrigger>
<TabsTrigger
value="contact"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Thông tin liên hệ
</TabsTrigger>
<TabsTrigger
value="social"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 hover:text-[#063e8e] data-[state=active]:bg-white data-[state=active]:text-[#063e8e]"
>
Mạng xã hội
</TabsTrigger>
</TabsList>
<TabsContent value="branding" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Nhận diện thương hiệu</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý logo hiển thị trên website.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
{currentLogo ? (
<>
<Button
type="button"
variant="outline"
onClick={() => openEditDialog("logo", currentLogo)}
className="rounded-xl border-[#063e8e]/15 text-gray-700"
>
<Edit className="mr-2 h-4 w-4" />
Cập nhật logo
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setDeleteTarget({
mode: "logo",
id: currentLogo.id,
name: currentLogo.name,
})
}
className="rounded-xl border-red-200 text-red-600 hover:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
</>
) : (
<Button
type="button"
onClick={() => openCreateDialog("logo")}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Thiết lập logo
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_360px]">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#f8fbff] to-white p-5">
<div className="relative flex min-h-[320px] items-center justify-center overflow-hidden rounded-[24px] border border-dashed border-[#063e8e]/18 bg-[#eef4ff]">
{currentLogoMedia ? (
<div className="relative h-[220px] w-[220px]">
<SafeNextImage
src={currentLogoMedia.url}
alt={currentLogoMedia.alt || currentLogoMedia.name}
fill
className="object-contain"
/>
</div>
) : (
<div className="text-center text-gray-500">
<ImagePlus className="mx-auto mb-3 h-10 w-10 text-[#4b74b8]" />
Chưa có logo nào được cấu hình
</div>
)}
</div>
</div>
<div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
{currentLogo ? (
<div className="space-y-4 rounded-3xl border border-[#063e8e]/12 bg-white p-5 text-sm text-slate-600 shadow-sm">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-[#4b74b8]">
Logo website
</div>
<div className="mt-3 font-semibold text-[#163b73]">{currentLogo.name}</div>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Tên website</Label>
<Input
value={config.websiteName}
onChange={(event) => handleWebsiteInfoChange("websiteName", event.target.value)}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Link website</Label>
<Input
value={config.websiteLink}
onChange={(event) => handleWebsiteInfoChange("websiteLink", event.target.value)}
className={fieldClassName}
/>
</div>
<div className="hidden rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Trạng thái
</div>
<div className="mt-2 flex items-center gap-2">
<Badge variant="outline" className="border-[#063e8e]/20 text-[#063e8e]">
{currentLogo.isActive ? "Đang hiển thị" : "Đang ẩn"}
</Badge>
</div>
</div>
<Button
type="button"
onClick={handleSaveWebsiteInfo}
className="w-full rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
Lưu thông tin website
</Button>
</div>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-8 text-center text-sm text-gray-500">
Chưa có logo nào. Hãy thiết lập logo cho website.
</div>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="banner" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Banner trang chủ</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý hình ảnh slider chỉ dùng cho khu vực banner trang chủ của website.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={() => openCreateDialog("banner")}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Thêm banner
</Button>
{currentBanner ? (
<>
<Button
type="button"
variant="outline"
onClick={() => openEditDialog("banner", currentBanner)}
className="rounded-xl border-[#063e8e]/15 text-gray-700"
>
<Edit className="mr-2 h-4 w-4" />
Sửa
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setDeleteTarget({
mode: "banner",
id: currentBanner.id,
name: currentBanner.name,
})
}
className="rounded-xl border-red-200 text-red-600 hover:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</Button>
</>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
<div className="relative aspect-[16/6] overflow-hidden rounded-[24px] border border-[#063e8e]/12 bg-[#eef4ff]">
{currentBannerMedia ? (
<SafeNextImage
src={currentBannerMedia.url}
alt={currentBannerMedia.alt || currentBannerMedia.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
Chưa có banner được chọn
</div>
)}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]">
Danh sách banner trang chủ
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="rounded-xl border-[#063e8e]/15"
onClick={() =>
setCurrentBannerIndex((previous) =>
previous <= 0 ? Math.max(sortedBanners.length - 1, 0) : previous - 1,
)
}
disabled={sortedBanners.length <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="rounded-xl border-[#063e8e]/15"
onClick={() =>
setCurrentBannerIndex((previous) =>
sortedBanners.length === 0 ? 0 : (previous + 1) % sortedBanners.length,
)
}
disabled={sortedBanners.length <= 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{sortedBanners.map((item, index) => (
<ConfigItemPreview
key={item.id}
title={`Banner ${index + 1}`}
item={item}
media={resolveMediaItem(mediaMap, item.imageId)}
current={index === currentBannerIndex}
onSelect={() => setCurrentBannerIndex(index)}
/>
))}
</div>
{currentBanner ? (
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">Tên banner</div>
<div className="mt-2 font-semibold text-[#163b73]">{currentBanner.name}</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Thứ tự hiển thị
</div>
<div className="mt-2 font-semibold text-[#163b73]">{currentBanner.sortOrder}</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">
Thời gian hiển thị
</div>
<div className="mt-2 font-semibold text-[#163b73]">
{currentBanner.displayTimeSeconds} giây
</div>
</div>
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-4">
<div className="text-xs uppercase tracking-[0.14em] text-gray-500">Trạng thái</div>
<div className="mt-2">
<Badge variant="outline" className="border-[#063e8e]/20 text-[#063e8e]">
{currentBanner.isActive ? "Đang hiển thị" : "Đang ẩn"}
</Badge>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="contact" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Thông tin liên hệ website</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý nhiều địa chỉ chi nhánh để hiển thị trên website.
</CardDescription>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="button"
onClick={handleAddBranch}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Thêm chi nhánh
</Button>
<Button
type="button"
onClick={handleSaveBranches}
disabled={savingContact}
className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
{savingContact ? "Đang lưu..." : "Lưu danh sách chi nhánh"}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-4 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
<div className="text-sm font-semibold uppercase tracking-[0.15em] text-[#4b74b8]">
Danh sách chi nhánh
</div>
<div className="space-y-3">
{config.branches.map((branch, index) => (
<BranchCard
key={branch.id}
branch={branch}
current={index === currentBranchIndex}
onSelect={() => setCurrentBranchIndex(index)}
onDelete={() => handleDeleteBranch(branch.id)}
/>
))}
</div>
</div>
<div className="space-y-5 rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5">
{currentBranch ? (
<>
<div className="space-y-2">
<Label className="text-gray-700">Tên chi nhánh</Label>
<Input
value={currentBranch.branchName}
onChange={(event) => handleBranchChange("branchName", event.target.value)}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Địa chỉ</Label>
<Textarea
value={currentBranch.address}
onChange={(event) => handleBranchChange("address", event.target.value)}
className={`${fieldClassName} min-h-[110px]`}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-gray-700">Hotline</Label>
<Input
value={currentBranch.hotline}
onChange={(event) => handleBranchChange("hotline", event.target.value)}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Email</Label>
<Input
value={currentBranch.email}
onChange={(event) => handleBranchChange("email", event.target.value)}
className={fieldClassName}
/>
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-gray-700">Fax</Label>
<Input
value={currentBranch.fax}
onChange={(event) => handleBranchChange("fax", event.target.value)}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Google Maps</Label>
<Input
value={currentBranch.mapsEmbedUrl}
onChange={(event) => handleBranchChange("mapsEmbedUrl", event.target.value)}
className={fieldClassName}
/>
</div>
</div>
</>
) : (
<div className="rounded-3xl border border-dashed border-[#063e8e]/15 bg-white px-5 py-10 text-center text-sm text-gray-500">
Chưa có chi nhánh nào. Hãy thêm chi nhánh để bắt đầu cấu hình.
</div>
)}
</div>
<div className="hidden rounded-[28px] border border-[#063e8e]/10 bg-gradient-to-br from-[#063e8e] to-[#0f4a9f] p-6 text-white shadow-[0_16px_30px_rgba(6,62,142,0.18)]">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-white/75">
Preview chi nhánh
</div>
{currentBranch ? (
<>
<div className="mt-4 text-2xl font-semibold">{currentBranch.branchName}</div>
<div className="mt-6 space-y-4 text-sm leading-6">
<div className="flex gap-3">
<MapPin className="mt-1 h-4 w-4 shrink-0 text-white/80" />
<span>{currentBranch.address || "Chưa cập nhật địa chỉ"}</span>
</div>
<div className="flex gap-3">
<Phone className="mt-1 h-4 w-4 shrink-0 text-white/80" />
<span>{currentBranch.hotline || "Chưa cập nhật hotline"}</span>
</div>
<div className="flex gap-3">
<Mail className="mt-1 h-4 w-4 shrink-0 text-white/80" />
<span>{currentBranch.email || "Chưa cập nhật email"}</span>
</div>
<div className="flex gap-3">
<Globe className="mt-1 h-4 w-4 shrink-0 text-white/80" />
<span>{currentBranch.fax || "Chưa cập nhật fax"}</span>
</div>
</div>
</>
) : (
<div className="mt-6 text-sm text-white/80">Không có dữ liệu chi nhánh để preview.</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="social" className="mt-0">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className="text-2xl text-[#163b73]">Mạng xã hội</CardTitle>
<CardDescription className="mt-2 text-sm text-slate-600">
Quản lý link mạng xã hội và thứ tự hiển thị trên website.
</CardDescription>
</div>
<Button
type="button"
onClick={handleSaveSocials}
className="rounded-xl bg-[#163b73] text-white hover:bg-[#163b73]/90"
>
<Save className="mr-2 h-4 w-4" />
Lưu mạng xã hội
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{sortedSocials.map((item) => (
<div
key={item.id}
className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<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 border-[#063e8e]/10 bg-white px-4 py-4">
<Checkbox
checked={item.isVisible}
onCheckedChange={(checked) =>
handleSocialChange(item.id, "isVisible", checked === true)
}
/>
<div>
<div className="font-semibold text-[#163b73]">{item.label}</div>
<div className="text-sm text-slate-500">
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Link URL</Label>
<Input
value={item.url}
onChange={(event) => handleSocialChange(item.id, "url", event.target.value)}
placeholder={`Nhập link ${item.label}...`}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={item.sortOrder}
onChange={(event) =>
handleSocialChange(item.id, "sortOrder", Number(event.target.value || 1))
}
className={fieldClassName}
/>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<ConfigItemDialog
open={itemDialogOpen}
mode={itemDialogMode}
form={itemForm}
previewMedia={previewMedia}
saving={savingItem}
title={
editingItemId
? itemDialogMode === "logo"
? "Cập nhật logo"
: "Chỉnh sửa banner"
: itemDialogMode === "logo"
? "Thiết lập logo"
: "Thêm banner mới"
}
description={
itemDialogMode === "logo"
? "Thiết lập logo hiển thị cho website."
: "Thiết lập banner hiển thị cho trang chủ."
}
onOpenChange={setItemDialogOpen}
onChange={(key, value) => setItemForm((previous) => ({ ...previous, [key]: value }))}
onPickImage={() => setImagePickerOpen(true)}
onSubmit={handleSubmitItem}
/>
<AdminImagePicker
open={imagePickerOpen}
selectedId={itemForm.imageId}
onOpenChange={setImagePickerOpen}
onSelect={(item) => setItemForm((previous) => ({ ...previous, imageId: item.id }))}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa cấu hình"
description={
<>
Bạn có chắc muốn xóa <span className="font-semibold">{deleteTarget?.name}</span>? Hành
động này không thể hoàn tác.
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={handleDeleteItem}
/>
</div>
);
}
"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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
CONTACT_PURPOSE_OPTIONS,
type ContactRequestItem,
persistContactRequests,
readContactRequests,
} from "@/mockdata/contact-management";
const selectTriggerClassName =
"w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[220px]";
const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function formatDateTime(value: string) {
return dayjs(value).format("DD/MM/YYYY HH:mm");
}
export default function AdminContactRequestsPage() {
const [items, setItems] = React.useState<ContactRequestItem[]>([]);
const [search, setSearch] = React.useState("");
const [purposeFilter, setPurposeFilter] = React.useState("all");
const [ready, setReady] = React.useState(false);
const [detailTarget, setDetailTarget] = React.useState<ContactRequestItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ContactRequestItem | null>(null);
React.useEffect(() => {
setItems(readContactRequests());
setReady(true);
}, []);
const filteredItems = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
return items.filter((item) => {
const matchesKeyword =
!keyword ||
item.id.toLowerCase().includes(keyword) ||
item.contactName.toLowerCase().includes(keyword) ||
item.contactEmail.toLowerCase().includes(keyword) ||
item.contactPhone.toLowerCase().includes(keyword) ||
item.organizationName.toLowerCase().includes(keyword) ||
item.email.toLowerCase().includes(keyword) ||
item.message.toLowerCase().includes(keyword);
const matchesPurpose = purposeFilter === "all" || item.purpose === purposeFilter;
return matchesKeyword && matchesPurpose;
});
}, [items, purposeFilter, search]);
const handleDelete = () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistContactRequests(nextItems);
toast.success("Đã xóa đơn liên hệ");
setDeleteTarget(null);
};
return (
<div className="space-y-8">
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm đơn liên hệ..."
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>
}
filters={
<Select value={purposeFilter} onValueChange={setPurposeFilter}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Mục đích liên hệ" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả mục đích
</SelectItem>
{CONTACT_PURPOSE_OPTIONS.map((option) => (
<SelectItem key={option} value={option} className={selectItemClassName}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
}
onSearchChange={setSearch}
>
<div className="scrollbar overflow-x-auto">
<Table className="min-w-[1100px]">
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-16 py-4 text-center text-white">STT</TableHead>
<TableHead className="w-[220px] py-4 text-center text-white">Mục đích liên hệ</TableHead>
<TableHead className="py-4 text-center text-white">Người liên hệ</TableHead>
<TableHead className="py-4 text-center text-white">Tên công ty / tổ chức</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: 4 }).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 liên hệ 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-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.purpose}
</Badge>
</TableCell>
<TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1">
<div className="font-semibold">{item.contactName}</div>
<div className="text-gray-600">{item.contactPhone}</div>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1">
<div className="font-medium text-gray-800">{item.organizationName}</div>
<div>{item.businessField}</div>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1">
<div>{item.email}</div>
<div className="text-gray-500">{item.contactEmail}</div>
</div>
</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 liên hệ"
description="Thông tin đầy đủ của đơn liên hệ được gửi từ website VCCI News."
badge={
detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.purpose}
</Badge>
) : null
}
sections={
detailTarget
? [
{
title: "Thông tin chung",
fields: [
{ label: "Mục đích liên hệ", value: detailTarget.purpose },
{ label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
],
},
{
title: "Người liên hệ",
fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName },
{ label: "Chức vụ", value: detailTarget.contactPosition },
{ label: "Email người liên hệ", value: detailTarget.contactEmail },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone },
{ label: "Nội dung liên hệ", value: detailTarget.message, fullWidth: true },
],
},
{
title: "Thông tin công ty / tổ chức",
fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField },
{ label: "Email", value: detailTarget.email },
{ label: "Website", value: detailTarget.website },
],
},
]
: []
}
onOpenChange={(open) => !open && setDetailTarget(null)}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa đơn liên hệ"
description={
<>
Bạn có chắc muốn xóa đơn liên hệ của{" "}
<span className="font-semibold">{deleteTarget?.contactName}</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, 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");
}
"use client";
import * as React from "react";
import Link from "next/link";
import dayjs from "dayjs";
import {
ArrowRight,
FolderTree,
Globe,
Image as ImageIcon,
LayoutTemplate,
Mail,
MapPin,
MonitorPlay,
Newspaper,
Sparkles,
Users,
} from "lucide-react";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
type AdminMediaItem,
type AdminNewsItem,
readAdminMediaItems,
readAdminNewsItems,
} from "@/mockdata/admin-news";
import { type BaseConfigData, readBaseConfig } from "@/mockdata/base-config";
import {
type ContactRequestItem,
type MembershipApplicationItem,
type NewsletterSubscriptionItem,
readContactRequests,
readMembershipApplications,
readNewsletterSubscriptions,
} from "@/mockdata/contact-management";
import {
type HeaderCategoryPostItem,
getHeaderCategoryPostSeed,
} from "@/mockdata/header-category-posts";
import { type HeaderCategoryItem, getHeaderCategorySeed } from "@/mockdata/header-config";
import {
type MemberField,
type MemberItem,
type MemberRegion,
readMemberFields,
readMemberRegions,
readMembers,
} from "@/mockdata/members";
import { type VideoItem, readVideos } from "@/mockdata/videos";
function formatDateTime(value: string) {
return dayjs(value).format("DD/MM/YYYY HH:mm");
}
type DashboardMetric = {
title: string;
value: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
href: string;
};
type DashboardShortcut = {
title: string;
description: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
};
type ActivityItem = {
id: string;
title: string;
description: string;
time: string;
badge: string;
href: string;
};
export default function AdminDashboardPage() {
const [ready, setReady] = React.useState(false);
const [newsItems, setNewsItems] = React.useState<AdminNewsItem[]>([]);
const [mediaItems, setMediaItems] = React.useState<AdminMediaItem[]>([]);
const [videos, setVideos] = React.useState<VideoItem[]>([]);
const [members, setMembers] = React.useState<MemberItem[]>([]);
const [memberFields, setMemberFields] = React.useState<MemberField[]>([]);
const [memberRegions, setMemberRegions] = React.useState<MemberRegion[]>([]);
const [newsletterItems, setNewsletterItems] = React.useState<NewsletterSubscriptionItem[]>([]);
const [contactRequests, setContactRequests] = React.useState<ContactRequestItem[]>([]);
const [membershipApplications, setMembershipApplications] = React.useState<
MembershipApplicationItem[]
>([]);
const [baseConfig, setBaseConfig] = React.useState<BaseConfigData>(() => readBaseConfig());
React.useEffect(() => {
setNewsItems(readAdminNewsItems());
setMediaItems(readAdminMediaItems());
setVideos(readVideos());
setMembers(readMembers());
setMemberFields(readMemberFields());
setMemberRegions(readMemberRegions());
setNewsletterItems(readNewsletterSubscriptions());
setContactRequests(readContactRequests());
setMembershipApplications(readMembershipApplications());
setBaseConfig(readBaseConfig());
setReady(true);
}, []);
const headerCategories = React.useMemo<HeaderCategoryItem[]>(() => getHeaderCategorySeed(), []);
const headerPosts = React.useMemo<HeaderCategoryPostItem[]>(() => getHeaderCategoryPostSeed(), []);
const metrics = React.useMemo<DashboardMetric[]>(() => {
const totalContactForms =
newsletterItems.length + contactRequests.length + membershipApplications.length;
return [
{
title: "Bài viết nội dung",
value: String(newsItems.length + headerPosts.length),
description: `${newsItems.length} bài admin, ${headerPosts.length} bài danh mục`,
icon: Newspaper,
href: "/admin/news",
},
{
title: "Tài nguyên media",
value: String(mediaItems.length + videos.length),
description: `${mediaItems.length} ảnh, ${videos.length} video`,
icon: ImageIcon,
href: "/admin/media",
},
{
title: "Hội viên & biểu mẫu",
value: String(members.length + membershipApplications.length),
description: `${members.length} hội viên, ${membershipApplications.length} đơn chờ xử lý`,
icon: Users,
href: "/admin/members",
},
{
title: "Liên hệ từ website",
value: String(totalContactForms),
description: `${newsletterItems.length} email nhận tin, ${contactRequests.length} đơn liên hệ`,
icon: Mail,
href: "/admin/contact-management/newsletter-emails",
},
];
}, [
contactRequests.length,
headerPosts.length,
mediaItems.length,
members.length,
membershipApplications.length,
newsletterItems.length,
newsItems.length,
videos.length,
]);
const shortcuts = React.useMemo<DashboardShortcut[]>(
() => [
{
title: "Cấu hình chung",
description: "Logo, banner, chi nhánh liên hệ và mạng xã hội",
href: "/admin/base-config",
icon: Globe,
},
{
title: "Cấu hình danh mục",
description: "Menu header và bài viết theo danh mục",
href: "/admin/header-config",
icon: FolderTree,
},
{
title: "Quản lý bài viết",
description: "Tin tức, bài viết trang và nội dung xuất bản",
href: "/admin/news",
icon: LayoutTemplate,
},
{
title: "Quản lý liên hệ",
description: "Email nhận tin, đơn liên hệ và đăng ký hội viên",
href: "/admin/contact-management/newsletter-emails",
icon: Mail,
},
],
[],
);
const recentActivities = React.useMemo<ActivityItem[]>(() => {
const items: ActivityItem[] = [
...newsItems.map((item) => ({
id: `news-${item.id}`,
title: item.title,
description: "Cập nhật trong Quản lý bài viết",
time: item.updated_at || item.created_at,
badge: "Bài viết",
href: "/admin/news",
})),
...mediaItems.map((item) => ({
id: `media-${item.id}`,
title: item.name,
description: "Cập nhật trong kho ảnh website",
time: item.updated_at,
badge: "Ảnh",
href: "/admin/media",
})),
...membershipApplications.map((item) => ({
id: `member-app-${item.id}`,
title: item.organizationName,
description: "Đơn đăng ký hội viên mới",
time: item.submittedAt,
badge: "Đơn hội viên",
href: "/admin/contact-management/membership-applications",
})),
...contactRequests.map((item) => ({
id: `contact-${item.id}`,
title: item.contactName,
description: item.purpose,
time: item.submittedAt,
badge: "Liên hệ",
href: "/admin/contact-management/contact-requests",
})),
];
return items
.sort((left, right) => dayjs(right.time).valueOf() - dayjs(left.time).valueOf())
.slice(0, 6);
}, [contactRequests, mediaItems, membershipApplications, newsItems]);
const spotlightNews = React.useMemo(() => newsItems.slice(0, 3), [newsItems]);
const visibleSocials = React.useMemo(
() => baseConfig.socials.filter((item) => item.isVisible).sort((a, b) => a.sortOrder - b.sortOrder),
[baseConfig.socials],
);
const activeBanners = React.useMemo(
() => baseConfig.banners.filter((item) => item.isActive).sort((a, b) => a.sortOrder - b.sortOrder),
[baseConfig.banners],
);
if (!ready) {
return (
<div className="grid gap-5 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<div
key={`dashboard-loading-${index}`}
className="h-40 animate-pulse rounded-[28px] border border-[#063e8e]/10 bg-white"
/>
))}
</div>
);
}
return (
<div className="space-y-6">
<section className="grid gap-5 xl:grid-cols-[1.35fr_0.65fr]">
<Card className="overflow-hidden rounded-[30px] border-[#063e8e]/10 bg-[linear-gradient(135deg,#ffffff_0%,#f5f9ff_55%,#ebf3ff_100%)] shadow-[0_18px_55px_rgba(6,62,142,0.08)]">
<CardContent className="p-6 sm:p-7">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-[#063e8e]/10 bg-white/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-[#063e8e]">
<Sparkles className="h-3.5 w-3.5" />
Tổng quan hệ thống
</div>
<div>
<h2 className="text-3xl font-semibold text-[#163b73]">Dashboard quản trị VCCI News</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Theo dõi nhanh nội dung, tài nguyên media, cấu hình website và các biểu mẫu từ
người dùng ngay trên một màn hình tổng hợp.
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Link
href="/admin/news"
className="rounded-[24px] border border-[#063e8e]/10 bg-white/90 p-4 transition hover:border-[#063e8e]/20 hover:shadow-sm"
>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">Xuất bản</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{newsItems.length}</div>
<div className="mt-1 text-sm text-slate-500">bài viết đang quản lý</div>
</Link>
<Link
href="/admin/contact-management/contact-requests"
className="rounded-[24px] border border-[#063e8e]/10 bg-white/90 p-4 transition hover:border-[#063e8e]/20 hover:shadow-sm"
>
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">Phản hồi</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">
{contactRequests.length + membershipApplications.length}
</div>
<div className="mt-1 text-sm text-slate-500">đơn đang cần theo dõi</div>
</Link>
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl text-[#163b73]">Thông tin website</CardTitle>
<CardDescription className="text-slate-600">
Tóm tắt nhận diện và cấu hình chung đang hiển thị.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Website</div>
<div className="mt-2 text-lg font-semibold text-[#163b73]">{baseConfig.websiteName}</div>
<div className="mt-1 truncate text-sm text-slate-500">{baseConfig.websiteLink}</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Banner hoạt động</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{activeBanners.length}</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Mạng xã hội hiển thị</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{visibleSocials.length}</div>
</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Chi nhánh liên hệ</div>
<div className="mt-2 text-lg font-semibold text-[#163b73]">{baseConfig.branches.length} địa điểm</div>
<div className="mt-1 text-sm text-slate-500">
{baseConfig.branches[0]?.branchName || "Chưa có chi nhánh nào được cấu hình"}
</div>
</div>
</CardContent>
</Card>
</section>
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{metrics.map((metric) => (
<Link key={metric.title} href={metric.href}>
<Card className="h-full rounded-[28px] border-[#063e8e]/10 shadow-sm transition hover:-translate-y-0.5 hover:border-[#063e8e]/20 hover:shadow-[0_16px_40px_rgba(6,62,142,0.1)]">
<CardContent className="flex h-full flex-col justify-between gap-5 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-medium text-slate-500">{metric.title}</div>
<div className="mt-3 text-3xl font-semibold text-[#163b73]">{metric.value}</div>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]">
<metric.icon className="h-5 w-5" />
</div>
</div>
<div className="text-sm leading-6 text-slate-500">{metric.description}</div>
</CardContent>
</Card>
</Link>
))}
</section>
<section className="grid gap-5 xl:grid-cols-[1.1fr_0.9fr]">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-xl text-[#163b73]">Hoạt động gần đây</CardTitle>
<CardDescription className="text-slate-600">
Các cập nhật mới nhất từ bài viết, kho ảnh và biểu mẫu liên hệ.
</CardDescription>
</div>
<Badge variant="outline" className="border-[#063e8e]/15 text-[#063e8e]">
{recentActivities.length} mục
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{recentActivities.map((item) => (
<Link
key={item.id}
href={item.href}
className="flex items-start justify-between gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-[#063e8e]/15 text-[#063e8e]">
{item.badge}
</Badge>
<span className="text-xs text-slate-400">{formatDateTime(item.time)}</span>
</div>
<div className="mt-2 line-clamp-1 text-sm font-semibold text-[#163b73]">
{item.title}
</div>
<div className="mt-1 line-clamp-2 text-sm text-slate-500">{item.description}</div>
</div>
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-slate-400" />
</Link>
))}
</CardContent>
</Card>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl text-[#163b73]">Lối tắt quản trị</CardTitle>
<CardDescription className="text-slate-600">
Truy cập nhanh vào các module quan trọng trong admin.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{shortcuts.map((item) => (
<Link
key={item.title}
href={item.href}
className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4 transition hover:border-[#063e8e]/20 hover:bg-white"
>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white text-[#063e8e] shadow-sm">
<item.icon className="h-5 w-5" />
</div>
<div className="mt-4 text-sm font-semibold text-[#163b73]">{item.title}</div>
<div className="mt-1 text-sm leading-6 text-slate-500">{item.description}</div>
</Link>
))}
</CardContent>
</Card>
</section>
<section className="grid gap-5 xl:grid-cols-[1fr_1fr_0.9fr]">
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-xl text-[#163b73]">Nội dung nổi bật</CardTitle>
<CardDescription className="text-slate-600">
Các bài viết mới nhất đang được quản lý trong admin.
</CardDescription>
</div>
<Button asChild variant="outline" className="rounded-xl border-[#063e8e]/15 text-[#063e8e]">
<Link href="/admin/news">Xem tất cả</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{spotlightNews.map((item) => (
<Link
key={item.id}
href={`/admin/news/${item.id}`}
className="flex gap-4 rounded-[24px] border border-[#063e8e]/10 bg-white p-4 transition hover:bg-[#f8fbff]"
>
<div className="relative h-20 w-28 shrink-0 overflow-hidden rounded-2xl bg-[#eef4ff]">
{item.thumbnail ? (
<SafeNextImage
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-[#063e8e]">
<Newspaper className="h-5 w-5" />
</div>
)}
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-semibold text-[#163b73]">{item.title}</div>
<div className="mt-2 text-xs text-slate-400">
{formatDateTime(item.updated_at || item.created_at)}
</div>
</div>
</Link>
))}
</CardContent>
</Card>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl text-[#163b73]">Danh mục & thư viện</CardTitle>
<CardDescription className="text-slate-600">
Tình trạng cấu trúc nội dung và dữ liệu danh mục đang dùng.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Menu header</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerCategories.length}</div>
<div className="mt-1 text-sm text-slate-500">mục điều hướng</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-[#f8fbff] p-4">
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">Bài trong danh mục</div>
<div className="mt-2 text-2xl font-semibold text-[#163b73]">{headerPosts.length}</div>
<div className="mt-1 text-sm text-slate-500">bản ghi nội dung</div>
</div>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-[#163b73]">Kho ảnh website</div>
<Badge variant="outline" className="border-[#063e8e]/15 text-[#063e8e]">
{mediaItems.length} ảnh
</Badge>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{mediaItems.slice(0, 3).map((item) => (
<div key={item.id} className="relative aspect-square overflow-hidden rounded-2xl bg-[#eef4ff]">
<SafeNextImage
src={item.url}
alt={item.alt || item.name}
fill
className="object-cover"
/>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-[30px] border-[#063e8e]/10 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl text-[#163b73]">Quy mô dữ liệu</CardTitle>
<CardDescription className="text-slate-600">
Tổng hợp nhanh các nhóm dữ liệu đang có trong hệ thống.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{[
{ label: "Video", value: videos.length, icon: MonitorPlay },
{ label: "Lĩnh vực hội viên", value: memberFields.length, icon: FolderTree },
{ label: "Khu vực hội viên", value: memberRegions.length, icon: MapPin },
{ label: "Chi nhánh liên hệ", value: baseConfig.branches.length, icon: Globe },
].map((item) => (
<div
key={item.label}
className="flex items-center justify-between rounded-[22px] border border-[#063e8e]/10 bg-[#f8fbff] px-4 py-3"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white text-[#063e8e] shadow-sm">
<item.icon className="h-4 w-4" />
</div>
<span className="text-sm font-medium text-slate-600">{item.label}</span>
</div>
<span className="text-lg font-semibold text-[#163b73]">{item.value}</span>
</div>
))}
</CardContent>
</Card>
</section>
</div>
);
}
......@@ -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 />
......
"use client";
import * as React from "react";
import {
Edit,
Image as ImageIcon,
Plus,
Save,
Trash2,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
type AdminMediaItem,
createAdminMediaId,
persistAdminMediaItems,
readAdminMediaItems,
} from "@/mockdata/admin-news";
const inputClassName =
"rounded-2xl border-[#063e8e]/15 bg-white text-gray-700 shadow-sm placeholder:text-gray-400 focus-visible:ring-[#063e8e]/20";
type MediaFormValues = {
id?: string;
name: string;
alt: string;
url: string;
mime: string;
size: number;
source: "seed" | "upload";
};
const EMPTY_MEDIA_FORM: MediaFormValues = {
name: "",
alt: "",
url: "",
mime: "image/*",
size: 0,
source: "upload",
};
function formatFileSize(size: number) {
if (!size) return "Ảnh hệ thống";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(value: string) {
return new Date(value).toLocaleString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function MediaCardSkeleton() {
return (
<div className="overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_18px_45px_rgba(6,62,142,0.08)]">
<div className="aspect-square animate-pulse bg-[#063e8e]/8" />
<div className="space-y-3 p-4">
<div className="h-4 w-2/3 animate-pulse rounded-full bg-[#063e8e]/10" />
<div className="h-3 w-full animate-pulse rounded-full bg-[#063e8e]/10" />
<div className="h-3 w-1/2 animate-pulse rounded-full bg-[#063e8e]/10" />
</div>
</div>
);
}
interface MediaFormDialogProps {
open: boolean;
initial: AdminMediaItem | null;
onOpenChange: (open: boolean) => void;
onSave: (data: MediaFormValues) => void;
}
function MediaFormDialog({
open,
initial,
onOpenChange,
onSave,
}: MediaFormDialogProps) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [form, setForm] = React.useState<MediaFormValues>(EMPTY_MEDIA_FORM);
React.useEffect(() => {
if (!open) return;
setForm(
initial
? {
id: initial.id,
name: initial.name,
alt: initial.alt,
url: initial.url,
mime: initial.mime,
size: initial.size,
source: initial.source,
}
: EMPTY_MEDIA_FORM,
);
}, [initial, open]);
const handleField = <K extends keyof MediaFormValues>(
key: K,
value: MediaFormValues[K],
) => {
setForm((previous) => ({ ...previous, [key]: value }));
};
const handleUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const defaultName = file.name.replace(/\.[^.]+$/, "");
setForm((previous) => ({
...previous,
name: previous.name || defaultName,
alt: previous.alt || defaultName,
url: typeof reader.result === "string" ? reader.result : previous.url,
mime: file.type || "image/*",
size: file.size,
source: "upload",
}));
};
reader.readAsDataURL(file);
event.target.value = "";
};
const handleSave = () => {
if (!form.name.trim()) {
toast.error("Vui lòng nhập tên ảnh");
return;
}
if (!form.url.trim()) {
toast.error("Vui lòng chọn ảnh hoặc nhập liên kết ảnh");
return;
}
onSave({
...form,
name: form.name.trim(),
alt: form.alt.trim() || form.name.trim(),
url: form.url.trim(),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100dvh-32px)] w-[calc(100vw-32px)] max-w-4xl flex-col overflow-hidden rounded-[32px] border border-[#063e8e]/15 bg-white p-0 shadow-[0_26px_70px_rgba(15,23,42,0.24)]">
<DialogHeader className="shrink-0 border-b border-[#063e8e]/10 px-6 py-5 sm:px-7">
<DialogTitle className="text-xl font-semibold text-[#063e8e]">
{initial ? "Chỉnh sửa ảnh" : "Tải ảnh lên"}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto lg:grid lg:grid-cols-[1.1fr_0.9fr]">
<div className="border-b border-[#063e8e]/10 bg-[linear-gradient(180deg,#f5f9ff_0%,#eef5ff_100%)] p-6 lg:border-b-0 lg:border-r lg:p-7">
<div className="space-y-4">
<div className="overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
<div className="relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]">
{form.url ? (
<SafeNextImage
src={form.url}
alt={form.alt || form.name}
fill
className="object-contain p-4"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#063e8e]/10 text-[#063e8e]">
<ImageIcon className="h-7 w-7" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-700">
Chưa có ảnh nào được chọn
</p>
<p className="text-xs text-slate-500">
Kéo thả hoặc tải ảnh từ máy tính của bạn
</p>
</div>
</div>
)}
</div>
</div>
<div className="rounded-[28px] border border-dashed border-[#063e8e]/20 bg-white/90 p-5">
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
/>
<div className="space-y-3">
<p className="text-sm font-semibold text-slate-700">
Tải ảnh từ máy tính
</p>
<p className="text-sm text-slate-500">
Hỗ trợ ảnh JPG, PNG, WEBP. Dung lượng sẽ được lưu theo file bạn chọn.
</p>
<Button
type="button"
variant="outline"
onClick={() => inputRef.current?.click()}
className="rounded-2xl border-[#063e8e]/15 bg-white text-[#063e8e] hover:bg-[#edf4ff]"
>
<Upload className="mr-2 h-4 w-4" />
Chọn ảnh
</Button>
</div>
</div>
</div>
</div>
<div className="p-6 lg:p-7">
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Tiêu đề ảnh</Label>
<Input
value={form.name}
onChange={(event) => handleField("name", event.target.value)}
placeholder="Nhập tiêu đề ảnh"
className={inputClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Mô tả alt</Label>
<Input
value={form.alt}
onChange={(event) => handleField("alt", event.target.value)}
placeholder="Nhập mô tả alt"
className={inputClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Liên kết ảnh</Label>
<Input
value={form.url}
onChange={(event) => handleField("url", event.target.value)}
placeholder="https://... hoặc data:image/..."
className={inputClassName}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Ghi chú hiển thị</Label>
<Textarea
value={form.alt}
onChange={(event) => handleField("alt", event.target.value)}
placeholder="Nhập nội dung mô tả ngắn cho ảnh"
rows={4}
className={`${inputClassName} min-h-[120px] resize-none`}
/>
</div>
<div className="rounded-[24px] border border-[#063e8e]/10 bg-white p-4 text-sm text-slate-500">
<div className="flex items-center gap-2">
<p className="text-xs uppercase tracking-[0.14em] text-slate-400">Dung lượng</p>
<p className="font-semibold text-slate-700">{formatFileSize(form.size)}</p>
</div>
</div>
</div>
</div>
</div>
<div className="shrink-0 border-t border-[#063e8e]/10 bg-[#f8fbff] px-6 py-4 sm:px-7">
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-2xl border-[#063e8e]/15 bg-white text-slate-600 hover:bg-slate-50"
>
<X className="mr-2 h-4 w-4" />
Hủy
</Button>
<Button
type="button"
onClick={handleSave}
className="rounded-2xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Save className="mr-2 h-4 w-4" />
{initial ? "Lưu thay đổi" : "Tải ảnh lên"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export default function AdminMediaPage() {
const [items, setItems] = React.useState<AdminMediaItem[]>([]);
const [search, setSearch] = React.useState("");
const [ready, setReady] = React.useState(false);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editTarget, setEditTarget] = React.useState<AdminMediaItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<AdminMediaItem | null>(null);
React.useEffect(() => {
setItems(readAdminMediaItems());
setReady(true);
}, []);
const filtered = React.useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => {
return [item.name, item.alt, item.url].some((value) =>
value.toLowerCase().includes(keyword),
);
});
}, [items, search]);
const openCreate = () => {
setEditTarget(null);
setDialogOpen(true);
};
const openEdit = (item: AdminMediaItem) => {
setEditTarget(item);
setDialogOpen(true);
};
const handleSave = (data: MediaFormValues) => {
const now = new Date().toISOString();
let nextItems: AdminMediaItem[];
if (data.id) {
nextItems = items.map((item) =>
item.id === data.id
? {
...item,
name: data.name,
alt: data.alt,
url: data.url,
mime: data.mime,
size: data.size,
source: data.source,
updated_at: now,
}
: item,
);
toast.success("Đã cập nhật ảnh");
} else {
nextItems = [
{
id: createAdminMediaId(),
name: data.name,
alt: data.alt,
url: data.url,
mime: data.mime,
size: data.size,
source: data.source,
created_at: now,
updated_at: now,
},
...items,
];
toast.success("Đã thêm ảnh mới");
}
persistAdminMediaItems(nextItems);
setItems(readAdminMediaItems());
setDialogOpen(false);
};
const handleDelete = () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistAdminMediaItems(nextItems);
toast.success("Đã xóa ảnh");
setDeleteTarget(null);
};
return (
<div className="space-y-8">
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm ảnh..."
actionLabel="Tải ảnh lê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ố ảnh: {items.length}
</div>
}
onSearchChange={setSearch}
onActionClick={openCreate}
>
<div className="bg-white p-4 sm:p-5">
{!ready ? (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, index) => (
<MediaCardSkeleton key={`media-loading-${index}`} />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-[28px] border border-dashed border-[#063e8e]/20 bg-[#fbfdff] text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-[22px] bg-[#063e8e]/10 text-[#063e8e]">
<ImageIcon className="h-8 w-8" />
</div>
<h2 className="mt-5 text-lg font-semibold text-slate-800">Chưa có ảnh phù hợp</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
Hãy tải ảnh mới hoặc thử lại với từ khóa khác để tìm đúng hình ảnh bạn cần.
</p>
</div>
) : (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{filtered.map((item) => (
<article
key={item.id}
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%)]">
<SafeNextImage
src={item.url}
alt={item.alt || item.name}
fill
className="object-contain p-4 transition duration-300 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(6,62,142,0)_15%,rgba(15,23,42,0.68)_100%)] opacity-0 transition duration-300 group-hover:opacity-100" />
<div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 p-4 opacity-0 transition duration-300 group-hover:opacity-100">
<div className="rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 backdrop-blur">
{item.mime.split("/")[1]?.toUpperCase() || "IMG"}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => openEdit(item)}
className="h-10 w-10 rounded-2xl bg-white text-[#063e8e] shadow-lg hover:bg-white"
>
<Edit className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => setDeleteTarget(item)}
className="h-10 w-10 rounded-2xl bg-white text-red-600 shadow-lg hover:bg-white"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="space-y-3 p-4">
<div className="space-y-1">
<h3 className="line-clamp-1 text-sm font-semibold text-slate-900">
{item.name}
</h3>
<p className="line-clamp-2 min-h-10 text-xs leading-5 text-slate-500">
{item.alt || "Chưa có mô tả alt cho ảnh này."}
</p>
</div>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>{formatFileSize(item.size)}</span>
</div>
<div className="border-t border-[#063e8e]/8 pt-3 text-xs text-slate-500">
{formatDate(item.updated_at)}
</div>
</div>
</article>
))}
</div>
)}
</div>
</AdminTableLayout>
<MediaFormDialog
open={dialogOpen}
initial={editTarget}
onOpenChange={setDialogOpen}
onSave={handleSave}
/>
<AdminDeleteDialog
open={!!deleteTarget}
title="Xóa ảnh"
description={
<>
Bạn có chắc muốn xóa ảnh <span className="font-semibold">{deleteTarget?.name}</span>?
</>
}
onOpenChange={(open) => !open && setDeleteTarget(null)}
onConfirm={handleDelete}
/>
</div>
);
}
......@@ -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>
......
......@@ -7,8 +7,12 @@ import { usePathname } from 'next/navigation';
import {
ChevronDown,
Globe,
ImagePlus,
Layers,
Mail,
Newspaper,
Settings,
Sparkles,
Users,
Video,
} from 'lucide-react';
......@@ -25,6 +29,7 @@ type NavItem = {
};
const navigation: NavItem[] = [
{ name: 'Cấu hình chung', href: '/admin/base-config', icon: Settings },
{ name: 'Cấu hình danh mục', href: '/admin/header-config', icon: Layers },
{ name: 'Quản lý bài viết', href: '/admin/news', icon: Newspaper },
{ name: 'Quản lý video', href: '/admin/videos', icon: Video },
......@@ -37,6 +42,25 @@ const navigation: NavItem[] = [
{ name: 'Quản lý khu vực', href: '/admin/members/regions' },
],
},
{
name: 'Quản lý liên hệ',
icon: Mail,
children: [
{
name: 'Quản lý Email đăng ký nhận thông tin',
href: '/admin/contact-management/newsletter-emails',
},
{
name: 'Quản lý Đơn liên hệ',
href: '/admin/contact-management/contact-requests',
},
{
name: 'Quản lý Đơn đăng ký hội viên',
href: '/admin/contact-management/membership-applications',
},
],
},
{ name: 'Quản lý ảnh', href: '/admin/media', icon: ImagePlus },
];
const membersReservedSegments = new Set(['fields', 'regions']);
......@@ -44,9 +68,7 @@ const membersReservedSegments = new Set(['fields', 'regions']);
export function AdminSidebar() {
const pathname = usePathname();
const { isOpen } = useSidebarStore();
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({
'Quản lý hội viên': true,
});
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({});
const isItemActive = React.useCallback(
(href: string) => {
......@@ -71,79 +93,92 @@ export function AdminSidebar() {
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen border-r border-[#063e8e]/15 bg-[#063e8e]/20 shadow-[0_10px_30px_rgba(6,62,142,0.08)] transition-all duration-300',
isOpen ? 'w-56' : 'w-20',
'fixed left-0 top-0 z-40 h-screen border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300',
isOpen ? 'w-72' : 'w-24',
)}
>
<div className="flex h-full flex-col">
<div
className={cn(
'flex h-16 items-center border-b border-[#063e8e]/12 bg-white/80 px-4 backdrop-blur-sm',
!isOpen && 'justify-center px-2.5',
)}
>
<div className={cn('px-4 pb-4 pt-5', !isOpen && 'px-3')}>
<Link
href="/admin/dashboard"
className={cn('flex min-w-0 items-center gap-3', isOpen && 'justify-start')}
>
<div
href="/admin/base-config"
className={cn(
'flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-[#063e8e]/12 bg-white p-1.5 shadow-sm',
!isOpen && 'h-10 w-10',
'flex items-center backdrop-blur-sm',
isOpen
? 'gap-4 rounded-[28px] border border-white/80 bg-white/95 px-4 py-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]'
: 'justify-center px-0 py-4',
)}
>
<Image
src={logo}
alt="VCCI HCM"
className="h-full w-full object-contain"
priority
/>
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] shadow-sm">
<Image src={logo} alt="VCCI HCM" className="h-10 w-10 object-contain" priority />
</div>
{isOpen ? (
<div className="min-w-0 leading-tight">
<div className="truncate text-sm font-semibold uppercase tracking-[0.18em] text-[#063e8e]">
<div className="min-w-0">
<div className="truncate text-[13px] font-bold uppercase tracking-[0.22em] text-[#063e8e]">
VCCI News
</div>
<div className="mt-1 truncate text-[11px] text-gray-700">
Trang quản trị website
</div>
<div className="mt-1 text-sm leading-5 text-slate-600">Trang quản trị website</div>
</div>
) : null}
</Link>
</div>
<nav className={cn('flex-1 space-y-2 overflow-y-auto px-3 py-4', !isOpen && 'px-2')}>
<div className="px-4 pb-2">
{isOpen ? (
<div className="flex items-center gap-2 px-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
<Sparkles className="h-3.5 w-3.5 text-[#063e8e]" />
Điều hướng quản trị
</div>
) : null}
</div>
<nav
className={cn(
'scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2',
!isOpen && 'px-3',
)}
>
{navigation.map((item) => {
if (item.children) {
const active = isGroupActive(item.children);
const expanded = expandedGroups[item.name] ?? active;
const expanded = expandedGroups[item.name] ?? false;
return (
<div key={item.name} className="space-y-2">
<div
key={item.name}
className={cn(
'rounded-[26px] border border-transparent transition-all duration-200',
isOpen && expanded && 'border-[#063e8e]/10 bg-white/70 p-2 shadow-sm',
)}
>
<button
type="button"
onClick={() => isOpen && toggleGroup(item.name)}
title={!isOpen ? item.name : undefined}
className={cn(
'group flex w-full items-center rounded-2xl px-3 py-3 text-sm font-medium transition-all duration-200',
'flex w-full items-center rounded-2xl text-sm font-medium transition-all duration-200',
active
? 'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.16)]'
: 'text-gray-700 hover:bg-[#063e8e]/8 hover:text-[#063e8e]',
!isOpen && 'justify-center px-0',
? 'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
: 'text-slate-700 hover:bg-white/85 hover:text-[#063e8e]',
isOpen ? 'gap-3 px-4 py-3.5' : 'mx-auto h-14 w-14 justify-center p-0',
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{isOpen ? (
<>
<span className="ml-3 truncate text-left">{item.name}</span>
<span className="min-w-0 flex-1 text-left">{item.name}</span>
<ChevronDown
className={cn('ml-auto h-4 w-4 transition-transform', expanded && 'rotate-180')}
className={cn(
'h-4 w-4 shrink-0 transition-transform',
expanded && 'rotate-180',
)}
/>
</>
) : null}
</button>
{isOpen && expanded ? (
<div className="ml-4 space-y-1.5 border-l border-[#063e8e]/12 pl-4">
<div className="mt-2 space-y-1.5 border-l border-[#d5e1f7] pl-4">
{item.children.map((child) => {
const childActive = isItemActive(child.href);
......@@ -152,13 +187,13 @@ export function AdminSidebar() {
key={child.name}
href={child.href}
className={cn(
'block rounded-xl px-3 py-2.5 text-sm transition-colors',
'group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all',
childActive
? 'bg-[#063e8e]/10 font-semibold text-[#063e8e]'
: 'text-gray-700 hover:bg-[#063e8e]/6 hover:text-[#063e8e]',
? 'bg-[#dbe8ff] font-semibold text-[#063e8e]'
: 'text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]',
)}
>
{child.name}
<span className="block">{child.name}</span>
</Link>
);
})}
......@@ -176,39 +211,46 @@ export function AdminSidebar() {
href={item.href || '#'}
title={!isOpen ? item.name : undefined}
className={cn(
'group flex items-center rounded-2xl px-3 py-3 text-sm font-medium transition-all duration-200',
'flex items-center rounded-2xl text-sm font-medium transition-all duration-200',
active
? 'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.16)]'
: 'text-gray-700 hover:bg-[#063e8e]/8 hover:text-[#063e8e]',
!isOpen && 'justify-center px-0',
? 'bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]'
: 'text-slate-700 hover:bg-white/85 hover:text-[#063e8e]',
isOpen ? 'gap-3 px-4 py-3.5' : 'mx-auto h-14 w-14 justify-center p-0',
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{isOpen ? <span className="ml-3 truncate">{item.name}</span> : null}
{isOpen ? <span className="min-w-0 flex-1">{item.name}</span> : null}
</Link>
);
})}
</nav>
<div className="border-t border-[#063e8e]/12 bg-white/40 px-4 py-4 backdrop-blur-sm">
<div className="px-4 pb-5 pt-3">
{isOpen ? (
<div className="rounded-2xl border border-[#063e8e]/10 bg-white px-4 py-3 shadow-sm">
<div className="rounded-[28px] border border-white/80 bg-white/95 p-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]">
<Link
href="/"
className="flex items-center gap-2 text-sm font-medium text-[#063e8e] hover:underline"
className="flex items-center gap-3 text-sm font-semibold text-[#063e8e] transition hover:opacity-80"
>
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#edf4ff] text-[#063e8e]">
<Globe className="h-4 w-4" />
Về trang chủ
</div>
<div>
<div>Về trang chủ</div>
<div className="mt-0.5 text-xs font-medium text-slate-500">Website công khai</div>
</div>
</Link>
<div className="mt-2 text-xs leading-5 text-gray-700">© 2026 VCCI HCM</div>
<div className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500">
© 2026 VCCI HCM
</div>
</div>
) : (
<Link
href="/"
title="Về trang chủ"
className="flex justify-center rounded-2xl border border-[#063e8e]/10 bg-white py-3 text-[#063e8e] shadow-sm"
className="mx-auto flex h-14 w-14 items-center justify-center rounded-[22px] border border-white/80 bg-white/95 text-[#063e8e] shadow-sm transition hover:bg-white"
>
<Globe className="h-4 w-4" />
<Globe className="h-5 w-5" />
</Link>
)}
</div>
......
......@@ -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