feat: 사이트 반영 폼 작업

This commit is contained in:
2025-04-03 17:37:35 +09:00
parent e3454e11d5
commit e659f47d1d
15 changed files with 304 additions and 51 deletions
@@ -5,7 +5,7 @@ import type {
PostService,
PostingParams,
Post,
WithModePostData,
WithModePost,
} from "../model/posting-interface";
export class PostingApiService implements PostService {
@@ -19,7 +19,7 @@ export class PostingApiService implements PostService {
return response.data;
}
public async requestPosting(data: WithModePostData[]): Promise<Post[]> {
public async requestPosting(data: WithModePost[]): Promise<Post[]> {
const response = await client.post<Post[]>(
`${ENDPOINT.CMS_SERVICE.SITE_PSTGS}`,
data,
+7
View File
@@ -0,0 +1,7 @@
export { PostingApi } from "./api/posting-service";
export {
POSTING_TYPE,
type Post,
type WithModePost,
} from "./model/posting-interface";
export { PostingDialog } from "./ui/posting-dialog";
@@ -0,0 +1,43 @@
import type { PageableList, PageableParams } from "@/shared/model";
import { z } from "zod";
export const POSTING_TYPE = {
POPUP: "POPUP",
BANNER: "BANNER",
IMAGE: "IMAGE",
} as const;
export type PostingType = (typeof POSTING_TYPE)[keyof typeof POSTING_TYPE];
export interface PostingParams extends PageableParams {
pstgTrgtNo: number;
}
export const postSchema = z.object({
siteId: z.string(),
siteNm: z.string(),
pstgTypeCd: z.string(),
pstgTypeNm: z.string(),
pstgTrgtNo: z.number(),
pstgBgngDt: z.string(),
pstgEndDt: z.string(),
pstgPrdSeCd: z.string(),
pstgPrdSeNm: z.string(),
delYn: z.string(),
});
export const withModePostSchema = postSchema
.extend({
mode: z.string(),
})
.omit({
siteNm: true,
});
export type Post = z.infer<typeof postSchema>;
export type WithModePost = z.infer<typeof withModePostSchema>;
export interface PostService {
getPostingHistory: (params: PostingParams) => Promise<PageableList<Post>>;
requestPosting: (data: WithModePost[]) => Promise<Post[]>;
}
@@ -0,0 +1,197 @@
import { useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import {
Dialog,
DialogTrigger,
Button,
DialogContent,
DialogHeader,
DialogTitle,
Badge,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Checkbox,
Select,
} from "@common/components/ui";
import { CodeSelect } from "@/entities/code";
import { SiteApi, type Site } from "@/entities/site";
import type { PageableList } from "@/shared/model";
import { ENDPOINT } from "@/shared/config";
import { type PostingType } from "../model/posting-interface";
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
type PostingDialogProps = {
postingType: PostingType;
postingTargets: PostingTarget[];
};
const POSTING_TYPE_LABEL = {
POPUP: "팝업",
BANNER: "배너",
IMAGE: "이미지",
} as const;
function PostingDialog({ postingType, postingTargets }: PostingDialogProps) {
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data: siteList } = useQuery<PageableList<Site>>({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList({}),
});
const form = useForm();
const { fields, append, replace } = useFieldArray({
control: form.control,
name: "pstg",
});
const handleSelectAll = () => {
if (selectedRows.length === (siteList?.content.length ?? 0)) {
initialize();
} else {
setSelectedRows(siteList?.content.map((_, i) => i) ?? []);
handleAllSelectRow();
}
};
const handleSelectRow = (i: number) => {
const isSelected = selectedRows.includes(i);
setSelectedRows((prev) =>
isSelected ? prev.filter((j) => j !== i) : [...prev, i],
);
if (isSelected) {
const siteId = siteList?.content[i]?.siteId;
if (!siteId) return;
handleRemoveFields(siteId);
return;
}
handleAppendFields(i);
};
const handleAllSelectRow = () => {
replace([]);
siteList?.content.forEach((_, i) => {
handleAppendFields(i);
});
};
const handleAppendFields = (i: number) => {
for (const target of postingTargets) {
append({
siteId: siteList?.content[i]?.siteId ?? "",
pstgTypeCd: postingType,
pstgTrgtNo: target.pstgTrgtNo,
pstgBgngDt: "",
pstgEndDt: "",
pstgPrdSeCd: "",
delYn: "N",
mode: "C",
});
}
};
const handleRemoveFields = (siteId: string) => {
const filteredFields = fields.filter(
(field) => "siteId" in field && field.siteId !== siteId,
);
replace(filteredFields);
};
const handleOpenChange = (open: boolean) => {
if (open) return true;
initialize();
};
const initialize = () => {
setSelectedRows([]);
replace([]);
};
const isAllSelected =
siteList && selectedRows.length === siteList.content.length;
return (
<Dialog onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button color="emerald" disabled={!postingTargets.length}>
<i className="diveicon di-layer" />
사이트반영
</Button>
</DialogTrigger>
<DialogContent className="max-w-[68rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>사이트 반영</DialogTitle>
</DialogHeader>
<div className="card bg-accent !p-4">
<h3 className="font-semibold">
선택된 {POSTING_TYPE_LABEL[postingType]}
</h3>
<div className="mt-4 flex flex-wrap gap-3">
{postingTargets.map((target) => (
<Badge key={target.pstgTrgtNo}>
<span className="text-xl">{target.pstgTrgtNm}</span>
</Badge>
))}
</div>
</div>
<div className="m-2 max-h-[28rem] overflow-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="25%" />
<col width="65%" />
</colgroup>
<TableHeader className="sticky top-0 z-10 bg-accent">
<TableRow>
<TableHead className="!px-0">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>사이트명</TableHead>
<TableHead>게시 구분</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{siteList?.content.map((site, i) => (
<TableRow key={site.siteId}>
<TableCell align="center" className="!px-0">
<Checkbox
checked={selectedRows.includes(i)}
onCheckedChange={() => handleSelectRow(i)}
/>
</TableCell>
<TableCell align="center">{site.siteNm}</TableCell>
<TableCell align="left">
<PostingPeriodSelect disabled={!selectedRows.includes(i)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</DialogContent>
</Dialog>
);
}
function PostingPeriodSelect({
...props
}: React.ComponentPropsWithoutRef<typeof Select>) {
return <CodeSelect upCd="PSTGPRDCD" {...props} />;
}
export { PostingDialog };
@@ -10,7 +10,7 @@ import {
} from "../model/site-interface";
export class SiteApiService implements SiteService {
public async getSiteList(params: SiteParams): Promise<SiteList> {
public async getSiteList(params?: SiteParams): Promise<SiteList> {
const response = await client.get<SiteList>(
`${ENDPOINT.CMS_SERVICE.SITES}`,
{ params },
@@ -11,8 +11,8 @@ export type ImageType = (typeof IMAGE_TYPE)[keyof typeof IMAGE_TYPE];
export interface ImageParams extends PageableParams {
keyword: string;
imgTypeCd: string;
useYn: string;
imgTypeCd?: string;
useYn?: string;
}
export interface Image {
@@ -1,27 +0,0 @@
import type { PageableList, PageableParams } from "@/shared/model";
export interface PostingParams extends PageableParams {
pstgTrgtNo: number;
}
export interface Post {
siteId: string;
siteNm: string;
pstgTypeCd: string;
pstgTypeNm: string;
pstgTrgtNo: number;
pstgBgngDt: string;
pstgEndDt: string;
pstgPrdSeCd: string;
pstgPrdSeNm: string;
delYn: string;
}
export interface WithModePostData extends Post {
mode: string;
}
export interface PostService {
getPostingHistory: (params: PostingParams) => Promise<PageableList<Post>>;
requestPosting: (data: WithModePostData[]) => Promise<Post[]>;
}
@@ -1,9 +1,9 @@
"use client";
import dayjs from "dayjs";
import { type ColumnDef } from "@tanstack/react-table";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import { type Post } from "./posting-interface";
import type { Post } from "@/entities/posting";
export const columns: ColumnDef<Post>[] = [
{
@@ -55,6 +55,7 @@ function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
setOnConfirm(false);
await handleSave(data);
}
});
@@ -63,6 +64,7 @@ function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
const onDelete = async () => {
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
await handleDelete?.();
}
});
+36 -9
View File
@@ -9,22 +9,23 @@ import {
type RowSelectionState,
} from "@tanstack/react-table";
import { Button, DataTable, Input, Label } from "@common/components";
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
import { ENDPOINT } from "@/shared/config";
import { ImageApi } from "../api/image-service";
import { columns } from "../model/image-table-columns";
import { type Image, type ImageParams } from "../model/image-interface";
type PostingTarget = {
pstgTrgtNm: string;
pstgTrgtNo: number;
};
function ImagePage() {
const router = useRouter();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [imageQuery, setImageQuery] = useState<ImageParams>({
keyword: "",
imgTypeCd: "",
useYn: "",
page: 0,
size: 100,
});
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
const [imageQuery, setImageQuery] = useState<ImageParams>({ keyword: "" });
const { data: imageList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES],
@@ -52,6 +53,22 @@ function ImagePage() {
router.push(`/image/register/${row.imgSn}`);
};
useEffect(() => {
const postingTargets = table
.getFilteredSelectedRowModel()
.rows.map((row) => ({
pstgTrgtNm: row.original.imgTtl,
pstgTrgtNo: row.original.imgSn,
}));
if (!postingTargets.length) {
setPostingTargets([]);
return;
}
setPostingTargets(postingTargets);
}, [rowSelection]);
useEffect(() => {
refetch();
}, []);
@@ -77,8 +94,18 @@ function ImagePage() {
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 목록</h3>
<Button onClick={() => router.push("/image/register")}>등록</Button>
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">이미지 목록</h3>
<div className="flex gap-2">
<PostingDialog
postingType={POSTING_TYPE.IMAGE}
postingTargets={postingTargets}
/>
<Button onClick={() => router.push("/image/register")}>
등록
</Button>
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-[49rem] overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} />
+5 -6
View File
@@ -12,14 +12,13 @@ import {
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable } from "@common/components/ui";
import { ENDPOINT } from "@/shared/config";
import { PostingApi, type WithModePost } from "@/entities/posting";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { ENDPOINT } from "@/shared/config";
import { imageFormSchema, type ImageFormData } from "../model/image-interface";
import type { WithModePostData } from "../model/posting-interface";
import { columns } from "../model/posting-table-columns";
import { ImageApi } from "../api/image-service";
import { PostingApi } from "../api/posting-service";
import { ImageForm } from "./image-form";
function ModifyPage({ serial }: { serial: string }) {
@@ -56,7 +55,7 @@ function ModifyPage({ serial }: { serial: string }) {
});
const { mutateAsync: requestPosting } = useMutation({
mutationFn: (data: WithModePostData[]) => PostingApi.requestPosting(data),
mutationFn: (data: WithModePost[]) => PostingApi.requestPosting(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITE_PSTGS, serial],
@@ -81,7 +80,7 @@ function ModifyPage({ serial }: { serial: string }) {
});
const handleDeletePostingHistory = async () => {
const data = table.getFilteredSelectedRowModel().flatRows.map((row) => ({
const data = table.getFilteredSelectedRowModel().rows.map((row) => ({
...row.original,
mode: "D",
}));
@@ -120,7 +119,7 @@ function ModifyPage({ serial }: { serial: string }) {
<Button
size="sm"
color="red"
disabled={!table.getFilteredSelectedRowModel().flatRows.length}
disabled={!table.getFilteredSelectedRowModel().rows.length}
onClick={handleDeletePostingHistory}
>
이력삭제
+3
View File
@@ -52,6 +52,7 @@ function MenuForm({
const onCreate = async (data: MenuFormData) => {
await handleConfirm(async () => {
if (await confirm("저장하시겠습니까?")) {
setOnConfirm(false);
handleCreate(data);
}
});
@@ -60,6 +61,7 @@ function MenuForm({
const onUpdate = async (data: MenuFormData) => {
await handleConfirm(async () => {
if (await confirm("수정하시겠습니까?")) {
setOnConfirm(false);
handleUpdate({ menuCd: menuCd!, data });
}
});
@@ -68,6 +70,7 @@ function MenuForm({
const onDelete = () => {
handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.(menuCd!);
}
});
+1 -1
View File
@@ -62,7 +62,7 @@ function MenuPage() {
const { data: options } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList({}),
queryFn: () => SiteApi.getSiteList(),
select: (data) =>
data.content.map((site) => ({
label: site.siteNm,
+2
View File
@@ -58,6 +58,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
setOnConfirm(false);
handleSave(data);
}
});
@@ -66,6 +67,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
const onDelete = async () => {
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
handleDelete?.();
}
});
@@ -15,7 +15,7 @@ export function VBaseLayout({
queryClient?: QueryClient;
}) {
return (
<QueryProvider client={queryClient}>
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header viewOnly />