Compare commits

...
35 changed files with 811 additions and 72 deletions
@@ -0,0 +1,2 @@
import { SkeletonPage } from "@/shared/ui/skeleton-page";
export default SkeletonPage;
+11 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { CLAIM_NAME, AUTH_USER_ID } from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
@@ -16,8 +17,17 @@ export function middleware(req: NextRequest) {
}
if (pathname.startsWith("/attach-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl}${pathname}`;
return NextResponse.rewrite(new URL(newUrl));
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
return NextResponse.next();
@@ -6,6 +6,7 @@ import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
@@ -16,6 +17,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<AlertDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
@@ -0,0 +1,17 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Attachment,
AttachmentService,
} from "../model/attachment-interface";
export class AttachmentApiService implements AttachmentService {
public async getAttachments(atflCd: string): Promise<Attachment[]> {
const response = await client.get<Attachment[]>(
`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`,
);
return response.data;
}
}
export const AttachmentApi = new AttachmentApiService();
+1
View File
@@ -0,0 +1 @@
export { AttachmentApi } from "./api/attachment-service";
@@ -0,0 +1,18 @@
export interface Attachment {
atflCd: string;
atflSn: number;
atflId: string;
physFileNm: string;
orgnlFileNm: string;
atflFlieSz: number;
atflType: string;
dwnldNocs: number;
delYn: string;
lnkgDmnId: string;
lnkgDmnNm: string;
regDt: string;
}
export interface AttachmentService {
getAttachments: (atflCd: string) => Promise<Attachment[]>;
}
@@ -0,0 +1,31 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList } from "@/shared/model";
import type {
PostService,
PostingParams,
Post,
WithModePost,
} from "../model/posting-interface";
export class PostingApiService implements PostService {
public async getPostingHistory(
params: PostingParams,
): Promise<PageableList<Post>> {
const response = await client.get<PageableList<Post>>(
`${ENDPOINT.CMS_SERVICE.SITE_PSTGS}`,
{ params },
);
return response.data;
}
public async requestPosting(data: WithModePost[]): Promise<Post[]> {
const response = await client.post<Post[]>(
`${ENDPOINT.CMS_SERVICE.SITE_PSTGS}`,
data,
);
return response.data;
}
}
export const PostingApi = new PostingApiService();
+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,50 @@
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().optional(),
pstgEndDt: z.string().optional(),
pstgPrdSeCd: z.string(),
pstgPrdSeNm: z.string(),
delYn: z.string(),
});
export const withModePostSchema = postSchema
.extend({
mode: z.string(),
})
.omit({
siteNm: true,
pstgTypeNm: true,
pstgPrdSeNm: true,
});
export const postingFormSchema = z.object({
pstg: z.array(withModePostSchema),
});
export type Post = z.infer<typeof postSchema>;
export type WithModePost = z.infer<typeof withModePostSchema>;
export type PostingFormData = z.infer<typeof postingFormSchema>;
export interface PostService {
getPostingHistory: (params: PostingParams) => Promise<PageableList<Post>>;
requestPosting: (data: WithModePost[]) => Promise<Post[]>;
}
@@ -0,0 +1,323 @@
import dayjs from "dayjs";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useFieldArray, useForm } from "react-hook-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Dialog,
DialogTrigger,
Button,
DialogContent,
DialogHeader,
DialogTitle,
Badge,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Checkbox,
Select,
DateRangePicker,
DialogClose,
DialogFooter,
} from "@common/components/ui";
import { cn } from "@common/components/lib";
import { CodeSelect } from "@/entities/code";
import { SiteApi, type Site } from "@/entities/site";
import type { PageableList } from "@/shared/model";
import { ENDPOINT } from "@/shared/config";
import { PostingApi } from "../api/posting-service";
import {
postingFormSchema,
type PostingFormData,
type PostingType,
type WithModePost,
} from "../model/posting-interface";
import { useAlertStore } from "@/shared/lib/use-alert-store";
type DateRange = {
from: string;
to?: string;
};
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 { confirm } = useConfirm();
const { setMessage: alert } = useAlertStore((state) => state);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const [rememberPeriodCode, setRememberPeriodCode] = useState<
Record<string, string>
>({});
const [rememberPeriodDate, setRememberPeriodDate] = useState<
Record<string, DateRange | undefined>
>({});
const { data: siteList } = useQuery<PageableList<Site>>({
queryKey: [ENDPOINT.CMS_SERVICE.SITES],
queryFn: () => SiteApi.getSiteList({}),
});
const { mutateAsync: requestPosting } = useMutation({
mutationFn: (data: WithModePost[]) => PostingApi.requestPosting(data),
onSuccess: () => {
alert("저장되었습니다.");
},
});
const form = useForm<PostingFormData>({
resolver: zodResolver(postingFormSchema),
});
const { fields, append, replace } = useFieldArray({
control: form.control,
name: "pstg",
});
const isFormValid =
fields.length && fields.every((field) => field.pstgPrdSeCd !== "");
const isAllSelected =
siteList && selectedRows.length === siteList.content.length;
const postingPeriod = (siteId: string) => {
const periodCode = Number(rememberPeriodCode[siteId]);
if (periodCode === 99) return "상시";
return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs()
.add(periodCode, "day")
.format("YYYY-MM-DD")}`;
};
const handleOpenChange = (open: boolean) => {
if (open) return true;
replace([]);
setSelectedRows([]);
setRememberPeriodCode({});
setRememberPeriodDate({});
};
const handleAppendFields = (i: number) => {
const siteId = siteList?.content[i]?.siteId ?? "";
const newFields = postingTargets.map((target) => ({
siteId,
pstgTypeCd: postingType,
pstgTrgtNo: target.pstgTrgtNo,
pstgBgngDt: rememberPeriodDate[siteId]?.from ?? "",
pstgEndDt: rememberPeriodDate[siteId]?.to ?? "",
pstgPrdSeCd: rememberPeriodCode[siteId] ?? "",
delYn: "N",
mode: "C",
}));
append(newFields);
};
const handleUpdateFields =
(siteId: string) => (obj: Record<string, unknown>) => {
const updatedFields = fields.map((field) =>
"siteId" in field && field.siteId === siteId
? { ...field, ...obj }
: field,
);
replace(updatedFields);
};
const handleRemoveFields = (siteId: string) => {
const filteredFields = fields.filter(
(field) => "siteId" in field && field.siteId !== siteId,
);
replace(filteredFields);
};
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 handleSelectAll = () => {
if (selectedRows.length === (siteList?.content.length ?? 0)) {
replace([]);
setSelectedRows([]);
} else {
replace([]);
setSelectedRows(siteList?.content.map((_, i) => i) ?? []);
siteList?.content.forEach((_, i) => handleAppendFields(i));
}
};
const handlePeriodCodeChange = (siteId: string) => (value: string) => {
const from = dayjs().format("YYYY-MM-DD 00:00:00");
const to = dayjs().add(Number(value), "day").format("YYYY-MM-DD 23:59:59");
setRememberPeriodCode((prev) => ({
...prev,
[siteId]: value,
}));
setRememberPeriodDate((prev) => ({
...prev,
[siteId]: { from, to },
}));
handleUpdateFields(siteId)({
pstgPrdSeCd: value,
pstgBgngDt: from,
pstgEndDt: to,
});
};
const handlePeriodDateChange =
(siteId: string) => (value: DateRange | undefined) => {
const from = dayjs(value?.from).format("YYYY-MM-DD 00:00:00");
const to = dayjs(value?.to).format("YYYY-MM-DD 23:59:59");
setRememberPeriodDate((prev) => ({
...prev,
[siteId]: { from, to },
}));
handleUpdateFields(siteId)({
pstgBgngDt: from,
pstgEndDt: to,
});
};
const handleSave = async () => {
if (await confirm("저장하시겠습니까?")) {
const data = form.getValues("pstg");
await requestPosting(data);
}
};
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-[30rem] 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">
<div className="flex items-center gap-4">
<PostingPeriodSelect
onValueChange={handlePeriodCodeChange(site.siteId)}
disabled={!selectedRows.includes(i)}
/>
{!!Number(rememberPeriodCode[site.siteId]) && (
<span
className={cn(
"text-lg text-label",
!selectedRows.includes(i) && "text-muted",
)}
>
게시 기간: <u>{postingPeriod(site.siteId)}</u>
</span>
)}
{Number(rememberPeriodCode[site.siteId]) === 0 && (
<DateRangePicker
handleDateChange={(date) =>
handlePeriodDateChange(site.siteId)(
date as DateRange | undefined,
)
}
disabled={!selectedRows.includes(i)}
/>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<Button size="lg" onClick={handleSave} disabled={!isFormValid}>
저장
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</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 {
@@ -0,0 +1,44 @@
"use client";
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@common/components/ui";
import type { Post } from "@/entities/posting";
export const columns: ColumnDef<Post>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "siteNm",
header: "사이트",
},
{
accessorKey: "pstgPrdSeNm",
header: "게시기간",
cell: ({ row }) => {
const pstgPrdSeNm = row.original.pstgPrdSeNm;
const pstgBgngDt = row.original.pstgBgngDt;
const pstgEndDt = row.original.pstgEndDt;
return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`;
},
},
];
+57 -33
View File
@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import type { UseFormReturn } from "react-hook-form";
import type { HttpResponse, Upload } from "tus-js-client";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -20,10 +22,11 @@ import {
TableRow,
} from "@common/components";
import { CodeSelect } from "@/entities/code";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { AttachmentApi } from "@/entities/attachment";
import { ENDPOINT } from "@/shared/config";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { handleUpload } from "../lib/utils";
import type { ImageFormData } from "../model/image-interface";
import { handleUpload } from "../lib/utils";
type ImageFormProps = {
form: UseFormReturn<ImageFormData>;
@@ -38,47 +41,50 @@ function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
const router = useRouter();
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const [attachmentFileId, setAttachmentFileId] = useState("");
const [upload, setUpload] = useState<Upload | null>(null);
const [progress, setProgress] = useState(0);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const { data: atflId } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS],
queryFn: () => AttachmentApi.getAttachments(form.getValues("atflCd")!),
enabled: !!form.getValues("atflCd"),
select: (data) => data?.[data.length - 1]?.atflId,
});
const onSubmit = async (data: ImageFormData) => {
const confirmMessage = isModifyMode
? "수정하시겠습니까?"
: "저장하시겠습니까?";
await handleConfirm(async () => {
if (await confirm(confirmMessage)) {
await handleSave(data);
}
});
if (await confirm(confirmMessage)) {
await handleSave(data);
}
};
const onDelete = async () => {
await handleConfirm(async () => {
if (await confirm("삭제하시겠습니까?")) {
await handleDelete?.();
}
});
if (await confirm("삭제하시겠습니까?")) {
await handleDelete?.();
}
};
const handleProgress = (percentage: string) => {
setProgress(Number(percentage));
};
const handleUploadResponse = (res: HttpResponse) => {
const handleUploadResponse = async (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
form.setValue("atflCd", JSON.parse(fileInfo)?.atflCd);
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd", atflCd);
if (atflId) setAttachmentFileId(atflId);
};
useEffect(() => {
if (atflId) setAttachmentFileId(atflId);
}, [atflId]);
return (
<>
<Form {...form}>
@@ -202,6 +208,7 @@ function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
<FileUploader
id="atflCd"
className="!h-auto !max-w-none flex-col !items-start"
accept=".jpeg,.jpg,.png"
onUpload={(e) => {
const files = e.target.files;
if (!files) return;
@@ -213,18 +220,36 @@ function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
});
}}
customPreview={
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] rounded-sm border border-border">
<p className="absolute left-0 top-0 p-2 text-base leading-none">
{(upload?.file as File)?.name}
</p>
<div></div>
<Progress
value={progress}
className="absolute bottom-0 left-0"
/>
<>
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent">
<p className="absolute bottom-3 left-0 p-2 text-base leading-none">
{(upload?.file as File)?.name}
</p>
<div className="relative h-full w-full">
{attachmentFileId && (
<Image
src={`${ENDPOINT.ATTACH_SERVICE.IMAGES}/${attachmentFileId}`}
alt="업로드된 이미지"
fill
objectFit="contain"
/>
)}
</div>
<Progress
value={progress}
className="absolute bottom-0 left-0"
/>
</div>
</Label>
<div className="mb-2 text-lg font-medium text-label">
<ul className="ml-10 list-disc">
<li>권장 사이즈: 1920x500</li>
<li>파일 최대 크기: 2MB</li>
<li>확장자: jpg, jpeg, png 가능</li>
</ul>
</div>
</Label>
</>
}
/>
</FormControl>
@@ -309,7 +334,6 @@ function ImageForm({ form, imgSn, handleSave, handleDelete }: ImageFormProps) {
</div>
</form>
</Form>
<ConfirmDialog open={onConfirm} />
</>
);
}
+37 -10
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/table-columns";
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} />
+93 -12
View File
@@ -1,24 +1,45 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type RowSelectionState,
} from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button, DataTable } from "@common/components/ui";
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 { columns } from "../model/posting-table-columns";
import { ImageApi } from "../api/image-service";
import { ImageForm } from "./image-form";
import { useAlertStore } from "@/shared/lib/use-alert-store";
function ModifyPage({ serial }: { serial: string }) {
const router = useRouter();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const [onConfirm, setOnConfirm] = useState(false);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const { data: imageDetail } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, serial],
queryFn: () => ImageApi.getImageInfo(serial),
});
const { data: postHistory } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SITE_PSTGS, serial],
queryFn: () => PostingApi.getPostingHistory({ pstgTrgtNo: Number(serial) }),
});
const { mutateAsync: updateImage } = useMutation({
mutationFn: (data: ImageFormData) => ImageApi.updateImage(serial, data),
onSuccess: () => {
@@ -33,23 +54,83 @@ function ModifyPage({ serial }: { serial: string }) {
},
});
const { mutateAsync: requestPosting } = useMutation({
mutationFn: (data: WithModePost[]) => PostingApi.requestPosting(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.SITE_PSTGS, serial],
});
},
});
const form = useForm<ImageFormData>({
resolver: zodResolver(imageFormSchema),
values: imageDetail,
});
const table = useReactTable({
data: postHistory?.content ?? [],
columns,
state: {
rowSelection,
},
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection,
});
const handleDeletePostingHistory = async () => {
const data = table.getFilteredSelectedRowModel().rows.map((row) => ({
...row.original,
mode: "D",
}));
setOnConfirm(true);
if (await confirm("삭제하시겠습니까?")) {
setOnConfirm(false);
await requestPosting(data);
alert("삭제되었습니다.");
}
setOnConfirm(false);
};
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 수정</h3>
<>
<div className="mt-6 flex flex-col">
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 수정</h3>
</div>
<ImageForm
form={form}
imgSn={serial}
handleSave={updateImage}
handleDelete={deleteImage}
/>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
사이트 게시 이력
</h3>
</div>
<div className="card">
<div className="mb-4 flex justify-end">
<Button
size="sm"
color="red"
disabled={!table.getFilteredSelectedRowModel().rows.length}
onClick={handleDeletePostingHistory}
>
이력삭제
</Button>
</div>
<DataTable table={table} />
</div>
</div>
</div>
<ImageForm
form={form}
imgSn={serial}
handleSave={updateImage}
handleDelete={deleteImage}
/>
</div>
<ConfirmDialog open={onConfirm} />
</>
);
}
@@ -33,8 +33,7 @@ function ContentSearchDialog({
const { data: contentList, refetch } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS],
queryFn: () =>
ContentApi.getContentList({ contsTtl, useYn: "Y", page: 0, size: 100 }),
queryFn: () => ContentApi.getContentList({ contsTtl }),
enabled: false,
});
+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!);
}
});
+2 -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,
@@ -179,6 +179,7 @@ function MenuPage() {
setOnConfirm(false);
deleteMenu(selectedMenu!.menuCd!);
}
setOnConfirm(false);
};
const handleAddMenu = () => {
@@ -90,6 +90,7 @@ function IPManagementDialog({
await mutateAsync(data.ips);
alert("저장되었습니다.");
}
setOnConfirm(false);
};
const handleAddIP = () => {
+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?.();
}
});
+3
View File
@@ -5,6 +5,8 @@ export const BASE_URL = `${PROXY_HOST}/server`;
export const ENDPOINT = {
ATTACH_SERVICE: {
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
IMAGES: "/attach-service/api/v1/images",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
@@ -21,6 +23,7 @@ export const ENDPOINT = {
CONTENTS: "/cms-service/api/v1/contents",
USER_OGNZ: "/cms-service/api/v1/user-ognz",
IMAGES: "/cms-service/api/v1/images",
SITE_PSTGS: "/cms-service/api/v1/site-pstgs",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
+3 -3
View File
@@ -9,7 +9,7 @@ import {
} from "@common/components/ui";
type ConfirmDialogProps = {
open: boolean;
open?: boolean;
confirmButtonProps?: ButtonProps & { label?: string };
cancelButtonProps?: ButtonProps & { label?: string };
};
@@ -19,9 +19,9 @@ function ConfirmDialog({
confirmButtonProps,
cancelButtonProps,
}: ConfirmDialogProps) {
const { message, onConfirm, onCancel } = useConfirm();
const { message, isOpen, onConfirm, onCancel } = useConfirm();
return (
<Dialog open={open} onOpenChange={onCancel}>
<Dialog open={open ?? isOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="text-center text-[1.4rem] text-foreground">{message}</p>
+12
View File
@@ -0,0 +1,12 @@
import { Skeleton } from "@common/components/ui/skeleton";
function SkeletonPage() {
return (
<div className="flex flex-col gap-4 p-6">
<Skeleton className="h-[6rem] rounded-xl" />
<Skeleton className="h-[60rem] rounded-xl" />
</div>
);
}
export { SkeletonPage };
@@ -34,12 +34,21 @@ const config: StorybookConfig = {
AUTH_USER_ID: JSON.stringify(process.env.AUTH_USER_ID || "token-id"),
},
};
config.optimizeDeps = {
...config.optimizeDeps,
include: ["react", "react-dom", "@storybook/react"],
exclude: ["@storybook/react-vite"],
};
config.plugins?.push(
tsconfigPaths({
projects: [path.resolve(path.dirname(__dirname), "tsconfig.json")],
}),
);
config.server = {
...config.server,
proxy: {
"/server": {
target: process.env.SERVER_API_URL,
@@ -44,7 +44,7 @@ export function Calendar({
),
day_range_end: "day-range-end",
day_selected:
"!bg-primary !text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
@@ -2,6 +2,6 @@
@layer components {
.date-picker-trigger {
@apply h-[3rem] w-[12rem] justify-between border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal leading-none text-muted-foreground hover:!bg-accent;
@apply h-[3rem] w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent;
}
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { DatePicker } from ".";
import { useState } from "react";
const meta: Meta<typeof DatePicker> = {
title: "Elements/DatePicker",
@@ -19,12 +19,12 @@ export function DatePicker({ className, ...props }: DatePickerProps) {
variant="outline"
className={cn("date-picker-trigger", className)}
>
<CalendarIcon className="!size-[1.6rem]" />
{props.selected ? (
format(props.selected, "yyyy-MM-dd")
) : (
<span>날짜 선택</span>
)}
<CalendarIcon className="!size-[1.6rem]" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import type { DateRange } from "react-day-picker";
import { cn } from "../../lib/utils";
import { Button } from "../button";
import { Calendar } from "../calendar";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
type DateRangePickerProps = React.HTMLAttributes<HTMLDivElement> & {
disabled?: boolean;
handleDateChange?: (date: DateRange | undefined) => void;
};
export function DateRangePicker({
disabled,
className,
handleDateChange,
}: DateRangePickerProps) {
const [date, setDate] = useState<DateRange>();
const handleSelect = (date: DateRange | undefined) => {
setDate(date);
handleDateChange?.(date);
};
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"h-[3.2rem] min-w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
!date && "text-muted-foreground",
)}
disabled={disabled}
>
<CalendarIcon className="!size-[1.6rem]" />
{date?.from ? (
date.to ? (
<>
{format(date.from, "yyyy-MM-dd")} ~{" "}
{format(date.to, "yyyy-MM-dd")}
</>
) : (
format(date.from, "yyyy-MM-dd")
)
) : (
<span>기간 선택</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}
@@ -26,8 +26,8 @@ export function FileUploader({
id={id}
type="file"
className="sr-only"
{...props}
onChange={onUpload}
{...props}
/>
{customPreview || (
<div className="file-uploader-preview">
@@ -5,6 +5,7 @@ export * from "./calendar";
export * from "./checkbox";
export * from "./data-table";
export * from "./date-picker";
export * from "./date-range-picker";
export * from "./dialog";
export * from "./dropdown-menu";
export * from "./file-uploader";
@@ -15,7 +15,7 @@ export function VBaseLayout({
queryClient?: QueryClient;
}) {
return (
<QueryProvider client={queryClient}>
<QueryProvider queryClient={queryClient}>
<SidebarProvider>
<div className="flex-1">
<Header viewOnly />
@@ -6,7 +6,7 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn("animate-pulse rounded-md bg-[rgba(0,0,0,0.1)]", className)}
{...props}
/>
);