diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/information/repository.ts | 58 | ||||
| -rw-r--r-- | lib/information/service.ts | 224 | ||||
| -rw-r--r-- | lib/information/table/add-information-dialog.tsx | 329 | ||||
| -rw-r--r-- | lib/information/table/delete-information-dialog.tsx | 125 | ||||
| -rw-r--r-- | lib/information/table/information-table-columns.tsx | 248 | ||||
| -rw-r--r-- | lib/information/table/information-table-toolbar-actions.tsx | 25 | ||||
| -rw-r--r-- | lib/information/table/information-table.tsx | 148 | ||||
| -rw-r--r-- | lib/information/table/update-information-dialog.tsx | 124 | ||||
| -rw-r--r-- | lib/information/validations.ts | 32 | ||||
| -rw-r--r-- | lib/notice/repository.ts | 244 | ||||
| -rw-r--r-- | lib/notice/service.ts | 324 | ||||
| -rw-r--r-- | lib/notice/validations.ts | 80 |
12 files changed, 772 insertions, 1189 deletions
diff --git a/lib/information/repository.ts b/lib/information/repository.ts index 2a3bc1c0..f640a4c6 100644 --- a/lib/information/repository.ts +++ b/lib/information/repository.ts @@ -40,19 +40,15 @@ export async function countInformationLists( // 기존 패턴 (하위 호환성을 위해 유지)
export async function selectInformation(input: GetInformationSchema) {
- const { page, per_page = 50, sort, pageCode, pageName, isActive, from, to } = input
+ const { page, per_page = 50, sort, pagePath, isActive, from, to } = input
const conditions = []
- if (pageCode) {
- conditions.push(ilike(pageInformation.pageCode, `%${pageCode}%`))
+ if (pagePath) {
+ conditions.push(ilike(pageInformation.pagePath, `%${pagePath}%`))
}
- if (pageName) {
- conditions.push(ilike(pageInformation.pageName, `%${pageName}%`))
- }
-
- if (isActive !== null) {
+ if (isActive !== null && isActive !== undefined) {
conditions.push(eq(pageInformation.isActive, isActive))
}
@@ -91,19 +87,15 @@ export async function selectInformation(input: GetInformationSchema) { // 기존 패턴: 인포메이션 총 개수 조회
export async function countInformation(input: GetInformationSchema) {
- const { pageCode, pageName, isActive, from, to } = input
+ const { pagePath, isActive, from, to } = input
const conditions = []
- if (pageCode) {
- conditions.push(ilike(pageInformation.pageCode, `%${pageCode}%`))
+ if (pagePath) {
+ conditions.push(ilike(pageInformation.pagePath, `%${pagePath}%`))
}
- if (pageName) {
- conditions.push(ilike(pageInformation.pageName, `%${pageName}%`))
- }
-
- if (isActive !== null) {
+ if (isActive !== null && isActive !== undefined) {
conditions.push(eq(pageInformation.isActive, isActive))
}
@@ -125,13 +117,13 @@ export async function countInformation(input: GetInformationSchema) { return result[0]?.count ?? 0
}
-// 페이지 코드별 인포메이션 조회 (활성화된 것만)
-export async function getInformationByPageCode(pageCode: string): Promise<PageInformation | null> {
+// 페이지 경로별 인포메이션 조회 (활성화된 것만)
+export async function getInformationByPagePath(pagePath: string): Promise<PageInformation | null> {
const result = await db
.select()
.from(pageInformation)
.where(and(
- eq(pageInformation.pageCode, pageCode),
+ eq(pageInformation.pagePath, pagePath),
eq(pageInformation.isActive, true)
))
.limit(1)
@@ -139,16 +131,6 @@ export async function getInformationByPageCode(pageCode: string): Promise<PageIn return result[0] || null
}
-// 인포메이션 생성
-export async function insertInformation(data: NewPageInformation): Promise<PageInformation> {
- const result = await db
- .insert(pageInformation)
- .values(data)
- .returning()
-
- return result[0]
-}
-
// 인포메이션 수정
export async function updateInformation(id: number, data: Partial<NewPageInformation>): Promise<PageInformation | null> {
const result = await db
@@ -160,24 +142,6 @@ export async function updateInformation(id: number, data: Partial<NewPageInforma return result[0] || null
}
-// 인포메이션 삭제
-export async function deleteInformationById(id: number): Promise<boolean> {
- const result = await db
- .delete(pageInformation)
- .where(eq(pageInformation.id, id))
-
- return (result.rowCount ?? 0) > 0
-}
-
-// 인포메이션 다중 삭제
-export async function deleteInformationByIds(ids: number[]): Promise<number> {
- const result = await db
- .delete(pageInformation)
- .where(sql`${pageInformation.id} = ANY(${ids})`)
-
- return result.rowCount ?? 0
-}
-
// ID로 인포메이션 조회
export async function getInformationById(id: number): Promise<PageInformation | null> {
const result = await db
diff --git a/lib/information/service.ts b/lib/information/service.ts index 8f1e5679..30a651f1 100644 --- a/lib/information/service.ts +++ b/lib/information/service.ts @@ -9,7 +9,6 @@ import db from "@/db/db" import { pageInformation, menuAssignments } from "@/db/schema"
import type {
- CreateInformationSchema,
UpdateInformationSchema,
GetInformationSchema
} from "./validations"
@@ -17,11 +16,8 @@ import type { import {
selectInformation,
countInformation,
- getInformationByPageCode,
- insertInformation,
+ getInformationByPagePath,
updateInformation,
- deleteInformationById,
- deleteInformationByIds,
getInformationById,
selectInformationLists,
countInformationLists
@@ -34,57 +30,65 @@ export async function getInformationLists(input: GetInformationSchema) { return unstable_cache(
async () => {
try {
- const offset = (input.page - 1) * input.perPage
+ // 고급 검색 로직
+ const { page, perPage, search, filters, joinOperator, pagePath, pageName, informationContent, isActive } = input
- // 고급 필터링
- const advancedWhere = filterColumns({
- table: pageInformation,
- filters: input.filters,
- joinOperator: input.joinOperator,
- })
+ // 기본 검색 조건들
+ const conditions = []
- // 전역 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- ilike(pageInformation.pageCode, s),
- ilike(pageInformation.pageName, s),
- ilike(pageInformation.title, s),
- ilike(pageInformation.description, s)
- )
+ // 검색어가 있으면 여러 필드에서 검색
+ if (search && search.trim()) {
+ const searchConditions = [
+ ilike(pageInformation.pagePath, `%${search}%`),
+ ilike(pageInformation.pageName, `%${search}%`),
+ ilike(pageInformation.informationContent, `%${search}%`)
+ ]
+ conditions.push(or(...searchConditions))
}
- // 기본 필터들
- let basicWhere
- const basicConditions = []
-
- if (input.pageCode) {
- basicConditions.push(ilike(pageInformation.pageCode, `%${input.pageCode}%`))
+ // 개별 필드 조건들
+ if (pagePath && pagePath.trim()) {
+ conditions.push(ilike(pageInformation.pagePath, `%${pagePath}%`))
}
-
- if (input.pageName) {
- basicConditions.push(ilike(pageInformation.pageName, `%${input.pageName}%`))
+
+ if (pageName && pageName.trim()) {
+ conditions.push(ilike(pageInformation.pageName, `%${pageName}%`))
}
-
- if (input.title) {
- basicConditions.push(ilike(pageInformation.title, `%${input.title}%`))
+
+ if (informationContent && informationContent.trim()) {
+ conditions.push(ilike(pageInformation.informationContent, `%${informationContent}%`))
}
-
- if (input.isActive !== undefined && input.isActive !== null) {
- basicConditions.push(eq(pageInformation.isActive, input.isActive))
- }
-
- if (basicConditions.length > 0) {
- basicWhere = and(...basicConditions)
- }
-
- // 최종 where 조건
- const finalWhere = and(
- advancedWhere,
- globalWhere,
- basicWhere
- )
+
+ if (isActive !== null && isActive !== undefined) {
+ conditions.push(eq(pageInformation.isActive, isActive))
+ }
+
+ // 고급 필터 처리
+ if (filters && filters.length > 0) {
+ const advancedConditions = filters.map(() =>
+ filterColumns({
+ table: pageInformation,
+ filters: filters,
+ joinOperator: joinOperator,
+ })
+ )
+
+ if (advancedConditions.length > 0) {
+ if (joinOperator === "or") {
+ conditions.push(or(...advancedConditions))
+ } else {
+ conditions.push(and(...advancedConditions))
+ }
+ }
+ }
+
+ // 전체 WHERE 조건 조합
+ const finalWhere = conditions.length > 0
+ ? (joinOperator === "or" ? or(...conditions) : and(...conditions))
+ : undefined
+
+ // 페이지네이션
+ const offset = (page - 1) * perPage
// 정렬 처리
const orderBy = input.sort.length > 0
@@ -93,12 +97,12 @@ export async function getInformationLists(input: GetInformationSchema) { return item.desc ? desc(pageInformation.createdAt) : asc(pageInformation.createdAt)
} else if (item.id === "updatedAt") {
return item.desc ? desc(pageInformation.updatedAt) : asc(pageInformation.updatedAt)
- } else if (item.id === "pageCode") {
- return item.desc ? desc(pageInformation.pageCode) : asc(pageInformation.pageCode)
+ } else if (item.id === "pagePath") {
+ return item.desc ? desc(pageInformation.pagePath) : asc(pageInformation.pagePath)
} else if (item.id === "pageName") {
return item.desc ? desc(pageInformation.pageName) : asc(pageInformation.pageName)
- } else if (item.id === "title") {
- return item.desc ? desc(pageInformation.title) : asc(pageInformation.title)
+ } else if (item.id === "informationContent") {
+ return item.desc ? desc(pageInformation.informationContent) : asc(pageInformation.informationContent)
} else if (item.id === "isActive") {
return item.desc ? desc(pageInformation.isActive) : asc(pageInformation.isActive)
} else {
@@ -129,7 +133,7 @@ export async function getInformationLists(input: GetInformationSchema) { return { data: [], pageCount: 0, total: 0 }
}
},
- [JSON.stringify(input)], // 캐싱 키
+ [JSON.stringify(input)],
{
revalidate: 3600,
tags: ["information-lists"],
@@ -161,18 +165,18 @@ export async function getInformationList(input: Partial<GetInformationSchema> & }
// 페이지별 인포메이션 조회 (일반 사용자용)
-export async function getPageInformation(pageCode: string): Promise<PageInformation | null> {
+export async function getPageInformation(pagePath: string): Promise<PageInformation | null> {
try {
- return await getInformationByPageCode(pageCode)
+ return await getInformationByPagePath(pagePath)
} catch (error) {
- console.error(`Failed to get information for page ${pageCode}:`, error)
+ console.error(`Failed to get information for page ${pagePath}:`, error)
return null
}
}
// 캐시된 페이지별 인포메이션 조회
export const getCachedPageInformation = unstable_cache(
- async (pageCode: string) => getPageInformation(pageCode),
+ async (pagePath: string) => getPageInformation(pagePath),
["page-information"],
{
tags: ["page-information"],
@@ -180,34 +184,20 @@ export const getCachedPageInformation = unstable_cache( }
)
-// 인포메이션 생성
-export async function createInformation(input: CreateInformationSchema) {
- try {
- const result = await insertInformation(input)
-
- revalidateTag("page-information")
- revalidateTag("information-lists")
- revalidateTag("information-edit-permission")
-
- return {
- success: true,
- data: result,
- message: "인포메이션이 성공적으로 생성되었습니다."
- }
- } catch (error) {
- console.error("Failed to create information:", error)
- return {
- success: false,
- message: getErrorMessage(error)
- }
- }
-}
-
-// 인포메이션 수정
+// 인포메이션 수정 (내용과 첨부파일만)
export async function updateInformationData(input: UpdateInformationSchema) {
try {
const { id, ...updateData } = input
- const result = await updateInformation(id, updateData)
+
+ // 수정 가능한 필드만 허용
+ const allowedFields = {
+ informationContent: updateData.informationContent,
+ attachmentFilePath: updateData.attachmentFilePath,
+ attachmentFileName: updateData.attachmentFileName,
+ updatedAt: new Date()
+ }
+
+ const result = await updateInformation(id, allowedFields)
if (!result) {
return {
@@ -233,56 +223,6 @@ export async function updateInformationData(input: UpdateInformationSchema) { }
}
-// 인포메이션 삭제
-export async function deleteInformation(id: number) {
- try {
- const success = await deleteInformationById(id)
-
- if (!success) {
- return {
- success: false,
- message: "인포메이션을 찾을 수 없거나 삭제에 실패했습니다."
- }
- }
-
- revalidateTag("page-information")
- revalidateTag("information-lists")
-
- return {
- success: true,
- message: "인포메이션이 성공적으로 삭제되었습니다."
- }
- } catch (error) {
- console.error("Failed to delete information:", error)
- return {
- success: false,
- message: getErrorMessage(error)
- }
- }
-}
-
-// 인포메이션 다중 삭제
-export async function deleteMultipleInformation(ids: number[]) {
- try {
- const deletedCount = await deleteInformationByIds(ids)
-
- revalidateTag("page-information")
- revalidateTag("information-lists")
-
- return {
- success: true,
- deletedCount,
- message: `${deletedCount}개의 인포메이션이 성공적으로 삭제되었습니다.`
- }
- } catch (error) {
- console.error("Failed to delete multiple information:", error)
- return {
- success: false,
- message: getErrorMessage(error)
- }
- }
-}
-
// ID로 인포메이션 조회
export async function getInformationDetail(id: number): Promise<PageInformation | null> {
try {
@@ -294,18 +234,18 @@ export async function getInformationDetail(id: number): Promise<PageInformation }
// 인포메이션 편집 권한 확인
-export async function checkInformationEditPermission(pageCode: string, userId: string): Promise<boolean> {
+export async function checkInformationEditPermission(pagePath: string, userId: string): Promise<boolean> {
try {
- // pageCode를 menuPath로 변환 (pageCode가 menuPath의 마지막 부분이라고 가정)
- // 예: pageCode "vendor-list" -> menuPath "/evcp/vendor-list" 또는 "/partners/vendor-list"
+ // pagePath를 menuPath로 변환 (pagePath가 menuPath의 마지막 부분이라고 가정)
+ // 예: pagePath "vendor-list" -> menuPath "/evcp/vendor-list" 또는 "/partners/vendor-list"
const menuPathQueries = [
- `/evcp/${pageCode}`,
- `/partners/${pageCode}`,
- `/${pageCode}`, // 루트 경로
- pageCode // 정확한 매칭
+ `/evcp/${pagePath}`,
+ `/partners/${pagePath}`,
+ `/${pagePath}`, // 루트 경로
+ pagePath // 정확한 매칭
]
- // menu_assignments에서 해당 pageCode와 매칭되는 메뉴 찾기
+ // menu_assignments에서 해당 pagePath와 매칭되는 메뉴 찾기
const menuAssignment = await db
.select()
.from(menuAssignments)
@@ -334,7 +274,7 @@ export async function checkInformationEditPermission(pageCode: string, userId: s // 캐시된 권한 확인
export const getCachedEditPermission = unstable_cache(
- async (pageCode: string, userId: string) => checkInformationEditPermission(pageCode, userId),
+ async (pagePath: string, userId: string) => checkInformationEditPermission(pagePath, userId),
["information-edit-permission"],
{
tags: ["information-edit-permission"],
diff --git a/lib/information/table/add-information-dialog.tsx b/lib/information/table/add-information-dialog.tsx deleted file mode 100644 index a879fbfe..00000000 --- a/lib/information/table/add-information-dialog.tsx +++ /dev/null @@ -1,329 +0,0 @@ -"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { Loader, Upload, X } from "lucide-react"
-import { useRouter } from "next/navigation"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Switch } from "@/components/ui/switch"
-import { createInformation } from "@/lib/information/service"
-import { createInformationSchema, type CreateInformationSchema } from "@/lib/information/validations"
-
-interface AddInformationDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-export function AddInformationDialog({
- open,
- onOpenChange,
-}: AddInformationDialogProps) {
- const router = useRouter()
- const [isLoading, setIsLoading] = React.useState(false)
- const [uploadedFile, setUploadedFile] = React.useState<File | null>(null)
-
- const form = useForm<CreateInformationSchema>({
- resolver: zodResolver(createInformationSchema),
- defaultValues: {
- pageCode: "",
- pageName: "",
- title: "",
- description: "",
- noticeTitle: "",
- noticeContent: "",
- attachmentFileName: "",
- attachmentFilePath: "",
- attachmentFileSize: "",
- isActive: true,
- },
- })
-
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
- const file = event.target.files?.[0]
- if (file) {
- setUploadedFile(file)
- // 파일 크기를 MB 단위로 변환
- const sizeInMB = (file.size / (1024 * 1024)).toFixed(2)
- form.setValue("attachmentFileName", file.name)
- form.setValue("attachmentFileSize", `${sizeInMB} MB`)
- }
- }
-
- const removeFile = () => {
- setUploadedFile(null)
- form.setValue("attachmentFileName", "")
- form.setValue("attachmentFilePath", "")
- form.setValue("attachmentFileSize", "")
- }
-
- const uploadFile = async (file: File): Promise<string> => {
- const formData = new FormData()
- formData.append("file", file)
-
- const response = await fetch("/api/upload", {
- method: "POST",
- body: formData,
- })
-
- if (!response.ok) {
- throw new Error("파일 업로드에 실패했습니다.")
- }
-
- const result = await response.json()
- return result.url
- }
-
- const onSubmit = async (values: CreateInformationSchema) => {
- setIsLoading(true)
- try {
- const finalValues = { ...values }
-
- // 파일이 있으면 업로드
- if (uploadedFile) {
- const filePath = await uploadFile(uploadedFile)
- finalValues.attachmentFilePath = filePath
- }
-
- const result = await createInformation(finalValues)
-
- if (result.success) {
- toast.success(result.message)
- form.reset()
- setUploadedFile(null)
- onOpenChange(false)
- router.refresh()
- } else {
- toast.error(result.message)
- }
- } catch (error) {
- toast.error("인포메이션 생성에 실패했습니다.")
- console.error(error)
- } finally {
- setIsLoading(false)
- }
- }
-
- // 다이얼로그가 닫힐 때 폼 초기화
- React.useEffect(() => {
- if (!open) {
- form.reset()
- setUploadedFile(null)
- }
- }, [open, form])
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-2xl">
- <DialogHeader>
- <DialogTitle>인포메이션 추가</DialogTitle>
- <DialogDescription>
- 새로운 페이지 인포메이션을 추가합니다.
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="pageCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>페이지 코드</FormLabel>
- <FormControl>
- <Input placeholder="예: vendor-list" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="pageName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>페이지명</FormLabel>
- <FormControl>
- <Input placeholder="예: 협력업체 목록" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목</FormLabel>
- <FormControl>
- <Input placeholder="인포메이션 제목을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설명</FormLabel>
- <FormControl>
- <Textarea
- placeholder="페이지 설명을 입력하세요"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="noticeTitle"
- render={({ field }) => (
- <FormItem>
- <FormLabel>공지사항 제목 (선택사항)</FormLabel>
- <FormControl>
- <Input placeholder="공지사항 제목을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="noticeContent"
- render={({ field }) => (
- <FormItem>
- <FormLabel>공지사항 내용 (선택사항)</FormLabel>
- <FormControl>
- <Textarea
- placeholder="공지사항 내용을 입력하세요"
- rows={3}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div>
- <FormLabel>첨부파일</FormLabel>
- <div className="mt-2">
- {uploadedFile ? (
- <div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium">{uploadedFile.name}</span>
- <span className="text-xs text-gray-500">
- ({(uploadedFile.size / (1024 * 1024)).toFixed(2)} MB)
- </span>
- </div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={removeFile}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ) : (
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
- <div className="text-center">
- <Upload className="mx-auto h-8 w-8 text-gray-400" />
- <div className="mt-2">
- <label
- htmlFor="file-upload"
- className="cursor-pointer text-sm text-blue-600 hover:text-blue-500"
- >
- 파일을 선택하세요
- </label>
- <input
- id="file-upload"
- type="file"
- className="hidden"
- onChange={handleFileSelect}
- accept=".pdf,.doc,.docx,.xlsx,.ppt,.pptx,.txt,.zip"
- />
- </div>
- <p className="text-xs text-gray-500 mt-1">
- PDF, DOC, DOCX, XLSX, PPT, PPTX, TXT, ZIP 파일만 업로드 가능
- </p>
- </div>
- </div>
- )}
- </div>
- </div>
-
- <FormField
- control={form.control}
- name="isActive"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel className="text-base">활성 상태</FormLabel>
- <div className="text-sm text-muted-foreground">
- 활성화하면 해당 페이지에서 인포메이션 버튼이 표시됩니다.
- </div>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isLoading}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isLoading}>
- {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- 생성
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-}
\ No newline at end of file diff --git a/lib/information/table/delete-information-dialog.tsx b/lib/information/table/delete-information-dialog.tsx deleted file mode 100644 index e36d948d..00000000 --- a/lib/information/table/delete-information-dialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { toast } from "sonner"
-import { Loader, Trash2 } from "lucide-react"
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
-import { deleteInformation } from "@/lib/information/service"
-import type { PageInformation } from "@/db/schema/information"
-
-interface DeleteInformationDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- information?: PageInformation
- onClose: () => void
-}
-
-export function DeleteInformationDialog({
- open,
- onOpenChange,
- information,
- onClose,
-}: DeleteInformationDialogProps) {
- const router = useRouter()
- const [isLoading, setIsLoading] = React.useState(false)
-
- const handleDelete = async () => {
- if (!information) return
-
- setIsLoading(true)
- try {
- const result = await deleteInformation(information.id)
-
- if (result.success) {
- toast.success(result.message)
- onClose()
- router.refresh()
- } else {
- toast.error(result.message)
- }
- } catch (error) {
- toast.error("인포메이션 삭제에 실패했습니다.")
- console.error(error)
- } finally {
- setIsLoading(false)
- }
- }
-
- return (
- <AlertDialog open={open} onOpenChange={onOpenChange}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle className="flex items-center gap-2">
- <Trash2 className="h-5 w-5 text-destructive" />
- 인포메이션 삭제
- </AlertDialogTitle>
- <AlertDialogDescription>
- 다음 인포메이션을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
-
- {information && (
- <div className="bg-muted rounded-lg p-4 my-4">
- <div className="space-y-2">
- <div>
- <span className="font-medium text-sm">페이지 코드:</span>
- <span className="ml-2 font-mono text-sm">{information.pageCode}</span>
- </div>
- <div>
- <span className="font-medium text-sm">페이지명:</span>
- <span className="ml-2 text-sm">{information.pageName}</span>
- </div>
- <div>
- <span className="font-medium text-sm">제목:</span>
- <span className="ml-2 text-sm">{information.title}</span>
- </div>
- {information.noticeTitle && (
- <div>
- <span className="font-medium text-sm">공지사항 제목:</span>
- <span className="ml-2 text-sm">{information.noticeTitle}</span>
- </div>
- )}
- {information.noticeContent && (
- <div>
- <span className="font-medium text-sm">공지사항 내용:</span>
- <span className="ml-2 text-sm text-orange-600">{information.noticeContent}</span>
- </div>
- )}
- {information.attachmentFileName && (
- <div>
- <span className="font-medium text-sm">첨부파일:</span>
- <span className="ml-2 text-sm">{information.attachmentFileName}</span>
- </div>
- )}
- </div>
- </div>
- )}
-
- <AlertDialogFooter>
- <AlertDialogCancel onClick={onClose} disabled={isLoading}>
- 취소
- </AlertDialogCancel>
- <AlertDialogAction
- onClick={handleDelete}
- disabled={isLoading}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- 삭제
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )
-}
\ No newline at end of file diff --git a/lib/information/table/information-table-columns.tsx b/lib/information/table/information-table-columns.tsx deleted file mode 100644 index f84fd2f9..00000000 --- a/lib/information/table/information-table-columns.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client" - -import * as React from "react" -import type { ColumnDef } from "@tanstack/react-table" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import type { DataTableRowAction } from "@/types/table" -import type { PageInformation } from "@/db/schema/information" -import { formatDate } from "@/lib/utils" -import { Ellipsis, FileText, Download } from "lucide-react" -import { informationColumnsConfig } from "@/config/informationColumnsConfig" - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PageInformation> | null>> -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getInformationColumns({ setRowAction }: GetColumnsProps): ColumnDef<PageInformation>[] { - // // ---------------------------------------------------------------- - // // 1) Select 컬럼 (체크박스) - // // ---------------------------------------------------------------- - // const selectColumn: ColumnDef<PageInformation> = { - // id: "select", - // header: ({ table }) => ( - // <Checkbox - // checked={ - // table.getIsAllPageRowsSelected() || - // (table.getIsSomePageRowsSelected() && "indeterminate") - // } - // onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - // aria-label="Select all" - // className="translate-y-0.5" - // /> - // ), - // cell: ({ row }) => ( - // <Checkbox - // checked={row.getIsSelected()} - // onCheckedChange={(value) => row.toggleSelected(!!value)} - // aria-label="Select row" - // className="translate-y-0.5" - // /> - // ), - // enableSorting: false, - // enableHiding: false, - // } - - // ---------------------------------------------------------------- - // 2) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<PageInformation>[]> = {} - - informationColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<PageInformation> = { - accessorKey: cfg.id, - enableResizing: cfg.id === "description" || cfg.id === "noticeContent" ? false : true, - size: cfg.id === "description" || cfg.id === "noticeContent" ? 200 : undefined, - minSize: cfg.id === "description" || cfg.id === "noticeContent" ? 200 : undefined, - maxSize: cfg.id === "description" || cfg.id === "noticeContent" ? 200 : undefined, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ row }) => { - const value = row.getValue(cfg.id) - - if (cfg.id === "pageCode") { - return <div className=" text-sm">{value as string}</div> - } - - if (cfg.id === "pageName") { - return <div className="max-w-4 truncate font-medium">{value as string}</div> - } - - if (cfg.id === "title") { - return <div className="max-w-4 truncate">{value as string}</div> - } - - if (cfg.id === "description") { - return ( - <div className="truncate text-muted-foreground" style={{ width: '200px', maxWidth: '200px' }}> - {value as string} - </div> - ) - } - - if (cfg.id === "noticeTitle") { - const noticeTitle = value as string - if (!noticeTitle) { - return <span className="text-muted-foreground">-</span> - } - return <div className="max-w-xs truncate">{noticeTitle}</div> - } - - if (cfg.id === "noticeContent") { - const noticeContent = value as string - if (!noticeContent) { - return <span className="text-muted-foreground">-</span> - } - return ( - <div className="truncate text-muted-foreground" style={{ width: '200px', maxWidth: '200px' }}> - {noticeContent} - </div> - ) - } - - if (cfg.id === "attachmentFileName") { - const fileName = value as string - if (!fileName) { - return <span className="text-muted-foreground">-</span> - } - return ( - <div className="flex items-center gap-1"> - <FileText className="h-3 w-3" /> - <span className="text-sm truncate max-w-32" title={fileName}> - {fileName} - </span> - </div> - ) - } - - if (cfg.id === "isActive") { - return ( - <Badge variant={value ? "default" : "secondary"}> - {value ? "활성" : "비활성"} - </Badge> - ) - } - - if (cfg.id === "createdAt" || cfg.id === "updatedAt") { - const dateVal = value as Date - return formatDate(dateVal) - } - - return value ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // ---------------------------------------------------------------- - // 3) groupMap에서 실제 상위 컬럼(그룹)을 만들기 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<PageInformation>[] = [] - - // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 - // 여기서는 그냥 Object.entries 순서 - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - // 그룹 없음 → 그냥 최상위 레벨 컬럼 - nestedColumns.push(...colDefs) - } else { - // 상위 컬럼 - nestedColumns.push({ - id: groupName, - header: groupName, // "기본 정보", "공지사항" 등 - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 4) Actions 컬럼 - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<PageInformation> = { - id: "actions", - cell: ({ row }) => ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - 수정 - </DropdownMenuItem> - {row.original.attachmentFileName && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem - onSelect={() => { - if (row.original.attachmentFilePath) { - const link = document.createElement('a') - link.href = row.original.attachmentFilePath - link.download = row.original.attachmentFileName || '' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } - }} - > - <Download className="mr-2 h-4 w-4" /> - 다운로드 - </DropdownMenuItem> - </> - )} - <DropdownMenuSeparator /> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - className="text-destructive" - > - 삭제 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ), - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 5) 최종 컬럼 배열: select, nestedColumns, actions - // ---------------------------------------------------------------- - return [ - // selectColumn, - ...nestedColumns, - actionsColumn, - ] -}
\ No newline at end of file diff --git a/lib/information/table/information-table-toolbar-actions.tsx b/lib/information/table/information-table-toolbar-actions.tsx deleted file mode 100644 index 5d8fff3a..00000000 --- a/lib/information/table/information-table-toolbar-actions.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"
-
-import { type Table } from "@tanstack/react-table"
-import { Plus } from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import type { PageInformation } from "@/db/schema/information"
-
-interface InformationTableToolbarActionsProps {
- table: Table<PageInformation>
- onAdd: () => void
-}
-
-export function InformationTableToolbarActions({
- onAdd,
-}: InformationTableToolbarActionsProps) {
- return (
- <div className="flex items-center gap-2">
- <Button size="sm" onClick={onAdd}>
- <Plus className="mr-2 size-4" aria-hidden="true" />
- 인포메이션 추가
- </Button>
- </div>
- )
-}
\ No newline at end of file diff --git a/lib/information/table/information-table.tsx b/lib/information/table/information-table.tsx deleted file mode 100644 index 9fc4ec29..00000000 --- a/lib/information/table/information-table.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"
-
-import * as React from "react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import type { PageInformation } from "@/db/schema/information"
-import { getInformationColumns } from "./information-table-columns"
-import { InformationTableToolbarActions } from "./information-table-toolbar-actions"
-import { AddInformationDialog } from "./add-information-dialog"
-import { UpdateInformationDialog } from "./update-information-dialog"
-import { DeleteInformationDialog } from "./delete-information-dialog"
-
-interface InformationTableProps {
- promises: Promise<{
- data: PageInformation[]
- pageCount: number
- total: number
- }>
-}
-
-export function InformationTable({ promises }: InformationTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<PageInformation> | null>(null)
- const [showAddDialog, setShowAddDialog] = React.useState(false)
- const [showUpdateDialog, setShowUpdateDialog] = React.useState(false)
- const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
-
- const { data, pageCount } = React.use(promises)
-
- // 컬럼 설정
- const columns = React.useMemo(
- () => getInformationColumns({ setRowAction }),
- [setRowAction]
- )
-
- const filterFields: DataTableFilterField<PageInformation>[] = []
-
- // 고급 필터 필드 설정
- const advancedFilterFields: DataTableAdvancedFilterField<PageInformation>[] = [
- {
- id: "pageCode",
- label: "페이지 코드",
- type: "text",
- },
- {
- id: "pageName",
- label: "페이지명",
- type: "text",
- },
- {
- id: "title",
- label: "제목",
- type: "text",
- },
- {
- id: "isActive",
- label: "상태",
- type: "select",
- options: [
- { label: "활성", value: "true" },
- { label: "비활성", value: "false" },
- ],
- },
- {
- id: "createdAt",
- label: "생성일",
- type: "date",
- },
- {
- id: "updatedAt",
- label: "수정일",
- type: "date",
- },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- // 행 액션 처리
- React.useEffect(() => {
- if (rowAction?.type === "update") {
- setShowUpdateDialog(true)
- } else if (rowAction?.type === "delete") {
- setShowDeleteDialog(true)
- }
- }, [rowAction])
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <InformationTableToolbarActions
- table={table}
- onAdd={() => setShowAddDialog(true)}
- />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <AddInformationDialog
- open={showAddDialog}
- onOpenChange={setShowAddDialog}
- />
-
- <UpdateInformationDialog
- open={showUpdateDialog}
- onOpenChange={setShowUpdateDialog}
- information={rowAction?.row.original}
- onClose={() => {
- setShowUpdateDialog(false)
- setRowAction(null)
- }}
- />
-
- <DeleteInformationDialog
- open={showDeleteDialog}
- onOpenChange={setShowDeleteDialog}
- information={rowAction?.row.original}
- onClose={() => {
- setShowDeleteDialog(false)
- setRowAction(null)
- }}
- />
- </>
- )
-}
\ No newline at end of file diff --git a/lib/information/table/update-information-dialog.tsx b/lib/information/table/update-information-dialog.tsx index afa7559b..ed749fe7 100644 --- a/lib/information/table/update-information-dialog.tsx +++ b/lib/information/table/update-information-dialog.tsx @@ -24,7 +24,7 @@ import { FormLabel,
FormMessage,
} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
+
import { Textarea } from "@/components/ui/textarea"
import { Switch } from "@/components/ui/switch"
import { updateInformationData } from "@/lib/information/service"
@@ -35,14 +35,14 @@ interface UpdateInformationDialogProps { open: boolean
onOpenChange: (open: boolean) => void
information?: PageInformation
- onClose: () => void
+ onSuccess?: () => void
}
export function UpdateInformationDialog({
open,
onOpenChange,
information,
- onClose,
+ onSuccess,
}: UpdateInformationDialogProps) {
const router = useRouter()
const [isLoading, setIsLoading] = React.useState(false)
@@ -52,12 +52,10 @@ export function UpdateInformationDialog({ resolver: zodResolver(updateInformationSchema),
defaultValues: {
id: 0,
- pageCode: "",
- pageName: "",
- title: "",
- description: "",
- noticeTitle: "",
- noticeContent: "",
+ informationContent: "",
+ attachmentFileName: "",
+ attachmentFilePath: "",
+ attachmentFileSize: "",
isActive: true,
},
})
@@ -67,12 +65,7 @@ export function UpdateInformationDialog({ if (information && open) {
form.reset({
id: information.id,
- pageCode: information.pageCode,
- pageName: information.pageName,
- title: information.title,
- description: information.description,
- noticeTitle: information.noticeTitle || "",
- noticeContent: information.noticeContent || "",
+ informationContent: information.informationContent || "",
attachmentFileName: information.attachmentFileName || "",
attachmentFilePath: information.attachmentFilePath || "",
attachmentFileSize: information.attachmentFileSize || "",
@@ -131,7 +124,8 @@ export function UpdateInformationDialog({ if (result.success) {
toast.success(result.message)
- onClose()
+ if (onSuccess) onSuccess()
+ onOpenChange(false)
router.refresh()
} else {
toast.error(result.message)
@@ -146,7 +140,7 @@ export function UpdateInformationDialog({ const handleClose = () => {
setUploadedFile(null)
- onClose()
+ onOpenChange(false)
}
const currentFileName = form.watch("attachmentFileName")
@@ -163,92 +157,26 @@ export function UpdateInformationDialog({ <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="pageCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>페이지 코드</FormLabel>
- <FormControl>
- <Input placeholder="예: vendor-list" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="pageName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>페이지명</FormLabel>
- <FormControl>
- <Input placeholder="예: 협력업체 목록" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ <div className="bg-blue-50 p-4 rounded-lg">
+ <div className="flex items-center gap-2 mb-2">
+ <span className="font-medium text-blue-900">페이지 정보</span>
+ </div>
+ <div className="text-sm text-blue-700">
+ <div><strong>페이지명:</strong> {information?.pageName}</div>
+ <div><strong>경로:</strong> {information?.pagePath}</div>
+ </div>
</div>
<FormField
control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목</FormLabel>
- <FormControl>
- <Input placeholder="인포메이션 제목을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설명</FormLabel>
- <FormControl>
- <Textarea
- placeholder="페이지 설명을 입력하세요"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="noticeTitle"
- render={({ field }) => (
- <FormItem>
- <FormLabel>공지사항 제목 (선택사항)</FormLabel>
- <FormControl>
- <Input placeholder="공지사항 제목을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="noticeContent"
+ name="informationContent"
render={({ field }) => (
<FormItem>
- <FormLabel>공지사항 내용 (선택사항)</FormLabel>
+ <FormLabel>인포메이션 내용</FormLabel>
<FormControl>
<Textarea
- placeholder="공지사항 내용을 입력하세요"
- rows={3}
+ placeholder="인포메이션 내용을 입력하세요"
+ rows={6}
{...field}
/>
</FormControl>
@@ -267,7 +195,7 @@ export function UpdateInformationDialog({ <span className="text-xs text-gray-500">
({(uploadedFile.size / (1024 * 1024)).toFixed(2)} MB)
</span>
- <span className="text-xs text-blue-600">(새 파일)</span>
+ <span className="text-xs">(새 파일)</span>
</div>
<Button
type="button"
@@ -291,7 +219,7 @@ export function UpdateInformationDialog({ <div className="flex gap-2">
<label
htmlFor="file-upload-update"
- className="cursor-pointer text-sm text-blue-600 hover:text-blue-500"
+ className="cursor-pointer text-sm"
>
변경
</label>
@@ -312,7 +240,7 @@ export function UpdateInformationDialog({ <div className="mt-2">
<label
htmlFor="file-upload-update"
- className="cursor-pointer text-sm text-blue-600 hover:text-blue-500"
+ className="cursor-pointer text-sm"
>
파일을 선택하세요
</label>
diff --git a/lib/information/validations.ts b/lib/information/validations.ts index 216e3354..c4f5d530 100644 --- a/lib/information/validations.ts +++ b/lib/information/validations.ts @@ -10,29 +10,10 @@ import { import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
import { PageInformation } from "@/db/schema/information"
-// 인포메이션 생성 스키마
-export const createInformationSchema = z.object({
- pageCode: z.string().min(1, "페이지 코드를 입력해주세요"),
- pageName: z.string().min(1, "페이지명을 입력해주세요"),
- title: z.string().min(1, "제목을 입력해주세요"),
- description: z.string().min(1, "설명을 입력해주세요"),
- noticeTitle: z.string().optional(),
- noticeContent: z.string().optional(),
- attachmentFileName: z.string().optional(),
- attachmentFilePath: z.string().optional(),
- attachmentFileSize: z.string().optional(),
- isActive: z.boolean().default(true),
-})
-
// 인포메이션 수정 스키마
export const updateInformationSchema = z.object({
id: z.number(),
- pageCode: z.string().min(1, "페이지 코드를 입력해주세요"),
- pageName: z.string().min(1, "페이지명을 입력해주세요"),
- title: z.string().min(1, "제목을 입력해주세요"),
- description: z.string().min(1, "설명을 입력해주세요"),
- noticeTitle: z.string().optional(),
- noticeContent: z.string().optional(),
+ informationContent: z.string().min(1, "내용을 입력해주세요"),
attachmentFileName: z.string().optional(),
attachmentFilePath: z.string().optional(),
attachmentFileSize: z.string().optional(),
@@ -49,9 +30,9 @@ export const searchParamsInformationCache = createSearchParamsCache({ ]),
// 기본 검색 필드들
- pageCode: parseAsString.withDefault(""),
+ pagePath: parseAsString.withDefault(""),
pageName: parseAsString.withDefault(""),
- title: parseAsString.withDefault(""),
+ informationContent: parseAsString.withDefault(""),
isActive: parseAsBoolean,
// 고급 필터
@@ -65,7 +46,6 @@ export const searchParamsInformationCache = createSearchParamsCache({ })
// 타입 추출
-export type CreateInformationSchema = z.infer<typeof createInformationSchema>
export type UpdateInformationSchema = z.infer<typeof updateInformationSchema>
export type GetInformationSchema = Awaited<ReturnType<typeof searchParamsInformationCache.parse>>
@@ -74,16 +54,14 @@ export const getInformationSchema = z.object({ page: z.coerce.number().default(1),
per_page: z.coerce.number().default(10),
sort: z.string().optional(),
- pageCode: z.string().optional(),
- pageName: z.string().optional(),
isActive: z.coerce.boolean().optional(),
from: z.string().optional(),
to: z.string().optional(),
})
-// 페이지 코드별 인포메이션 조회 스키마
+// 페이지 경로별 인포메이션 조회 스키마
export const getPageInformationSchema = z.object({
- pageCode: z.string().min(1, "페이지 코드를 입력해주세요"),
+ pagePath: z.string().min(1, "페이지 경로를 입력해주세요"),
})
export type GetPageInformationSchema = z.infer<typeof getPageInformationSchema>
\ No newline at end of file diff --git a/lib/notice/repository.ts b/lib/notice/repository.ts new file mode 100644 index 00000000..84e64f00 --- /dev/null +++ b/lib/notice/repository.ts @@ -0,0 +1,244 @@ +import { asc, desc, eq, ilike, and, count, sql } from "drizzle-orm"
+import db from "@/db/db"
+import { notice, users, type Notice, type NewNotice } from "@/db/schema"
+
+// 최신 패턴: 트랜잭션을 지원하는 공지사항 조회
+export async function selectNoticeLists(
+ tx: typeof db,
+ params: {
+ where?: ReturnType<typeof and>
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]
+ offset?: number
+ limit?: number
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params
+
+ return tx
+ .select({
+ id: notice.id,
+ pagePath: notice.pagePath,
+ title: notice.title,
+ content: notice.content,
+ authorId: notice.authorId,
+ isActive: notice.isActive,
+ createdAt: notice.createdAt,
+ updatedAt: notice.updatedAt,
+ authorName: users.name,
+ authorEmail: users.email,
+ })
+ .from(notice)
+ .leftJoin(users, eq(notice.authorId, users.id))
+ .where(where)
+ .orderBy(...(orderBy ?? [desc(notice.createdAt)]))
+ .offset(offset)
+ .limit(limit)
+}
+
+// 최신 패턴: 트랜잭션을 지원하는 카운트 조회
+export async function countNoticeLists(
+ tx: typeof db,
+ where?: ReturnType<typeof and>
+) {
+ const res = await tx
+ .select({ count: count() })
+ .from(notice)
+ .where(where)
+
+ return res[0]?.count ?? 0
+}
+
+// 기존 패턴 (하위 호환성을 위해 유지)
+export async function selectNotice(input: { page: number; per_page: number; sort?: string; pagePath?: string; title?: string; authorId?: number; isActive?: boolean; from?: string; to?: string }) {
+ const { page, per_page = 50, sort, pagePath, title, authorId, isActive, from, to } = input
+
+ const conditions = []
+
+ if (pagePath) {
+ conditions.push(ilike(notice.pagePath, `%${pagePath}%`))
+ }
+
+ if (title) {
+ conditions.push(ilike(notice.title, `%${title}%`))
+ }
+
+ if (authorId) {
+ conditions.push(eq(notice.authorId, authorId))
+ }
+
+ if (isActive !== null && isActive !== undefined) {
+ conditions.push(eq(notice.isActive, isActive))
+ }
+
+ if (from) {
+ conditions.push(sql`${notice.createdAt} >= ${from}`)
+ }
+
+ if (to) {
+ conditions.push(sql`${notice.createdAt} <= ${to}`)
+ }
+
+ const offset = (page - 1) * per_page
+
+ // 정렬 설정
+ let orderBy = desc(notice.createdAt);
+
+ if (sort && Array.isArray(sort) && sort.length > 0) {
+ const sortItem = sort[0];
+ if (sortItem.id === "createdAt") {
+ orderBy = sortItem.desc ? desc(notice.createdAt) : asc(notice.createdAt);
+ }
+ }
+
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined
+
+ const data = await db
+ .select({
+ id: notice.id,
+ pagePath: notice.pagePath,
+ title: notice.title,
+ content: notice.content,
+ authorId: notice.authorId,
+ isActive: notice.isActive,
+ createdAt: notice.createdAt,
+ updatedAt: notice.updatedAt,
+ authorName: users.name,
+ authorEmail: users.email,
+ })
+ .from(notice)
+ .leftJoin(users, eq(notice.authorId, users.id))
+ .where(whereClause)
+ .orderBy(orderBy)
+ .limit(per_page)
+ .offset(offset)
+
+ return data
+}
+
+// 기존 패턴: 공지사항 총 개수 조회
+export async function countNotice(input: { pagePath?: string; title?: string; authorId?: number; isActive?: boolean; from?: string; to?: string }) {
+ const { pagePath, title, authorId, isActive, from, to } = input
+
+ const conditions = []
+
+ if (pagePath) {
+ conditions.push(ilike(notice.pagePath, `%${pagePath}%`))
+ }
+
+ if (title) {
+ conditions.push(ilike(notice.title, `%${title}%`))
+ }
+
+ if (authorId) {
+ conditions.push(eq(notice.authorId, authorId))
+ }
+
+ if (isActive !== null && isActive !== undefined) {
+ conditions.push(eq(notice.isActive, isActive))
+ }
+
+ if (from) {
+ conditions.push(sql`${notice.createdAt} >= ${from}`)
+ }
+
+ if (to) {
+ conditions.push(sql`${notice.createdAt} <= ${to}`)
+ }
+
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined
+
+ const result = await db
+ .select({ count: count() })
+ .from(notice)
+ .where(whereClause)
+
+ return result[0]?.count ?? 0
+}
+
+// 페이지 경로별 공지사항 조회 (활성화된 것만, 작성자 정보 포함)
+export async function getNoticesByPagePath(pagePath: string): Promise<Array<Notice & { authorName: string | null; authorEmail: string | null }>> {
+ const result = await db
+ .select({
+ id: notice.id,
+ pagePath: notice.pagePath,
+ title: notice.title,
+ content: notice.content,
+ authorId: notice.authorId,
+ isActive: notice.isActive,
+ createdAt: notice.createdAt,
+ updatedAt: notice.updatedAt,
+ authorName: users.name,
+ authorEmail: users.email,
+ })
+ .from(notice)
+ .leftJoin(users, eq(notice.authorId, users.id))
+ .where(and(
+ eq(notice.pagePath, pagePath),
+ eq(notice.isActive, true)
+ ))
+ .orderBy(desc(notice.createdAt))
+
+ return result
+}
+
+// 공지사항 생성
+export async function insertNotice(data: NewNotice): Promise<Notice> {
+ const result = await db
+ .insert(notice)
+ .values(data)
+ .returning()
+
+ return result[0]
+}
+
+// 공지사항 수정
+export async function updateNotice(id: number, data: Partial<NewNotice>): Promise<Notice | null> {
+ const result = await db
+ .update(notice)
+ .set({ ...data, updatedAt: new Date() })
+ .where(eq(notice.id, id))
+ .returning()
+
+ return result[0] || null
+}
+
+// 공지사항 삭제
+export async function deleteNoticeById(id: number): Promise<boolean> {
+ const result = await db
+ .delete(notice)
+ .where(eq(notice.id, id))
+
+ return (result.rowCount ?? 0) > 0
+}
+
+// 공지사항 다중 삭제
+export async function deleteNoticeByIds(ids: number[]): Promise<number> {
+ const result = await db
+ .delete(notice)
+ .where(sql`${notice.id} = ANY(${ids})`)
+
+ return result.rowCount ?? 0
+}
+
+// ID로 공지사항 조회 (작성자 정보 포함)
+export async function getNoticeById(id: number): Promise<(Notice & { authorName: string | null; authorEmail: string | null }) | null> {
+ const result = await db
+ .select({
+ id: notice.id,
+ pagePath: notice.pagePath,
+ title: notice.title,
+ content: notice.content,
+ authorId: notice.authorId,
+ isActive: notice.isActive,
+ createdAt: notice.createdAt,
+ updatedAt: notice.updatedAt,
+ authorName: users.name,
+ authorEmail: users.email,
+ })
+ .from(notice)
+ .leftJoin(users, eq(notice.authorId, users.id))
+ .where(eq(notice.id, id))
+ .limit(1)
+
+ return result[0] || null
+}
\ No newline at end of file diff --git a/lib/notice/service.ts b/lib/notice/service.ts new file mode 100644 index 00000000..24b03fe9 --- /dev/null +++ b/lib/notice/service.ts @@ -0,0 +1,324 @@ +"use server"
+
+import { revalidateTag, unstable_noStore } from "next/cache"
+import { getErrorMessage } from "@/lib/handle-error"
+import { unstable_cache } from "@/lib/unstable-cache"
+import { filterColumns } from "@/lib/filter-columns"
+import { asc, desc, ilike, and, or, eq } from "drizzle-orm"
+import db from "@/db/db"
+import { notice, pageInformation } from "@/db/schema"
+
+import type {
+ CreateNoticeSchema,
+ UpdateNoticeSchema,
+ GetNoticeSchema
+} from "./validations"
+
+import {
+ selectNotice,
+ countNotice,
+ getNoticesByPagePath,
+ insertNotice,
+ updateNotice,
+ deleteNoticeById,
+ deleteNoticeByIds,
+ getNoticeById,
+ selectNoticeLists,
+ countNoticeLists
+} from "./repository"
+
+import type { Notice } from "@/db/schema/notice"
+
+export async function getNoticeLists(input: GetNoticeSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 고급 검색 로직
+ const { page, perPage, search, filters, joinOperator, pagePath, title, content, authorId, isActive } = input
+
+ // 기본 검색 조건들
+ const conditions = []
+
+ // 검색어가 있으면 여러 필드에서 검색
+ if (search && search.trim()) {
+ const searchConditions = [
+ ilike(notice.pagePath, `%${search}%`),
+ ilike(notice.title, `%${search}%`),
+ ilike(notice.content, `%${search}%`)
+ ]
+ conditions.push(or(...searchConditions))
+ }
+
+ // 개별 필드 조건들
+ if (pagePath && pagePath.trim()) {
+ conditions.push(ilike(notice.pagePath, `%${pagePath}%`))
+ }
+
+ if (title && title.trim()) {
+ conditions.push(ilike(notice.title, `%${title}%`))
+ }
+
+ if (content && content.trim()) {
+ conditions.push(ilike(notice.content, `%${content}%`))
+ }
+
+ if (authorId !== null && authorId !== undefined) {
+ conditions.push(eq(notice.authorId, authorId))
+ }
+
+ if (isActive !== null && isActive !== undefined) {
+ conditions.push(eq(notice.isActive, isActive))
+ }
+ // 고급 필터 처리
+ if (filters && filters.length > 0) {
+ const advancedConditions = filters.map(() =>
+ filterColumns({
+ table: notice,
+ filters: filters,
+ joinOperator: joinOperator,
+ })
+ )
+
+ if (advancedConditions.length > 0) {
+ if (joinOperator === "or") {
+ conditions.push(or(...advancedConditions))
+ } else {
+ conditions.push(and(...advancedConditions))
+ }
+ }
+ }
+
+ // 전체 WHERE 조건 조합
+ const finalWhere = conditions.length > 0
+ ? (joinOperator === "or" ? or(...conditions) : and(...conditions))
+ : undefined
+
+ // 페이지네이션
+ const offset = (page - 1) * perPage
+
+ // 정렬 처리
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) => {
+ if (item.id === "createdAt") {
+ return item.desc ? desc(notice.createdAt) : asc(notice.createdAt)
+ } else if (item.id === "updatedAt") {
+ return item.desc ? desc(notice.updatedAt) : asc(notice.updatedAt)
+ } else if (item.id === "pagePath") {
+ return item.desc ? desc(notice.pagePath) : asc(notice.pagePath)
+ } else if (item.id === "title") {
+ return item.desc ? desc(notice.title) : asc(notice.title)
+ } else if (item.id === "authorId") {
+ return item.desc ? desc(notice.authorId) : asc(notice.authorId)
+ } else if (item.id === "isActive") {
+ return item.desc ? desc(notice.isActive) : asc(notice.isActive)
+ } else {
+ return desc(notice.createdAt) // 기본값
+ }
+ })
+ : [desc(notice.createdAt)]
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectNoticeLists(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ })
+
+ const total = await countNoticeLists(tx, finalWhere)
+ return { data, total }
+ })
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+ } catch (err) {
+ console.error("Failed to get notice lists:", err)
+ // 에러 발생 시 기본값 반환
+ return { data: [], pageCount: 0, total: 0 }
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["notice-lists"],
+ }
+ )()
+}
+
+// 기존 패턴 (하위 호환성을 위해 유지)
+export async function getNoticeList(input: Partial<{ page: number; per_page: number; sort?: string; pagePath?: string; title?: string; authorId?: number; isActive?: boolean; from?: string; to?: string }> & { page: number; per_page: number }) {
+ unstable_noStore()
+
+ try {
+ const [data, total] = await Promise.all([
+ selectNotice(input),
+ countNotice(input)
+ ])
+
+ const pageCount = Math.ceil(total / input.per_page)
+
+ return {
+ data,
+ pageCount,
+ total
+ }
+ } catch (error) {
+ console.error("Failed to get notice list:", error)
+ throw new Error(getErrorMessage(error))
+ }
+}
+
+// 페이지별 공지사항 조회 (일반 사용자용)
+export async function getPageNotices(pagePath: string): Promise<Array<Notice & { authorName: string | null; authorEmail: string | null }>> {
+ try {
+ return await getNoticesByPagePath(pagePath)
+ } catch (error) {
+ console.error(`Failed to get notices for page ${pagePath}:`, error)
+ return []
+ }
+}
+
+// 캐시된 페이지별 공지사항 조회
+export const getCachedPageNotices = unstable_cache(
+ async (pagePath: string) => getPageNotices(pagePath),
+ ["page-notices"],
+ {
+ tags: ["page-notices"],
+ revalidate: 3600, // 1시간 캐시
+ }
+)
+
+// 공지사항 생성
+export async function createNotice(input: CreateNoticeSchema) {
+ try {
+ const result = await insertNotice(input)
+
+ revalidateTag("page-notices")
+ revalidateTag("notice-lists")
+
+ return {
+ success: true,
+ data: result,
+ message: "공지사항이 성공적으로 생성되었습니다."
+ }
+ } catch (error) {
+ console.error("Failed to create notice:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+}
+
+// 공지사항 수정
+export async function updateNoticeData(input: UpdateNoticeSchema) {
+ try {
+ const { id, ...updateData } = input
+ const result = await updateNotice(id, updateData)
+
+ if (!result) {
+ return {
+ success: false,
+ message: "공지사항을 찾을 수 없거나 수정에 실패했습니다."
+ }
+ }
+
+ revalidateTag("page-notices")
+ revalidateTag("notice-lists")
+
+ return {
+ success: true,
+ message: "공지사항이 성공적으로 수정되었습니다."
+ }
+ } catch (error) {
+ console.error("Failed to update notice:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+}
+
+// 공지사항 삭제
+export async function deleteNotice(id: number) {
+ try {
+ const success = await deleteNoticeById(id)
+
+ if (!success) {
+ return {
+ success: false,
+ message: "공지사항을 찾을 수 없거나 삭제에 실패했습니다."
+ }
+ }
+
+ revalidateTag("page-notices")
+ revalidateTag("notice-lists")
+
+ return {
+ success: true,
+ message: "공지사항이 성공적으로 삭제되었습니다."
+ }
+ } catch (error) {
+ console.error("Failed to delete notice:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+}
+
+// 공지사항 다중 삭제
+export async function deleteMultipleNotices(ids: number[]) {
+ try {
+ const deletedCount = await deleteNoticeByIds(ids)
+
+ revalidateTag("page-notices")
+ revalidateTag("notice-lists")
+
+ return {
+ success: true,
+ deletedCount,
+ message: `${deletedCount}개의 공지사항이 성공적으로 삭제되었습니다.`
+ }
+ } catch (error) {
+ console.error("Failed to delete multiple notices:", error)
+ return {
+ success: false,
+ message: getErrorMessage(error)
+ }
+ }
+}
+
+// ID로 공지사항 조회
+export async function getNoticeDetail(id: number): Promise<(Notice & { authorName: string | null; authorEmail: string | null }) | null> {
+ try {
+ return await getNoticeById(id)
+ } catch (error) {
+ console.error(`Failed to get notice detail for id ${id}:`, error)
+ return null
+ }
+}
+
+// pagePath 목록 조회 (정보 시스템에서 사용)
+export async function getPagePathList(): Promise<Array<{ pagePath: string; pageName: string }>> {
+ try {
+ const result = await db
+ .selectDistinct({
+ pagePath: pageInformation.pagePath,
+ pageName: pageInformation.pageName
+ })
+ .from(pageInformation)
+ .where(eq(pageInformation.isActive, true))
+ .orderBy(asc(pageInformation.pagePath))
+
+ return result.map(item => ({
+ pagePath: item.pagePath,
+ pageName: item.pageName || item.pagePath
+ }))
+ } catch (error) {
+ console.error("Failed to get page path list:", error)
+ return []
+ }
+}
\ No newline at end of file diff --git a/lib/notice/validations.ts b/lib/notice/validations.ts new file mode 100644 index 00000000..05e84af9 --- /dev/null +++ b/lib/notice/validations.ts @@ -0,0 +1,80 @@ +import { z } from "zod"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+ parseAsBoolean,
+} from "nuqs/server"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Notice } from "@/db/schema/notice"
+
+// 공지사항 생성 스키마
+export const createNoticeSchema = z.object({
+ pagePath: z.string().min(1, "페이지 경로를 입력해주세요"),
+ title: z.string().min(1, "제목을 입력해주세요"),
+ content: z.string().min(1, "내용을 입력해주세요"),
+ authorId: z.number().min(1, "작성자를 선택해주세요"),
+ isActive: z.boolean().default(true),
+})
+
+// 공지사항 수정 스키마
+export const updateNoticeSchema = z.object({
+ id: z.number(),
+ pagePath: z.string().min(1, "페이지 경로를 입력해주세요"),
+ title: z.string().min(1, "제목을 입력해주세요"),
+ content: z.string().min(1, "내용을 입력해주세요"),
+ isActive: z.boolean().default(true),
+})
+
+// 현대적인 검색 파라미터 캐시
+export const searchParamsNoticeCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Notice>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 기본 검색 필드들
+ pagePath: parseAsString.withDefault(""),
+ title: parseAsString.withDefault(""),
+ content: parseAsString.withDefault(""),
+ authorId: parseAsInteger,
+ isActive: parseAsBoolean,
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+ // 날짜 범위
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+})
+
+// 타입 추출
+export type CreateNoticeSchema = z.infer<typeof createNoticeSchema>
+export type UpdateNoticeSchema = z.infer<typeof updateNoticeSchema>
+export type GetNoticeSchema = Awaited<ReturnType<typeof searchParamsNoticeCache.parse>>
+
+// 기존 스키마 (하위 호환성을 위해 유지)
+export const getNoticeSchema = z.object({
+ page: z.coerce.number().default(1),
+ per_page: z.coerce.number().default(10),
+ sort: z.string().optional(),
+ pagePath: z.string().optional(),
+ title: z.string().optional(),
+ authorId: z.coerce.number().optional(),
+ isActive: z.coerce.boolean().optional(),
+ from: z.string().optional(),
+ to: z.string().optional(),
+})
+
+// 페이지 경로별 공지사항 조회 스키마
+export const getPageNoticeSchema = z.object({
+ pagePath: z.string().min(1, "페이지 경로를 입력해주세요"),
+})
+
+export type GetPageNoticeSchema = z.infer<typeof getPageNoticeSchema>
\ No newline at end of file |
