Commit a696ea95 authored by Phạm Quang Bảo's avatar Phạm Quang Bảo

[tag]0.1-vcci

parents dacb81a5 d0f45809
Pipeline #53389 passed with stages
in 6 minutes and 14 seconds
......@@ -10,6 +10,7 @@ const orvalConfig = defineConfig({
target: "src/api/vcci-news/endpoints/index.ts",
schemas: "src/api/vcci-news/models",
client: "react-query",
clean: true,
override: {
query: {
useInfinite: true,
......
......@@ -152,6 +152,9 @@ importers:
react-resizable-panels:
specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
sharp:
specifier: 0.34.5
version: 0.34.5
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
......@@ -4251,8 +4254,7 @@ snapshots:
transitivePeerDependencies:
- encoding
'@img/colour@1.0.0':
optional: true
'@img/colour@1.0.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
......@@ -7466,7 +7468,6 @@ snapshots:
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0:
dependencies:
......
"use client";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules";
import { Swiper as SwiperType } from "swiper/types";
......@@ -84,9 +84,8 @@ function BannerSlideItem({
: "/thumbnail.png";
return (
<SafeImage
<Image
src={url}
fallbackSrc="/thumbnail.png"
alt={alt}
width={2560}
height={720}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
......@@ -48,9 +48,8 @@ function Events() {
className="group flex h-full cursor-pointer flex-col overflow-hidden rounded-[14px] bg-white text-[#20408f] shadow-[0_14px_28px_rgba(10,39,95,0.18)]"
>
<div className="relative h-[180px] overflow-hidden md:h-[220px] xl:h-[248px]">
<SafeImage
src={featuredEvent.thumbnail?.url}
fallbackSrc={featuredFallback}
<Image
src={featuredEvent.thumbnail?.url ?? featuredFallback}
alt={featuredEvent.thumbnail?.alt || featuredEvent.title}
width={720}
height={520}
......@@ -115,9 +114,8 @@ function Events() {
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[14px] bg-white/10 p-2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)] backdrop-blur-sm transition-colors hover:bg-white/14"
>
<div className="h-[64px] w-[64px] shrink-0 overflow-hidden rounded-[14px]">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={sideFallbacks[index]}
<Image
src={item.thumbnail?.url ?? sideFallbacks[index] ?? featuredFallback}
alt={item.thumbnail?.alt || item.title}
width={160}
height={160}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight } from "lucide-react";
......@@ -51,9 +51,8 @@ function FeaturedNews() {
className="group relative block cursor-pointer overflow-hidden rounded-[16px] bg-[#0d2f5f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:rounded-[16px] md:min-h-[320px] lg:min-h-[380px]"
>
<div className="relative h-full min-h-[195px] md:min-h-[320px] lg:min-h-[380px]">
<SafeImage
src={primaryItem.thumbnail?.url}
fallbackSrc={primaryFallback}
<Image
src={primaryItem.thumbnail?.url ?? primaryFallback}
alt={primaryItem.thumbnail?.alt || primaryItem.title}
width={1200}
height={800}
......@@ -98,9 +97,8 @@ function FeaturedNews() {
className="group relative block cursor-pointer overflow-hidden rounded-[16px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] min-h-[165px]"
>
<div className="relative flex h-full min-h-[165px]">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={secondaryFallbacks[index]}
<Image
src={item.thumbnail?.url ?? secondaryFallbacks[index] ?? primaryFallback}
alt={item.thumbnail?.alt || item.title}
width={600}
height={420}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import Link from "next/link";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import links from "@/links";
......@@ -27,9 +27,8 @@ function HorizontalAdBanner() {
style={{ aspectRatio: "1600 / 200" }}
title={title}
>
<SafeImage
<Image
src={src}
fallbackSrc={FALLBACK_SRC}
alt={alt}
fill
sizes="100vw"
......
'use client';
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { ChevronRight } from "lucide-react";
import memberImages from "@/constants/memberImages";
import { MOCK_FEATURED_MEMBERS_RESPONSE } from "@/mockdata/bff-fallback";
......@@ -113,9 +113,8 @@ function Members() {
>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<SafeImage
<Image
src={resolveMemberImage(member.avatar, index)}
fallbackSrc="/img-error.png"
alt={member.name ?? ""}
width={260}
height={180}
......@@ -131,9 +130,8 @@ function Members() {
<>
<div className="flex h-[210px] items-center justify-center overflow-hidden rounded-[14px] bg-white px-4 py-5">
<div className="flex h-full w-full max-w-[260px] items-center justify-center">
<SafeImage
<Image
src={resolveMemberImage(member.avatar, index)}
fallbackSrc="/img-error.png"
alt={member.name ?? ""}
width={260}
height={180}
......@@ -214,9 +212,8 @@ function Members() {
className="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden xl:aspect-[1.25/1]">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={MEMBER_CONNECTION_FALLBACK_IMAGE}
<Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title}
width={520}
height={420}
......@@ -257,9 +254,8 @@ function Members() {
className="group relative block cursor-pointer overflow-hidden rounded-[14px] shadow-[0_16px_32px_rgba(31,59,124,0.12)]"
>
<div className="aspect-[16/10] overflow-hidden">
<SafeImage
src={item.thumbnail?.url}
fallbackSrc={MEMBER_CONNECTION_FALLBACK_IMAGE}
<Image
src={item.thumbnail?.url ?? MEMBER_CONNECTION_FALLBACK_IMAGE}
alt={item.thumbnail?.alt || item.title}
width={520}
height={420}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import Link from "next/link";
......@@ -83,9 +83,8 @@ function News() {
className="group block cursor-pointer overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="relative aspect-[1.4/1] overflow-hidden">
<SafeImage
src={featuredArticle.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
......@@ -152,9 +151,8 @@ function News() {
className="group flex flex-1 cursor-pointer items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
>
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-[14px]">
<SafeImage
src={news.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title}
width={160}
height={160}
......@@ -229,9 +227,8 @@ function News() {
className="group block cursor-pointer overflow-hidden rounded-[16px] border border-[#dbe4f2] bg-white shadow-[0_8px_24px_rgba(31,59,124,0.08)]"
>
<div className="relative aspect-[16/9] overflow-hidden">
<SafeImage
src={featuredArticle.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={featuredArticle.thumbnail?.url ?? "/thumbnail.png"}
alt={featuredArticle.thumbnail?.alt || featuredArticle.title}
width={720}
height={580}
......@@ -295,9 +292,8 @@ function News() {
className="group flex cursor-pointer items-center gap-3 rounded-[16px] border border-[#dbe4f2] bg-white px-4 py-2.5 shadow-[0_8px_24px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(31,59,124,0.12)]"
>
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-[14px]">
<SafeImage
src={news.thumbnail?.url}
fallbackSrc="/thumbnail.png"
<Image
src={news.thumbnail?.url ?? "/thumbnail.png"}
alt={news.thumbnail?.alt || news.title}
width={160}
height={160}
......
'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
......@@ -22,9 +22,8 @@ function AdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: strin
title={item.name}
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<SafeImage
<Image
src={src}
fallbackSrc={fallbackSrc}
alt={item.alt || item.name}
width={2048}
height={1365}
......@@ -45,7 +44,7 @@ function FallbackAdItem({ src }: { src: string }) {
title="Quảng cáo VCCI HCM"
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<SafeImage
<Image
src={src}
alt="Quảng cáo VCCI HCM"
width={2048}
......
"use client";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import partnerImages from "@/constants/partnerImages";
import { ChevronRight, Play } from "lucide-react";
import Link from "next/link";
......@@ -68,9 +68,8 @@ const renderPartnerContent = (partners: Organization[]) => {
className="block"
>
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] transition-all hover:-translate-y-0.5 hover:shadow-[0_14px_24px_rgba(31,59,124,0.1)] xl:h-[151px]">
<SafeImage
<Image
src={resolvePartnerImage(partner.avatar, index)}
fallbackSrc="/img-error.png"
alt={partner.name ?? ""}
width={140}
height={72}
......@@ -80,9 +79,8 @@ const renderPartnerContent = (partners: Organization[]) => {
</a>
) : (
<div className="flex h-[96px] items-center justify-center rounded-[16px] border border-[#edf1f7] bg-white px-5 py-4 shadow-[0_8px_20px_rgba(31,59,124,0.05)] xl:h-[151px]">
<SafeImage
<Image
src={resolvePartnerImage(partner.avatar, index)}
fallbackSrc="/img-error.png"
alt={partner.name ?? ""}
width={140}
height={72}
......@@ -181,7 +179,7 @@ function VideoAndPartners() {
className="overflow-hidden rounded-[16px] border border-[#e5ebf4] bg-white shadow-[0_10px_22px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_16px_30px_rgba(31,59,124,0.12)]"
>
<div className="group relative aspect-[1.95/1] overflow-hidden">
<SafeImage
<Image
src={video.thumbnail}
alt={video.name}
width={640}
......
......@@ -6,7 +6,7 @@ import parse from "html-react-parser";
import Link from "next/link";
import ListCategory from "@/components/base/list-category";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import links from "@/links";
import {
buildDynamicPostHref,
......@@ -338,7 +338,7 @@ export default function AboutVcciHcm({ post, category, allCategories }: AboutVcc
className="group overflow-hidden rounded-[22px] bg-white shadow-[0_18px_38px_rgba(28,52,120,0.16)] transition-transform hover:-translate-y-1"
>
<div className="relative aspect-[1.28] overflow-hidden">
<SafeImage
<Image
src={item.thumbnailUrl}
alt={item.thumbnailAlt}
width={720}
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
......@@ -123,7 +123,7 @@ export default function AnPham({ category, allCategories }: AnPhamProps) {
>
<div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
<div className="relative aspect-3/4 overflow-hidden bg-white">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { FileText, Globe2, Newspaper, TrendingUp } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem } from "../templates/types";
......@@ -266,7 +266,7 @@ export default function MarketProfile({ post, category, allCategories }: MarketP
})}
</div>
<SafeImage
<Image
src={activeRegion.image}
alt={activeRegion.imageAlt}
width={1200}
......
......@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Pagination } from "@components/base/pagination";
import { Button } from "@/components/ui/button";
......@@ -57,7 +57,7 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="overflow-hidden rounded-md bg-[#edf1f5]">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
......@@ -123,7 +123,7 @@ export default function ThuVienTaiLieu({ category, allCategories }: ThuVienTaiLi
>
<div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
<div className="relative aspect-3/4 overflow-hidden bg-white">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -3,7 +3,7 @@
import { Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Play } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Pagination } from "@/components/base/pagination";
import { Spinner } from "@/components/ui/spinner";
import ListCategory from "@/components/base/list-category";
......@@ -87,7 +87,7 @@ function VideoPageContent() {
className="group overflow-hidden rounded-[18px] border border-[#e5ebf4] bg-white shadow-[0_12px_30px_rgba(31,59,124,0.08)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_38px_rgba(31,59,124,0.14)]"
>
<div className="relative aspect-video overflow-hidden bg-[#edf1f5]">
<SafeImage
<Image
src={video.thumbnail}
alt={video.name}
width={900}
......
......@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
......@@ -158,7 +158,7 @@ export default function NewsPage({ category, allCategories }: NewsPageProps) {
className="group grid items-center gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="relative overflow-hidden rounded-md bg-[#edf1f5] aspect-[25/15] sm:aspect-[5/3]">
<SafeImage
<Image
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
......
......@@ -45,7 +45,7 @@ import {
import { PermissionGate } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import links from "@/links";
import { useQueryClient } from "@tanstack/react-query";
......@@ -369,12 +369,11 @@ export function AdvertisementList({
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 112 : 72 }}
>
{imageUrl ? (
<SafeImage
<Image
src={imageUrl}
alt={ad.alt || ad.name}
fill
className="object-cover"
unoptimized={isGif}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
......@@ -514,12 +513,11 @@ export function AdvertisementList({
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 200 : 120 }}
>
{form.filePreviewUrl ? (
<SafeImage
<Image
src={form.filePreviewUrl}
alt="Preview"
fill
className="object-cover"
unoptimized={form.filePreviewUrl.toLowerCase().endsWith(".gif")}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
......
"use client";
import Image from "next/image";
import type { BaseConfigBannerItem } from "@/api/vcci-news/types/base-config";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { SafeImage } from "@/components/shared/safe-image";
import { useGetApiV10FileId } from "@/api/vcci-news/endpoints/file";
import { toAdminMediaItem } from "@/lib/utils/file";
function BannerMedia({ fileId }: { fileId: string }) {
const { data } = useGetApiV10FileId(fileId, {
query: {
enabled: Boolean(fileId),
select: (response) =>
response.responseData ? toAdminMediaItem(response.responseData) : null,
},
});
return data ? (
<Image
src={data.url}
alt={data.alt || data.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>
);
}
export function ConfigItemPreview({
title,
item,
media,
current,
onSelect,
}: {
title: string;
item: BaseConfigBannerItem;
media: AdminMediaItem | null;
current: boolean;
onSelect: () => void;
}) {
......@@ -21,24 +42,10 @@ export function ConfigItemPreview({
<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"
}`}
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 ? (
<SafeImage
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>
)}
<BannerMedia fileId={item.imageId} />
</div>
<div className="space-y-2 px-4 py-3">
<div className="line-clamp-1 text-sm font-semibold text-[#163b73]">
......
import type { Banner, BannerMutate } from "@/api/vcci-news/models";
import type { BaseConfigBannerItem } from "@/mockdata/base-config";
import { createBaseConfigItemId } from "@/mockdata/base-config";
export function mapApiBannerToConfig(banner: Banner): BaseConfigBannerItem {
return {
id: banner.id ?? createBaseConfigItemId("banner"),
name: banner.banner_name ?? "",
imageId: banner.file_id ?? "",
isActive: banner.status !== "INACTIVE",
displayTimeSeconds: banner.display_time ?? 5,
sortOrder: banner.display_order ?? 1,
};
}
export function mapConfigBannerToApi(
banner: BaseConfigBannerItem,
): BannerMutate {
return {
banner_name: banner.name.trim(),
file_id: banner.imageId,
display_order: banner.sortOrder,
display_time: Math.max(1, banner.displayTimeSeconds || 1),
status: banner.isActive ? "ACTIVE" : "INACTIVE",
};
}
This diff is collapsed.
import type { Logo } from "@/api/vcci-news/models";
import type { BaseConfigLogoItem } from "@/mockdata/base-config";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import type { LogoMediaItem } from "../../types";
import links from "@/links";
export function mapApiLogoToConfig(logo: Logo): {
logo: BaseConfigLogoItem;
media: LogoMediaItem;
} {
const media: LogoMediaItem = {
id: logo.file_id,
logoId: logo.id,
name: logo.logo_name,
alt: logo.logo_name,
url: links.resolveImageUrl(logo.logo_url) || "/img-error.png",
mime: "image/*",
size: 0,
created_at: logo.created_at,
updated_at: logo.updated_at,
source: "upload",
};
return {
logo: {
id: logo.id,
name: logo.logo_name,
imageId: logo.file_id,
isActive: true,
},
media,
};
}
export function toLogoMediaItem(logo: Logo): AdminMediaItem {
return mapApiLogoToConfig(logo).media;
}
This diff is collapsed.
......@@ -17,10 +17,7 @@ export function BranchCard({
}) {
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"
}`}
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]">
......@@ -30,7 +27,6 @@ export function BranchCard({
{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"}
......
import type {
SiteInformationBranch,
SiteInformationBranchMutate,
} from "@/api/vcci-news/models";
import type { BaseConfigBranchItem } from "@/mockdata/base-config";
import { createBaseConfigItemId } from "@/mockdata/base-config";
export function mapApiBranchToConfig(
branch: SiteInformationBranch,
): BaseConfigBranchItem {
return {
id: branch.id ?? createBaseConfigItemId("branch"),
branchName: branch.branch_name ?? "",
address: branch.address ?? "",
hotline: branch.hotline ?? branch.telephone ?? "",
email: branch.email ?? "",
fax: branch.fax ?? "",
mapsEmbedUrl: branch.googlemap_link ?? "",
sortOrder: branch.sort_order ?? 1,
isVisible: branch.is_active ?? true,
};
}
export function mapConfigBranchToApi(
branch: BaseConfigBranchItem,
index: number,
): SiteInformationBranchMutate {
return {
branch_name: branch.branchName.trim() || null,
address: branch.address.trim() || null,
hotline: branch.hotline.trim() || null,
email: branch.email.trim() || null,
fax: branch.fax.trim() || null,
googlemap_link: branch.mapsEmbedUrl.trim() || null,
sort_order: Number.isFinite(branch.sortOrder)
? branch.sortOrder
: index + 1,
is_active: branch.isVisible,
};
}
This diff is collapsed.
import type {
SiteInformationSocialLink,
SiteInformationSocialMutate,
} from "@/api/vcci-news/models";
import type { BaseConfigSocialItem } from "@/mockdata/base-config";
export function mapApiSocialToConfig(
social: SiteInformationSocialLink,
): BaseConfigSocialItem {
return {
id: social.id,
label: social.label,
url: social.url ?? "",
isVisible: social.is_active,
sortOrder: social.sort_order,
};
}
export function mapConfigSocialToApi(
social: BaseConfigSocialItem,
): SiteInformationSocialMutate {
return {
url: social.url.trim() || null,
sort_order: social.sortOrder,
is_active: social.isVisible,
};
}
"use client";
import { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
useGetApiV10SiteInformationSocials,
usePatchApiV10SiteInformationSocialsId,
} from "@/api/vcci-news/endpoints/site-information";
import type { SiteInformationSocialLink } from "@/api/vcci-news/models";
import type { BaseConfigSocialItem } from "@/mockdata/base-config";
import {
mapConfigSocialToApi,
mapApiSocialToConfig,
} from "./_components/utils";
import { fieldClassName } from "../types";
export function SocialTab() {
const { data: socialsData, isLoading: isSocialsLoading } =
useGetApiV10SiteInformationSocials();
const { mutateAsync: updateSocial, isPending: isUpdatingSocials } =
usePatchApiV10SiteInformationSocialsId();
const [socials, setSocials] = useState<BaseConfigSocialItem[]>([]);
const socialsResponse = socialsData as
| {
responseData?: SiteInformationSocialLink[];
data?: { responseData?: SiteInformationSocialLink[] };
}
| undefined;
const apiSocials =
socialsResponse?.responseData ?? socialsResponse?.data?.responseData ?? [];
useEffect(() => {
if (apiSocials.length) setSocials(apiSocials.map(mapApiSocialToConfig));
}, [apiSocials]);
const change = <K extends keyof BaseConfigSocialItem>(
id: string,
key: K,
value: BaseConfigSocialItem[K],
) => {
setSocials((previous) =>
previous.map((social) =>
social.id === id ? { ...social, [key]: value } : social,
),
);
};
const save = async () => {
try {
await Promise.all(
socials.map((social) =>
updateSocial({
id: social.id,
data: mapConfigSocialToApi(social),
}),
),
);
toast.success("Đã lưu cấu hình mạng xã hội");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Không thể lưu cấu hình mạng xã hội",
);
}
};
return (
<TabsContent value="social" className="mt-0" aria-busy={isSocialsLoading}>
<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ị.
</CardDescription>
</div>
<Button type="button" onClick={save} disabled={isUpdatingSocials}>
<Save className="mr-2 h-4 w-4" />
{isUpdatingSocials ? "Đang lưu..." : "Lưu cấu hình"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 sm:px-6">
{socials.length === 0 ? (
<div className="py-12 text-center text-sm text-gray-500">
Không có data mạng xã hội
</div>
) : (
socials.map((item) => (
<div
key={item.id}
className="rounded-[28px] border border-[#063e8e]/10 bg-[#f8fbff] p-5"
>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)_180px] lg:items-end">
<div className="flex items-center gap-3 rounded-2xl border bg-white px-4 py-4">
<Checkbox
checked={item.isVisible}
onCheckedChange={(checked) =>
change(item.id, "isVisible", checked === true)
}
/>
<div>
<div className="font-semibold">{item.label}</div>
<div className="text-sm text-slate-500">
{item.isVisible ? "Đang hiển thị" : "Đang ẩn"}
</div>
</div>
</div>
<div className="space-y-2">
<Label>Link URL</Label>
<Input
value={item.url}
onChange={(event) =>
change(item.id, "url", event.target.value)
}
placeholder={`Nhập link ${item.label}...`}
className={fieldClassName}
/>
</div>
<div className="space-y-2">
<Label>Thứ tự hiển thị</Label>
<Input
type="number"
min={1}
value={item.sortOrder}
onChange={(event) =>
change(
item.id,
"sortOrder",
Number(event.target.value || 1),
)
}
className={fieldClassName}
/>
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
);
}
"use client";
import { ImagePlus, Save } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import {
Dialog,
......@@ -15,7 +15,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { fieldClassName, type ConfigItemForm, type ConfigItemMode } from "./types";
import {
fieldClassName,
type ConfigItemForm,
type ConfigItemMode,
} from "./types";
export function ConfigItemDialog({
open,
......@@ -114,7 +118,7 @@ export function ConfigItemDialog({
<div className="overflow-hidden rounded-3xl border border-dashed border-[#063e8e]/20 bg-[#eef4ff]/60">
<div className="relative aspect-[16/9]">
{previewMedia ? (
<SafeImage
<Image
src={previewMedia.url}
alt={previewMedia.alt || previewMedia.name}
fill
......
......@@ -5,13 +5,6 @@ export const fieldClassName =
"rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
export type ConfigItemMode = "logo" | "banner";
export type ApiEnvelope<T> = {
responseData?: T;
data?: {
responseData?: T;
};
};
export type LogoMediaItem = AdminMediaItem & {
logoId?: string;
};
......
import type {
Banner,
BannerMutate,
Logo,
SiteInformationBranch,
SiteInformationBranchMutate,
SiteInformationData,
SiteInformationSocialLink,
SiteInformationSocialMutate,
} from "@/api/vcci-news/models";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import links from "@/links";
import {
type BaseConfigBannerItem,
type BaseConfigBranchItem,
type BaseConfigData,
type BaseConfigLogoItem,
type BaseConfigSocialItem,
createBaseConfigItemId,
} from "@/mockdata/base-config";
import type {
ApiEnvelope,
ConfigItemForm,
LogoMediaItem,
} from "./types";
export function emptyItemForm(): ConfigItemForm {
return {
name: "",
imageId: "",
isActive: true,
displayTimeSeconds: 5,
sortOrder: 1,
};
}
export function resolveMediaItem(
mediaMap: Map<string, AdminMediaItem>,
imageId: string,
) {
return mediaMap.get(imageId) ?? null;
}
export function getEnvelopeData<T>(payload: unknown): T | undefined {
const root = payload as ApiEnvelope<T>;
return root.responseData ?? root.data?.responseData;
}
export function mapApiBranchToConfig(
branch: SiteInformationBranch,
): BaseConfigBranchItem {
return {
id: branch.id ?? createBaseConfigItemId("branch"),
branchName: branch.branch_name ?? "",
address: branch.address ?? "",
hotline: branch.hotline ?? branch.telephone ?? "",
email: branch.email ?? "",
fax: branch.fax ?? "",
mapsEmbedUrl: branch.googlemap_link ?? "",
sortOrder: branch.sort_order ?? 1,
isVisible: branch.is_active ?? true,
};
}
export function mapConfigBranchToApi(
branch: BaseConfigBranchItem,
index: number,
): SiteInformationBranchMutate {
return {
branch_name: branch.branchName.trim() || null,
address: branch.address.trim() || null,
hotline: branch.hotline.trim() || null,
email: branch.email.trim() || null,
fax: branch.fax.trim() || null,
googlemap_link: branch.mapsEmbedUrl.trim() || null,
sort_order: Number.isFinite(branch.sortOrder)
? branch.sortOrder
: index + 1,
is_active: branch.isVisible,
};
}
export function mapApiSocialToConfig(
social: SiteInformationSocialLink,
): BaseConfigSocialItem {
return {
id: social.id,
label: social.label,
url: social.url ?? "",
isVisible: social.is_active,
sortOrder: social.sort_order,
};
}
export function mapConfigSocialToApi(
social: BaseConfigSocialItem,
): SiteInformationSocialMutate {
return {
url: social.url.trim() || null,
sort_order: social.sortOrder,
is_active: social.isVisible,
};
}
export function mapApiBannerToConfig(banner: Banner): BaseConfigBannerItem {
return {
id: banner.id ?? createBaseConfigItemId("banner"),
name: banner.banner_name ?? "",
imageId: banner.file_id ?? "",
isActive: banner.status !== "INACTIVE",
displayTimeSeconds: banner.display_time ?? 5,
sortOrder: banner.display_order ?? 1,
};
}
export function mapConfigBannerToApi(banner: BaseConfigBannerItem): BannerMutate {
return {
banner_name: banner.name.trim(),
file_id: banner.imageId,
display_order: banner.sortOrder,
display_time: Math.max(1, banner.displayTimeSeconds || 1),
status: banner.isActive ? "ACTIVE" : "INACTIVE",
};
}
export function mapApiLogoToConfig(logo: Logo): {
logo: BaseConfigLogoItem | null;
media: LogoMediaItem | null;
} | null {
const media: LogoMediaItem = {
id: logo.file_id,
logoId: logo.id,
name: logo.logo_name,
alt: logo.logo_name,
url: links.resolveImageUrl(logo.logo_url) || "/img-error.png",
mime: "image/*",
size: 0,
created_at: logo.created_at,
updated_at: logo.updated_at,
source: "upload",
};
return {
logo: {
id: logo.id,
name: logo.logo_name,
imageId: logo.file_id,
isActive: true,
},
media,
};
}
export function applySiteInformationToConfig(
baseConfig: BaseConfigData,
siteInformation: SiteInformationData,
logo?: Logo | null,
): BaseConfigData {
const logoConfig = logo ? mapApiLogoToConfig(logo) : null;
return {
...baseConfig,
logo: logoConfig?.logo ?? baseConfig.logo,
websiteName: siteInformation.website_name ?? baseConfig.websiteName,
websiteLink: siteInformation.website_link ?? baseConfig.websiteLink,
branches: Array.isArray(siteInformation.branches)
? siteInformation.branches.map(mapApiBranchToConfig)
: baseConfig.branches,
socials: Array.isArray(siteInformation.socials)
? siteInformation.socials.map(mapApiSocialToConfig)
: baseConfig.socials,
};
}
This diff is collapsed.
......@@ -2,7 +2,6 @@
import * as React from "react";
import dayjs from "dayjs";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
......@@ -24,12 +23,15 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useQueryClient } from "@tanstack/react-query";
import { useGetApiV10Contact, useDeleteApiV10ContactId } from "@/api/vcci-news/endpoints/contact";
import {
CONTACT_PURPOSE_OPTIONS,
type ContactRequestItem,
persistContactRequests,
readContactRequests,
} from "@/mockdata/contact-management";
type AdminContactRequestRow,
extractRows,
mapApiRowToAdminContactRequest,
} from "@/lib/utils/admin-contact";
import { useMemo, useState } from "react";
const selectTriggerClassName =
"w-full rounded-xl border-[#063e8e]/15 bg-white text-gray-700 data-[placeholder]:text-gray-700 focus:ring-[#063e8e]/30 lg:w-[220px]";
......@@ -38,23 +40,35 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName = "text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm");
}
export default function AdminContactRequestsPage() {
const [items, setItems] = React.useState<ContactRequestItem[]>([]);
const [search, setSearch] = React.useState("");
const [purposeFilter, setPurposeFilter] = React.useState("all");
const [ready, setReady] = React.useState(false);
const [detailTarget, setDetailTarget] = React.useState<ContactRequestItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ContactRequestItem | null>(null);
const queryClient = useQueryClient();
React.useEffect(() => {
setItems(readContactRequests());
setReady(true);
}, []);
const [search, setSearch] = useState("");
const [purposeFilter, setPurposeFilter] = useState("all");
const [detailTarget, setDetailTarget] = useState<AdminContactRequestRow | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminContactRequestRow | null>(null);
const filteredItems = React.useMemo(() => {
const contactsQuery = useGetApiV10Contact({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const deleteContact = useDeleteApiV10ContactId();
const items = useMemo(
() =>
extractRows((contactsQuery.data as any)?.responseData).map(
mapApiRowToAdminContactRequest,
),
[contactsQuery.data],
);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
return items.filter((item) => {
......@@ -74,14 +88,20 @@ export default function AdminContactRequestsPage() {
});
}, [items, purposeFilter, search]);
const handleDelete = () => {
const handleDelete = async () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistContactRequests(nextItems);
try {
await deleteContact.mutateAsync({ id: deleteTarget.id });
toast.success("Đã xóa đơn liên hệ");
queryClient.invalidateQueries({ queryKey: ["getApiV10Contact"] });
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Không thể xóa đơn liên hệ";
toast.error(message);
} finally {
setDeleteTarget(null);
}
};
return (
......@@ -127,7 +147,7 @@ export default function AdminContactRequestsPage() {
</TableRow>
</TableHeader>
<TableBody>
{!ready ? (
{contactsQuery.isLoading ? (
Array.from({ length: 4 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell colSpan={7} className="px-4 py-4">
......@@ -152,25 +172,25 @@ export default function AdminContactRequestsPage() {
</TableCell>
<TableCell className="py-3 text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.purpose}
{item.purpose || "—"}
</Badge>
</TableCell>
<TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1">
<div className="font-semibold">{item.contactName}</div>
<div className="text-gray-600">{item.contactPhone}</div>
<div className="font-semibold">{item.contactName || "—"}</div>
<div className="text-gray-600">{item.contactPhone || "—"}</div>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1">
<div className="font-medium text-gray-800">{item.organizationName}</div>
<div>{item.businessField}</div>
<div className="font-medium text-gray-800">{item.organizationName || "—"}</div>
<div>{item.businessField || "—"}</div>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1">
<div>{item.email}</div>
<div className="text-gray-500">{item.contactEmail}</div>
<div>{item.email || "—"}</div>
<div className="text-gray-500">{item.contactEmail || "—"}</div>
</div>
</TableCell>
<TableCell className="py-3 text-center text-sm text-gray-700">
......@@ -199,7 +219,7 @@ export default function AdminContactRequestsPage() {
badge={
detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.purpose}
{detailTarget.purpose || "—"}
</Badge>
) : null
}
......@@ -209,27 +229,27 @@ export default function AdminContactRequestsPage() {
{
title: "Thông tin chung",
fields: [
{ label: "Mục đích liên hệ", value: detailTarget.purpose },
{ label: "Mục đích liên hệ", value: detailTarget.purpose || "—" },
{ label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
],
},
{
title: "Người liên hệ",
fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName },
{ label: "Chức vụ", value: detailTarget.contactPosition },
{ label: "Email người liên hệ", value: detailTarget.contactEmail },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone },
{ label: "Nội dung liên hệ", value: detailTarget.message, fullWidth: true },
{ label: "Họ tên người liên hệ", value: detailTarget.contactName || "—" },
{ label: "Chức vụ", value: detailTarget.contactPosition || "—" },
{ label: "Email người liên hệ", value: detailTarget.contactEmail || "—" },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone || "—" },
{ label: "Nội dung liên hệ", value: detailTarget.message || "—", fullWidth: true },
],
},
{
title: "Thông tin công ty / tổ chức",
fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField },
{ label: "Email", value: detailTarget.email },
{ label: "Website", value: detailTarget.website },
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName || "—" },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField || "—" },
{ label: "Email", value: detailTarget.email || "—" },
{ label: "Website", value: detailTarget.website || "—" },
],
},
]
......
......@@ -2,7 +2,6 @@
import * as React from "react";
import dayjs from "dayjs";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
......@@ -17,29 +16,72 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
type MembershipApplicationItem,
persistMembershipApplications,
readMembershipApplications,
} from "@/mockdata/contact-management";
import { useQueryClient } from "@tanstack/react-query";
import { useGetApiV10Contact, useDeleteApiV10ContactId } from "@/api/vcci-news/endpoints/contact";
import { extractRows } from "@/lib/utils/admin-contact";
import { useMemo, useState } from "react";
interface AdminMembershipApplicationRow {
id: string;
organizationName: string;
businessField: string;
address: string;
website: string;
contactName: string;
contactPosition: string;
contactEmail: string;
contactPhone: string;
membershipType: string;
note: string;
submittedAt: string;
}
function mapApiRowToMembershipApplication(item: any): AdminMembershipApplicationRow {
return {
id: String(item?.id ?? ""),
organizationName: item?.fullname ?? "",
businessField: "",
address: "",
website: "",
contactName: item?.fullname ?? "",
contactPosition: "",
contactEmail: item?.email ?? "",
contactPhone: item?.phone ?? "",
membershipType: item?.title ?? "",
note: item?.content ?? "",
submittedAt: item?.created_at ?? "",
};
}
function formatDateTime(value: string) {
if (!value) return "—";
return dayjs(value).format("DD/MM/YYYY HH:mm");
}
export default function AdminMembershipApplicationsPage() {
const [items, setItems] = React.useState<MembershipApplicationItem[]>([]);
const [search, setSearch] = React.useState("");
const [ready, setReady] = React.useState(false);
const [detailTarget, setDetailTarget] = React.useState<MembershipApplicationItem | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<MembershipApplicationItem | null>(null);
const queryClient = useQueryClient();
React.useEffect(() => {
setItems(readMembershipApplications());
setReady(true);
}, []);
const [search, setSearch] = useState("");
const [detailTarget, setDetailTarget] = useState<AdminMembershipApplicationRow | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminMembershipApplicationRow | null>(null);
const filteredItems = React.useMemo(() => {
const contactsQuery = useGetApiV10Contact({
page: 1,
pageSize: 200,
sortField: "created_at",
sortOrder: "desc",
});
const deleteContact = useDeleteApiV10ContactId();
const items = useMemo(
() =>
extractRows((contactsQuery.data as any)?.responseData).map(
mapApiRowToMembershipApplication,
),
[contactsQuery.data],
);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
......@@ -54,19 +96,24 @@ export default function AdminMembershipApplicationsPage() {
);
}, [items, search]);
const handleDelete = () => {
const handleDelete = async () => {
if (!deleteTarget) return;
const nextItems = items.filter((item) => item.id !== deleteTarget.id);
setItems(nextItems);
persistMembershipApplications(nextItems);
try {
await deleteContact.mutateAsync({ id: deleteTarget.id });
toast.success("Đã xóa đơn đăng ký hội viên");
queryClient.invalidateQueries({ queryKey: ["getApiV10Contact"] });
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Không thể xóa đơn đăng ký hội viên";
toast.error(message);
} finally {
setDeleteTarget(null);
}
};
return (
<div className="space-y-8">
<AdminTableLayout
searchValue={search}
searchPlaceholder="Tìm kiếm đơn đăng ký hội viên..."
......@@ -91,7 +138,7 @@ export default function AdminMembershipApplicationsPage() {
</TableRow>
</TableHeader>
<TableBody>
{!ready ? (
{contactsQuery.isLoading ? (
Array.from({ length: 3 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell colSpan={7} className="px-4 py-4">
......@@ -116,22 +163,22 @@ export default function AdminMembershipApplicationsPage() {
</TableCell>
<TableCell className="py-3 text-sm text-gray-800">
<div className="space-y-1">
<div className="font-semibold">{item.organizationName}</div>
<div className="text-gray-600">{item.businessField}</div>
<div className="font-semibold">{item.organizationName || "—"}</div>
<div className="text-gray-600">{item.businessField || "—"}</div>
</div>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">
<div className="space-y-1">
<div className="font-medium text-gray-800">{item.contactName}</div>
<div>{item.contactPhone}</div>
<div className="font-medium text-gray-800">{item.contactName || "—"}</div>
<div>{item.contactPhone || "—"}</div>
</div>
</TableCell>
<TableCell className="py-3 text-center">
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{item.membershipType}
{item.membershipType || "—"}
</Badge>
</TableCell>
<TableCell className="py-3 text-sm text-gray-700">{item.contactEmail}</TableCell>
<TableCell className="py-3 text-sm text-gray-700">{item.contactEmail || "—"}</TableCell>
<TableCell className="py-3 text-center text-sm text-gray-700">
{formatDateTime(item.submittedAt)}
</TableCell>
......@@ -158,11 +205,11 @@ export default function AdminMembershipApplicationsPage() {
<ContactManagementDetailDialog
open={!!detailTarget}
title="Chi tiết đơn đăng ký hội viên"
description="Biểu mẫu mẫu phục vụ duyệt giao diện quản trị đơn đăng ký hội viên."
description="Thông tin đầy đủ của đơn đăng ký hội viên được gửi từ website VCCI News."
badge={
detailTarget ? (
<Badge variant="outline" className="border-[#063e8e]/25 text-[#063e8e]">
{detailTarget.membershipType}
{detailTarget.membershipType || "—"}
</Badge>
) : null
}
......@@ -172,27 +219,27 @@ export default function AdminMembershipApplicationsPage() {
{
title: "Thông tin chung",
fields: [
{ label: "Loại hội viên", value: detailTarget.membershipType },
{ label: "Loại hội viên", value: detailTarget.membershipType || "—" },
{ label: "Ngày gửi", value: formatDateTime(detailTarget.submittedAt) },
],
},
{
title: "Thông tin doanh nghiệp",
fields: [
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField },
{ label: "Địa chỉ", value: detailTarget.address, fullWidth: true },
{ label: "Website", value: detailTarget.website },
{ label: "Tên công ty / tổ chức", value: detailTarget.organizationName || "—" },
{ label: "Lĩnh vực hoạt động", value: detailTarget.businessField || "—" },
{ label: "Địa chỉ", value: detailTarget.address || "—", fullWidth: true },
{ label: "Website", value: detailTarget.website || "—" },
],
},
{
title: "Thông tin người liên hệ",
fields: [
{ label: "Họ tên người liên hệ", value: detailTarget.contactName },
{ label: "Chức vụ", value: detailTarget.contactPosition },
{ label: "Email người liên hệ", value: detailTarget.contactEmail },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone },
{ label: "Ghi chú", value: detailTarget.note, fullWidth: true },
{ label: "Họ tên người liên hệ", value: detailTarget.contactName || "—" },
{ label: "Chức vụ", value: detailTarget.contactPosition || "—" },
{ label: "Email người liên hệ", value: detailTarget.contactEmail || "—" },
{ label: "Điện thoại người liên hệ", value: detailTarget.contactPhone || "—" },
{ label: "Ghi chú", value: detailTarget.note || "—", fullWidth: true },
],
},
]
......
This diff is collapsed.
......@@ -15,7 +15,7 @@ import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { AdminStatsGrid } from "@/components/admin/admin-stats-grid";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { Pagination } from "@/components/base/pagination";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
......@@ -26,10 +26,15 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getApiV10Post } from "@/api/vcci-news/endpoints/post";
import { deleteApiV10PostId } from "@/api/vcci-news/endpoints/post";
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import { ADMIN_NEWS_TYPE_LABELS, type AdminNewsItem } from "@/mockdata/admin-news";
import {
useDeleteApiV10PostId,
useGetApiV10Post,
} from "@/api/vcci-news/endpoints/post";
import { useGetApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import {
ADMIN_NEWS_TYPE_LABELS,
type AdminNewsItem,
} from "@/api/vcci-news/types/post";
import { normalizeUser } from "@/lib/utils/cms-user";
import { normalizeDateTimeInput } from "@/lib/utils/datetime";
import { parsePostContent, parseLegacyPostContent } from "@/lib/utils/post-content";
......@@ -48,64 +53,37 @@ export default function HeaderCategoryPostsPage() {
const pathname = usePathname();
const searchParams = useSearchParams();
const categoryId = String(params.categoryId ?? "");
const [items, setItems] = useState<AdminNewsItem[]>([]);
const [headerTree, setHeaderTree] = useState<HeaderCategoryTreeItem[]>([]);
const { data: treeResponse, isLoading: isTreeLoading } = useGetApiV10CategoryTree();
const { mutateAsync: deletePost, isPending: isDeletingPost } = useDeleteApiV10PostId();
const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
const [ready, setReady] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AdminNewsItem | null>(null);
const didMountRef = useRef(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => {
const parsedPage = Number(searchParams.get("page") ?? 1);
return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1;
});
const listQueryString = useMemo(() => {
const nextParams = new URLSearchParams();
if (page > 1) {
nextParams.set("page", String(page));
}
if (search.trim()) {
nextParams.set("q", search.trim());
}
return nextParams.toString();
}, [page, search]);
const listPath = useMemo(
() => (listQueryString ? `${pathname}?${listQueryString}` : pathname),
[listQueryString, pathname],
);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const apiFilters = useMemo(() => {
const keyword = search.trim();
const filters = [
`category.id==${categoryId}`,
keyword ? `title@=${keyword}|slug@=${keyword}` : "",
].filter(Boolean).join(",");
return filters.trim() || undefined;
}, [categoryId, search]);
const [newsResponse, treeResponse] = await Promise.all([
getApiV10Post({
const postsQuery = useGetApiV10Post({
page,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
filters: filters.trim() || undefined,
}),
getApiV10CategoryTree(),
]);
if (cancelled) return;
filters: apiFilters,
});
const newsResult = (newsResponse.responseData ?? {}) as { rows?: any[]; count?: number };
const tree = (treeResponse.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
setItems((newsResult.rows ?? []).map((item: any): AdminNewsItem => {
const postsResponse = (postsQuery.data ?? {}) as { responseData?: { rows?: any[]; count?: number } };
const items: AdminNewsItem[] = useMemo(
() =>
(postsResponse.responseData?.rows ?? []).map((item: any): AdminNewsItem => {
const structuredContent = parsePostContent(item.content_structure);
const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(item.content);
const categories = Array.isArray(item.categories) ? item.categories : [];
......@@ -142,23 +120,35 @@ export default function HeaderCategoryPostsPage() {
creator: normalizeUser(item.creator),
editor: normalizeUser(item.editor),
};
}));
setTotal(newsResult.count ?? 0);
setHeaderTree(tree);
setReady(true);
} catch (error) {
if (cancelled) return;
toast.error(error instanceof Error ? error.message : "Không thể tải dữ liệu");
setReady(true);
}),
[postsResponse.responseData],
);
const total = postsResponse.responseData?.count ?? 0;
const headerTree: HeaderCategoryTreeItem[] = useMemo(
() => (treeResponse?.responseData ?? []) as unknown as HeaderCategoryTreeItem[],
[treeResponse],
);
const ready = !isTreeLoading && !postsQuery.isLoading;
const listQueryString = useMemo(() => {
const nextParams = new URLSearchParams();
if (page > 1) {
nextParams.set("page", String(page));
}
};
void load();
if (search.trim()) {
nextParams.set("q", search.trim());
}
return () => {
cancelled = true;
};
}, [categoryId, page, search]);
return nextParams.toString();
}, [page, search]);
const listPath = useMemo(
() => (listQueryString ? `${pathname}?${listQueryString}` : pathname),
[listQueryString, pathname],
);
const flatCategories = useMemo(() => {
return flattenTree(headerTree);
......@@ -229,14 +219,13 @@ export default function HeaderCategoryPostsPage() {
}, [items, total]);
const handleDelete = async () => {
if (!deleteTarget) return;
if (!deleteTarget || isDeletingPost) return;
try {
await deleteApiV10PostId(deleteTarget.id);
setItems((current) => current.filter((item) => item.id !== deleteTarget.id));
setTotal((current) => Math.max(0, current - 1));
await deletePost({ id: deleteTarget.id });
toast.success("Đã xóa bài viết");
setDeleteTarget(null);
postsQuery.refetch();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể xóa bài viết");
}
......@@ -354,7 +343,7 @@ export default function HeaderCategoryPostsPage() {
<TableCell className="text-center">
<div className="relative mx-auto h-16 w-24 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#063e8e]/[0.03]">
{item.thumbnail ? (
<SafeImage
<Image
src={item.thumbnail.url}
alt={item.thumbnail.alt || item.thumbnail.name}
fill
......
......@@ -20,11 +20,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type HeaderCategoryTreeItem,
type HeaderCategoryType,
toSlug,
} from "@/mockdata/header-config";
import { toSlug } from "@/lib/utils/header-category";
import type {
HeaderCategoryTreeItem,
HeaderCategoryType,
} from "@/api/vcci-news/types/header-config";
export type HeaderCategoryFormMode = "create" | "edit";
......
......@@ -20,10 +20,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
type HeaderCategoryTreeItem,
getHeaderCategoryTypeLabel,
} from "@/mockdata/header-config";
import { getHeaderCategoryTypeLabel } from "@/lib/utils/header-category";
import type { HeaderCategoryTreeItem } from "@/api/vcci-news/types/header-config";
export type HeaderCategoryFlatRow = HeaderCategoryTreeItem & {
depth: number;
......
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
useDeleteApiV10CategoryId,
useGetApiV10CategoryTree,
usePostApiV10Category,
usePutApiV10CategoryId,
} from "@/api/vcci-news/endpoints/category";
import { toast } from "sonner";
import {
HeaderCategoryDeleteDialog,
......@@ -12,17 +18,11 @@ import {
HeaderCategoryTable,
} from "./components";
import { buildStaticLink } from "@/lib/utils/header-link";
import {
deleteApiV10CategoryId,
getApiV10CategoryTree,
postApiV10Category,
putApiV10CategoryId,
} from "@/api/vcci-news/endpoints/category";
import {
import { toSlug } from "@/lib/utils/header-category";
import type {
HeaderCategoryItem,
HeaderCategoryTreeItem,
toSlug,
} from "@/mockdata/header-config";
} from "@/api/vcci-news/types/header-config";
const EMPTY_HEADER_CATEGORY_FORM: HeaderCategoryFormValues = {
name: "",
......@@ -54,36 +54,41 @@ function toFormValues(item?: HeaderCategoryItem | null): HeaderCategoryFormValue
}
function useHeaderConfigModule() {
const {
data: treeResponse,
isLoading,
error,
refetch: refetchTree,
} = useGetApiV10CategoryTree();
const [tree, setTree] = useState<HeaderCategoryTreeItem[]>([]);
const [rootStaticLink, setRootStaticLink] = useState("/");
const [isReady, setIsReady] = useState(false);
const load = useCallback(async () => {
const response = await getApiV10CategoryTree();
const treeData = (response.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
useEffect(() => {
const treeData = (treeResponse?.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
setTree(treeData);
setRootStaticLink("/");
setIsReady(true);
}, []);
}, [treeResponse]);
useEffect(() => {
void load().catch((error) => {
if (error) {
toast.error(error instanceof Error ? error.message : "Không thể tải cấu hình danh mục");
setIsReady(true);
});
}, [load]);
}
}, [error]);
return {
tree,
rootStaticLink,
isReady,
reload: load,
isReady: !isLoading,
reload: refetchTree,
};
}
export default function HeaderConfigPage() {
const { tree, rootStaticLink, isReady, reload } = useHeaderConfigModule();
const { mutateAsync: createCategory, isPending: isCreatingCategory } = usePostApiV10Category();
const { mutateAsync: updateCategory, isPending: isUpdatingCategory } = usePutApiV10CategoryId();
const { mutateAsync: deleteCategory, isPending: isDeletingCategory } = useDeleteApiV10CategoryId();
const isMutating = isCreatingCategory || isUpdatingCategory || isDeletingCategory;
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
......@@ -221,7 +226,7 @@ export default function HeaderConfigPage() {
};
const handleSubmit = async () => {
if (isSubmitting) return;
if (isSubmitting || isMutating) return;
if (isProtectedHomeCategory(formValues.id)) {
return;
......@@ -277,10 +282,10 @@ export default function HeaderConfigPage() {
};
if (formMode === "create") {
await postApiV10Category(payload);
await createCategory({ data: payload });
toast.success("Tạo danh mục thành công");
} else if (formValues.id) {
await putApiV10CategoryId(formValues.id, payload);
await updateCategory({ id: formValues.id, data: payload });
toast.success("Cập nhật danh mục thành công");
}
......@@ -295,7 +300,7 @@ export default function HeaderConfigPage() {
};
const handleDelete = async () => {
if (!deleteTarget || isSubmitting) return;
if (!deleteTarget || isSubmitting || isMutating) return;
if (isProtectedHomeCategory(deleteTarget.id)) {
setDeleteTarget(null);
......@@ -305,7 +310,7 @@ export default function HeaderConfigPage() {
setIsSubmitting(true);
try {
await deleteApiV10CategoryId(deleteTarget.id);
await deleteCategory({ id: deleteTarget.id });
toast.success("Xóa danh mục thành công");
setDeleteTarget(null);
await reload();
......
......@@ -8,7 +8,7 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import {
Dialog,
......@@ -103,7 +103,7 @@ export function MediaFormDialog({
<div className="overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]">
<div className="relative aspect-[16/10] bg-[radial-gradient(circle_at_top,#d9e8ff_0%,#f7faff_58%,#ffffff_100%)]">
{form.previewUrl ? (
<SafeImage
<Image
src={form.previewUrl}
alt={form.name}
fill
......
......@@ -9,7 +9,7 @@ import {
import { toast } from "sonner";
import { AdminDeleteDialog } from "@/components/admin/admin-delete-dialog";
import { AdminTableLayout } from "@/components/admin/admin-table-layout";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Pagination } from "@/components/base/pagination";
import { Button } from "@/components/ui/button";
import {
......@@ -161,7 +161,7 @@ export default function AdminMediaPage() {
className="group overflow-hidden rounded-[28px] border border-[#063e8e]/10 bg-white shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition duration-300 hover:-translate-y-1 hover:shadow-[0_28px_60px_rgba(6,62,142,0.14)]"
>
<div className="relative aspect-square overflow-hidden bg-[radial-gradient(circle_at_top,#dce9ff_0%,#f8fbff_55%,#ffffff_100%)]">
<SafeImage
<Image
src={resolveCmsFileUrl(item.path)}
alt={item.original ?? ""}
fill
......
......@@ -7,7 +7,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { MemberField, MemberRegion } from "@/api/vcci-news/types/member";
import {
selectContentClassName,
selectItemClassName,
......@@ -15,8 +14,8 @@ import {
} from "./constants";
interface MemberFiltersProps {
fields: MemberField[];
regions: MemberRegion[];
fields: { id: string; name: string }[];
regions: { id: string; name: string }[];
fieldFilter: string;
regionFilter: string;
onFieldFilterChange: (value: string) => void;
......@@ -35,11 +34,11 @@ export function MemberFilters({
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<Select value={fieldFilter} onValueChange={onFieldFilterChange}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Lĩnh vực" />
<SelectValue placeholder="Doanh nghiệp" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả lĩnh vực
Tất cả doanh nghiệp
</SelectItem>
{fields.map((f) => (
<SelectItem key={f.id} value={f.id} className={selectItemClassName}>
......@@ -51,11 +50,11 @@ export function MemberFilters({
<Select value={regionFilter} onValueChange={onRegionFilterChange}>
<SelectTrigger className={selectTriggerClassName}>
<SelectValue placeholder="Khu vực" />
<SelectValue placeholder="Chức vụ" />
</SelectTrigger>
<SelectContent className={selectContentClassName}>
<SelectItem value="all" className={selectItemClassName}>
Tất cả khu vực
Tất cả chức vụ
</SelectItem>
{regions.map((r) => (
<SelectItem key={r.id} value={r.id} className={selectItemClassName}>
......
"use client";
import { AdminRowActions } from "@/components/admin/admin-row-actions";
import { SafeImage } from "@/components/shared/safe-image";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import {
TableCell,
TableRow,
} from "@/components/ui/table";
import type { MemberItem } from "@/api/vcci-news/types/member";
import type { AdminMemberRow } from "@/lib/utils/admin-member";
interface MemberRowProps {
item: MemberItem;
item: AdminMemberRow;
index: number;
fieldName: string | undefined;
regionName: string | undefined;
......@@ -33,30 +33,30 @@ export function MemberRow({
>
<TableCell className="px-4 py-3 text-sm font-medium text-gray-800">
<div className="space-y-1">
<div>{item.name}</div>
{item.is_featured ? (
<div>{item.full_name}</div>
{item.job_title ? (
<Badge
variant="outline"
className="border-[#063e8e]/25 bg-[#063e8e]/[0.04] text-[#063e8e]"
>
Hội viên tiêu biểu
{item.job_title}
</Badge>
) : null}
</div>
</TableCell>
<TableCell className="px-4 py-3 text-center">
{item.image ? (
<div className="mx-auto h-12 w-16 overflow-hidden rounded-lg border border-[#063e8e]/15">
<SafeImage
src={item.image.url}
alt={item.image.alt || item.name}
width={64}
{item.avatar_url ? (
<div className="mx-auto h-12 w-12 overflow-hidden rounded-full border border-[#063e8e]/15">
<Image
src={item.avatar_url}
alt={item.full_name}
width={48}
height={48}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="mx-auto flex h-12 w-16 items-center justify-center rounded-lg border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full border border-dashed border-[#063e8e]/20 bg-[#063e8e]/5 text-xs text-gray-400">
Chưa có
</div>
)}
......@@ -68,13 +68,10 @@ export function MemberRow({
{fieldName ?? "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
{item.phone && <div>{item.phone}</div>}
{item.email && (
<div className="truncate text-xs text-[#063e8e]">{item.email}</div>
)}
<span className="line-clamp-2">{item.job_title || "—"}</span>
</TableCell>
<TableCell className="px-4 py-3 text-center text-sm text-gray-600">
<span className="line-clamp-2">{item.address || "—"}</span>
{item.birth_date || "—"}
</TableCell>
<TableCell className="px-4 py-3 text-center">
<AdminRowActions
......
......@@ -12,19 +12,18 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MemberField } from "@/api/vcci-news/types/member";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface FieldFormDialogProps {
open: boolean;
initial: MemberField | null;
initial: { id: string; name: string } | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
saving?: boolean;
}
export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFormDialogProps) {
export function FieldFormDialog({ open, initial, onOpenChange, onSave, saving }: FieldFormDialogProps) {
const [name, setName] = useState("");
useEffect(() => {
......@@ -34,7 +33,7 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên lĩnh vực");
toast.error("Vui lòng nhập tên doanh nghiệp");
return;
}
onSave({ id: initial?.id, name: trimmed });
......@@ -45,20 +44,21 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa lĩnh vực" : "Thêm lĩnh vực mới"}
{initial ? "Chỉnh sửa doanh nghiệp" : "Thêm doanh nghiệp mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên lĩnh vực <span className="text-red-500">*</span>
Tên doanh nghiệp <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên lĩnh vực..."
placeholder="Nhập tên doanh nghiệp..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
onKeyDown={(event) => event.key === "Enter" && !saving && handleSave()}
disabled={saving}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
......@@ -75,9 +75,10 @@ export function FieldFormDialog({ open, initial, onOpenChange, onSave }: FieldFo
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
disabled={saving}
>
<Save className="mr-2 h-4 w-4" />
Lưu
{saving ? "Đang lưu..." : "Lưu"}
</Button>
</div>
</div>
......
This diff is collapsed.
This diff is collapsed.
......@@ -12,19 +12,18 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MemberRegion } from "@/api/vcci-news/types/member";
const fieldClassName =
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30";
interface RegionFormDialogProps {
open: boolean;
initial: MemberRegion | null;
initial: { id: string; name: string } | null;
onOpenChange: (open: boolean) => void;
onSave: (data: { id?: string; name: string }) => void;
saving?: boolean;
}
export function RegionFormDialog({ open, initial, onOpenChange, onSave }: RegionFormDialogProps) {
export function RegionFormDialog({ open, initial, onOpenChange, onSave, saving }: RegionFormDialogProps) {
const [name, setName] = useState("");
useEffect(() => {
......@@ -34,7 +33,7 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
const handleSave = () => {
const trimmed = name.trim();
if (!trimmed) {
toast.error("Vui lòng nhập tên khu vực");
toast.error("Vui lòng nhập tên chức vụ");
return;
}
onSave({ id: initial?.id, name: trimmed });
......@@ -45,20 +44,21 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
<DialogContent className="max-w-md border-[#063e8e]/15 bg-white">
<DialogHeader>
<DialogTitle className="text-[#063e8e]">
{initial ? "Chỉnh sửa khu vực" : "Thêm khu vực mới"}
{initial ? "Chỉnh sửa chức vụ" : "Thêm chức vụ mới"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label className="text-gray-700">
Tên khu vực <span className="text-red-500">*</span>
Tên chức vụ <span className="text-red-500">*</span>
</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nhập tên khu vực..."
placeholder="Nhập tên chức vụ..."
className={fieldClassName}
onKeyDown={(event) => event.key === "Enter" && handleSave()}
onKeyDown={(event) => event.key === "Enter" && !saving && handleSave()}
disabled={saving}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
......@@ -75,9 +75,10 @@ export function RegionFormDialog({ open, initial, onOpenChange, onSave }: Region
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={handleSave}
disabled={saving}
>
<Save className="mr-2 h-4 w-4" />
Lưu
{saving ? "Đang lưu..." : "Lưu"}
</Button>
</div>
</div>
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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