summaryrefslogtreecommitdiff
path: root/lib/cover
diff options
context:
space:
mode:
Diffstat (limited to 'lib/cover')
-rw-r--r--lib/cover/repository.ts44
-rw-r--r--lib/cover/service.ts123
-rw-r--r--lib/cover/table/cover-template-dialog.tsx455
-rw-r--r--lib/cover/table/projects-table-columns.tsx187
-rw-r--r--lib/cover/table/projects-table-toolbar-actions.tsx50
-rw-r--r--lib/cover/table/projects-table.tsx114
-rw-r--r--lib/cover/validation.ts36
7 files changed, 1009 insertions, 0 deletions
diff --git a/lib/cover/repository.ts b/lib/cover/repository.ts
new file mode 100644
index 00000000..62b70778
--- /dev/null
+++ b/lib/cover/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { projects } from "@/db/schema";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export async function selectProjectLists(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+ ) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(projects)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+/** 총 개수 count */
+export async function countProjectLists(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(projects).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/cover/service.ts b/lib/cover/service.ts
new file mode 100644
index 00000000..91ea3458
--- /dev/null
+++ b/lib/cover/service.ts
@@ -0,0 +1,123 @@
+"use server";
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { countProjectLists, selectProjectLists } from "./repository";
+import { projects, projectCoverTemplates, generatedCoverPages } from "@/db/schema";
+import { GetProjectListsSchema } from "./validation";
+
+export async function getProjectListsForCover(input: GetProjectListsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+ const advancedTable = true;
+
+ const advancedWhere = filterColumns({
+ table: projects,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(projects.name, s),
+ ilike(projects.code, s),
+ ilike(projects.type, s),
+ )
+ }
+
+ const finalWhere = and(
+ eq(projects.type, "plant"),
+ advancedWhere,
+ globalWhere
+ )
+
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(projects[item.id]) : asc(projects[item.id])
+ )
+ : [asc(projects.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const projectData = await selectProjectLists(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 프로젝트 ID 목록 추출
+ const projectIds = projectData.map(p => p.id);
+
+ // 활성 템플릿 정보 조회
+ const templates = projectIds.length > 0
+ ? await tx
+ .select()
+ .from(projectCoverTemplates)
+ .where(
+ and(
+ inArray(projectCoverTemplates.projectId, projectIds),
+ eq(projectCoverTemplates.isActive, true)
+ )
+ )
+ : [];
+
+ // 템플릿 맵 생성
+ const templateMap = new Map(
+ templates.map(t => [t.projectId, t])
+ );
+
+ // 생성된 커버 페이지 조회 (각 템플릿의 최신 것만)
+ const templateIds = templates.map(t => t.id);
+ const generatedCovers = templateIds.length > 0
+ ? await tx
+ .select()
+ .from(generatedCoverPages)
+ .where(inArray(generatedCoverPages.templateId, templateIds))
+ .orderBy(desc(generatedCoverPages.generatedAt))
+ : [];
+
+ // 각 템플릿별 최신 생성 커버 맵 생성
+ const latestCoverMap = new Map();
+ for (const cover of generatedCovers) {
+ if (!latestCoverMap.has(cover.templateId)) {
+ latestCoverMap.set(cover.templateId, cover);
+ }
+ }
+
+ // 프로젝트에 템플릿 및 생성된 커버 정보 병합
+ const data = projectData.map(project => {
+ const template = templateMap.get(project.id);
+ const latestCover = template ? latestCoverMap.get(template.id) : null;
+
+ return {
+ ...project,
+ coverTemplatePath: template?.filePath || null,
+ templateVariables: template?.variables || null,
+ template: template || null,
+ generatedCover: latestCover || null,
+ generatedCoverPath: latestCover?.filePath || null,
+ };
+ });
+
+ const total = await countProjectLists(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("❌ getProjectListsForCover 오류:", err);
+ return { data: [], pageCount: 0 };
+ }
+} \ No newline at end of file
diff --git a/lib/cover/table/cover-template-dialog.tsx b/lib/cover/table/cover-template-dialog.tsx
new file mode 100644
index 00000000..f5ac3fae
--- /dev/null
+++ b/lib/cover/table/cover-template-dialog.tsx
@@ -0,0 +1,455 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Upload, Save, Download, Copy, Check, Loader2 } from "lucide-react"
+import { WebViewerInstance } from "@pdftron/webviewer"
+import { Project } from "@/db/schema"
+import { toast } from "sonner"
+import { quickDownload } from "@/lib/file-download"
+import { BasicContractTemplateViewer } from "@/lib/basic-contract/template/basic-contract-template-viewer"
+import { useRouter, usePathname } from "next/navigation"
+
+interface CoverTemplateDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ project: Project | null
+}
+
+export function CoverTemplateDialog({ open, onOpenChange, project }: CoverTemplateDialogProps) {
+ const [instance, setInstance] = React.useState<WebViewerInstance | null>(null)
+ const [filePath, setFilePath] = React.useState<string>("")
+ const [uploadedFile, setUploadedFile] = React.useState<File | null>(null)
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [copiedVar, setCopiedVar] = React.useState<string | null>(null)
+ const router = useRouter()
+
+ // 필수 템플릿 변수
+ const templateVariables = [
+ { key: "docNumber", value: "docNumber", label: "문서 번호" },
+ { key: "projectNumber", value: "projectNumber", label: "프로젝트 번호" },
+ { key: "projectName", value: "projectName", label: "프로젝트명" }
+ ]
+
+ // instance 상태 모니터링
+ React.useEffect(() => {
+ console.log("🔍 Instance 상태:", instance ? "있음" : "없음");
+ }, [instance]);
+
+ // 다이얼로그가 열릴 때마다 상태 초기화 및 템플릿 로드
+ React.useEffect(() => {
+ if (open) {
+ // instance는 초기화하지 않음 - 뷰어가 알아서 설정함
+ setUploadedFile(null)
+ setIsSaving(false)
+ setIsUploading(false)
+ setCopiedVar(null)
+
+ // 프로젝트에 저장된 템플릿이 있으면 로드
+ if (project?.coverTemplatePath) {
+ setFilePath(project.coverTemplatePath)
+ } else {
+ setFilePath("")
+ }
+ } else {
+ // 다이얼로그가 닫힐 때만 완전히 초기화
+ setFilePath("")
+ setInstance(null)
+ setUploadedFile(null)
+ }
+ }, [open, project])
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ if (!file.name.endsWith('.docx')) {
+ toast.error("DOCX 파일만 업로드 가능합니다")
+ return
+ }
+
+ setIsUploading(true)
+ setUploadedFile(file)
+
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("projectId", String(project?.id))
+
+ try {
+ const response = await fetch("/api/projects/cover-template/upload", {
+ method: "POST",
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || "업로드 실패")
+ }
+
+ const data = await response.json()
+ setFilePath(data.filePath)
+ router.refresh()
+ toast.success("템플릿 파일이 업로드되었습니다")
+ } catch (error) {
+ console.error("파일 업로드 오류:", error)
+ toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다")
+ } finally {
+ setIsUploading(false)
+ }
+ }
+
+ // 복사 함수 - 더 강력한 버전
+ const copyToClipboard = async (text: string, key: string) => {
+ let copySuccess = false;
+
+ // 방법 1: 최신 Clipboard API (가장 확실함)
+ try {
+ await navigator.clipboard.writeText(text);
+ copySuccess = true;
+ setCopiedVar(key);
+ setTimeout(() => setCopiedVar(null), 2000);
+ toast.success(`복사됨: ${text}`, {
+ description: "문서에 붙여넣으세요 (Ctrl+V)"
+ });
+ return;
+ } catch (err) {
+ console.error("Clipboard API 실패:", err);
+ }
+
+ // 방법 2: 이벤트 기반 복사 (사용자 상호작용 컨텍스트 유지)
+ try {
+ const listener = (e: ClipboardEvent) => {
+ e.clipboardData?.setData('text/plain', text);
+ e.preventDefault();
+ copySuccess = true;
+ };
+
+ document.addEventListener('copy', listener);
+ const result = document.execCommand('copy');
+ document.removeEventListener('copy', listener);
+
+ if (result && copySuccess) {
+ setCopiedVar(key);
+ setTimeout(() => setCopiedVar(null), 2000);
+ toast.success(`복사됨: ${text}`, {
+ description: "문서에 붙여넣으세요 (Ctrl+V)"
+ });
+ return;
+ }
+ } catch (err) {
+ console.error("이벤트 기반 복사 실패:", err);
+ }
+
+ // 방법 3: textarea 방식 (강화 버전)
+ try {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ // 스타일 설정으로 화면에 보이지 않게
+ textArea.style.position = "fixed";
+ textArea.style.top = "0";
+ textArea.style.left = "0";
+ textArea.style.width = "2em";
+ textArea.style.height = "2em";
+ textArea.style.padding = "0";
+ textArea.style.border = "none";
+ textArea.style.outline = "none";
+ textArea.style.boxShadow = "none";
+ textArea.style.background = "transparent";
+ textArea.style.opacity = "0";
+
+ document.body.appendChild(textArea);
+
+ // iOS 대응
+ if (navigator.userAgent.match(/ipad|ipod|iphone/i)) {
+ textArea.contentEditable = "true";
+ textArea.readOnly = false;
+ const range = document.createRange();
+ range.selectNodeContents(textArea);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ textArea.setSelectionRange(0, 999999);
+ } else {
+ textArea.select();
+ textArea.setSelectionRange(0, 99999);
+ }
+
+ const successful = document.execCommand('copy');
+ document.body.removeChild(textArea);
+
+ if (successful) {
+ setCopiedVar(key);
+ setTimeout(() => setCopiedVar(null), 2000);
+ toast.success(`복사됨: ${text}`, {
+ description: "문서에 붙여넣으세요 (Ctrl+V)"
+ });
+ copySuccess = true;
+ return;
+ }
+ } catch (err) {
+ console.error("textarea 복사 실패:", err);
+ }
+
+ // 모든 방법 실패
+ if (!copySuccess) {
+ toast.error("자동 복사 실패", {
+ description: `수동으로 복사하세요: ${text}`,
+ duration: 5000,
+ });
+ }
+ };
+
+ // 템플릿 저장
+ const handleSaveTemplate = async () => {
+ console.log("💾 저장 시도 - instance:", instance);
+ console.log("💾 저장 시도 - project:", project);
+
+ if (!instance) {
+ toast.error("뷰어가 아직 준비되지 않았습니다", {
+ description: "문서가 완전히 로드될 때까지 기다려주세요"
+ })
+ return
+ }
+
+ if (!project) {
+ toast.error("프로젝트 정보가 없습니다")
+ return
+ }
+
+ setIsSaving(true)
+
+ try {
+ const { documentViewer } = instance.Core
+ const doc = documentViewer.getDocument()
+
+ if (!doc) {
+ throw new Error("문서가 로드되지 않았습니다")
+ }
+
+ console.log("📄 문서 export 시작...");
+
+ // DOCX로 export
+ const data = await doc.getFileData({
+ downloadType: 'office',
+ includeAnnotations: true
+ })
+
+ console.log("✅ 문서 export 완료, 크기:", data.byteLength);
+
+ const blob = new Blob([data], {
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ })
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("file", blob, `${project.code}_cover_template.docx`)
+ formData.append("projectId", String(project.id))
+ formData.append("templateName", `${project.name} 커버 템플릿`)
+
+ console.log("📤 서버 전송 시작...");
+
+ const response = await fetch("/api/projects/cover-template/save", {
+ method: "POST",
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || "저장 실패")
+ }
+
+ const result = await response.json()
+
+ console.log("✅ 서버 저장 완료:", result);
+ router.refresh()
+ toast.success("커버 페이지가 생성되었습니다")
+
+ // 저장된 파일 경로 업데이트
+ if (result.filePath) {
+ setFilePath(result.filePath)
+ }
+
+ onOpenChange(false)
+ } catch (error) {
+ console.error("❌ 템플릿 저장 오류:", error)
+ toast.error(error instanceof Error ? error.message : "템플릿 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const handleDownloadTemplate = () => {
+ if (!filePath || !project) return
+ quickDownload(filePath, `${project.code}_cover_template.docx`)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl w-[90vw] h-[85vh] p-0 gap-0 flex flex-col">
+ <DialogHeader className="px-6 py-3 border-b">
+ <DialogTitle className="text-base">
+ 커버 페이지 템플릿 관리 - {project?.name} ({project?.code})
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="flex flex-1 min-h-0 overflow-hidden">
+ <div className="w-80 h-full border-r p-4 overflow-y-auto flex flex-col gap-4">
+ <div className="space-y-2">
+ <Label>템플릿 파일 업로드</Label>
+ <div className="flex gap-2">
+ <Input
+ type="file"
+ accept=".docx"
+ onChange={handleFileUpload}
+ disabled={isUploading}
+ className="flex-1"
+ />
+ {filePath && (
+ <Button
+ size="icon"
+ variant="outline"
+ onClick={handleDownloadTemplate}
+ title="현재 템플릿 다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ {isUploading && (
+ <p className="text-xs text-muted-foreground">업로드 중...</p>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label>필수 템플릿 변수</Label>
+ <div className="text-xs text-muted-foreground mb-2">
+ 복사 버튼을 클릭하여 변수를 복사한 후 문서에 붙여넣으세요
+ </div>
+
+ <div className="space-y-2">
+ {templateVariables.map(({ key, value, label }) => (
+ <div key={key} className="flex gap-2 items-center">
+ <div className="flex-1">
+ <div className="text-xs text-muted-foreground mb-1">{label}</div>
+ <div className="flex gap-2">
+ <Input
+ value={`{{${value}}}`}
+ readOnly
+ className="flex-1 text-xs font-mono bg-muted/50"
+ />
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ onClick={() => copyToClipboard(`{{${value}}}`, key)}
+ title="클립보드에 복사"
+ >
+ {copiedVar === key ? (
+ <Check className="h-3 w-3 text-green-600" />
+ ) : (
+ <Copy className="h-3 w-3" />
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="text-xs text-muted-foreground p-3 bg-muted/50 rounded-md mt-3">
+ <div className="font-semibold mb-1">💡 사용 방법</div>
+ 1. 복사 버튼을 클릭하여 변수를 복사<br />
+ 2. 문서에서 원하는 위치에 Ctrl+V로 붙여넣기<br />
+ 3. 문서 생성 시 변수는 실제 값으로 자동 치환됩니다<br />
+ <br />
+ <div className="font-semibold">📌 커스텀 변수</div>
+ 필요한 경우 {`{{customField}}`} 형식으로 직접 입력 가능
+ </div>
+ </div>
+
+ <div className="mt-auto pt-4 space-y-2">
+ {/* 상태 표시 */}
+ <div className="text-xs text-muted-foreground space-y-1 p-2 bg-muted/30 rounded">
+ <div className="flex items-center gap-2">
+ <div className={`w-2 h-2 rounded-full ${filePath ? 'bg-green-500' : 'bg-gray-300'}`} />
+ 파일: {filePath ? '준비됨' : '없음'}
+ </div>
+ {filePath &&
+ <div className="flex items-center gap-2">
+ <div className={`w-2 h-2 rounded-full ${instance ? 'bg-green-500' : 'bg-yellow-500'}`} />
+ 뷰어: {instance ? '준비됨' : '로딩 중...'}
+ </div>
+ }
+ </div>
+
+ <Button
+ className="w-full"
+ onClick={handleSaveTemplate}
+ disabled={!filePath || isSaving || !instance}
+ >
+ {(() => {
+ if (isSaving) {
+ return (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ );
+ }
+
+ if (!filePath) {
+ return (
+ <>
+ <Upload className="mr-2 h-4 w-4" />
+ 파일을 먼저 업로드하세요
+ </>
+ );
+ }
+
+ if (!instance) {
+ return (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 뷰어 로딩 중...
+ </>
+ );
+ }
+
+ return (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ 커버 페이지 생성
+ </>
+ );
+ })()}
+ </Button>
+ </div>
+ </div>
+
+ <div className="flex-1 relative overflow-hidden">
+ {filePath ? (
+ <div className="absolute inset-0">
+ <BasicContractTemplateViewer
+ key={filePath}
+ filePath={filePath}
+ instance={instance}
+ setInstance={setInstance}
+ />
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ DOCX 파일을 업로드하세요
+ </div>
+ )}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/cover/table/projects-table-columns.tsx b/lib/cover/table/projects-table-columns.tsx
new file mode 100644
index 00000000..9ed36436
--- /dev/null
+++ b/lib/cover/table/projects-table-columns.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Eye, FileText, FilePlus } from "lucide-react"
+import { Checkbox } from "@/components/ui/checkbox";
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { quickDownload } from "@/lib/file-download"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Project } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Project> | null>>
+ onDetailClick: (project: Project) => void
+ onTemplateManage: (project: Project) => void
+}
+
+export function getColumns({ setRowAction, onDetailClick, onTemplateManage }: GetColumnsProps): ColumnDef<Project>[] {
+ return [
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "code",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />
+ ),
+ meta: {
+ excelHeader: "Project Code",
+ },
+ },
+ {
+ accessorKey: "name",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ meta: {
+ excelHeader: "Project Name",
+ },
+ },
+ {
+ id: "coverTemplate",
+ enableResizing: true,
+ header: "커버 템플릿",
+ cell: ({ row }) => {
+ const project = row.original
+ const hasTemplate = !!project.coverTemplatePath
+
+ return (
+ <div className="flex items-center gap-2">
+ {hasTemplate ? (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => quickDownload(project.coverTemplatePath!, `${project.code}_template.docx`)}
+ title="템플릿 다운로드"
+ >
+ <FileText className="h-4 w-4 text-blue-600" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => onTemplateManage(project)}
+ title="템플릿 관리"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ </>
+ ) : (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => onTemplateManage(project)}
+ >
+ <FilePlus className="h-4 w-4 mr-1" />
+ 생성
+ </Button>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ id: "generatedCover",
+ enableResizing: true,
+ header: "생성된 커버",
+ cell: ({ row }) => {
+ const project = row.original
+ const hasGenerated = !!project.generatedCoverPath
+ const generatedAt = project.generatedCover?.generatedAt
+
+ return (
+ <div className="flex flex-col gap-1">
+ {hasGenerated ? (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => quickDownload(
+ project.generatedCoverPath!,
+ project.generatedCover?.fileName || `${project.code}_cover.docx`
+ )}
+ className="justify-start"
+ >
+ <Download className="h-4 w-4 mr-2 text-green-600" />
+ <span className="text-xs">다운로드</span>
+ </Button>
+ {generatedAt && (
+ <span className="text-xs text-muted-foreground pl-2">
+ {formatDate(new Date(generatedAt), "KR")}
+ </span>
+ )}
+ </>
+ ) : (
+ <span className="text-xs text-muted-foreground px-2">-</span>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "OWN_NM",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선주명" />
+ ),
+ meta: {
+ excelHeader: "Owner Name",
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ meta: {
+ excelHeader: "Created At",
+ },
+ cell: ({ cell }) => {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal, "KR")
+ },
+ },
+ {
+ accessorKey: "updatedAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ meta: {
+ excelHeader: "Updated At",
+ },
+ cell: ({ cell }) => {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal, "KR")
+ },
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/cover/table/projects-table-toolbar-actions.tsx b/lib/cover/table/projects-table-toolbar-actions.tsx
new file mode 100644
index 00000000..5d2d1fc6
--- /dev/null
+++ b/lib/cover/table/projects-table-toolbar-actions.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, FileText } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Project } from "@/db/schema"
+import { CoverTemplateDialog } from "./cover-template-dialog"
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<Project>
+}
+
+export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ const handleTemplateClick = () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length !== 1) {
+ toast.error("프로젝트를 하나만 선택해주세요")
+ return
+ }
+ setSelectedProject(selectedRows[0].original)
+ setTemplateDialogOpen(true)
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleTemplateClick}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 커버 페이지 템플릿
+ </Button>
+
+ <CoverTemplateDialog
+ open={templateDialogOpen}
+ onOpenChange={setTemplateDialogOpen}
+ project={selectedProject}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/cover/table/projects-table.tsx b/lib/cover/table/projects-table.tsx
new file mode 100644
index 00000000..944013ef
--- /dev/null
+++ b/lib/cover/table/projects-table.tsx
@@ -0,0 +1,114 @@
+"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 { getColumns } from "./projects-table-columns"
+import { getProjectListsForCover } from "../service"
+import { Project } from "@/db/schema"
+import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions"
+import { CoverTemplateDialog } from "./cover-template-dialog"
+
+interface ItemsTableProps {
+promises: Promise<
+[
+ Awaited<ReturnType<typeof getProjectListsForCover>>,
+]
+>
+}
+
+export function ProjectsTableForCover({ promises }: ItemsTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data, 'data')
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<Project> | null>(null)
+
+ // 템플릿 다이얼로그 상태
+ const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ const handleTemplateManage = React.useCallback((project: Project) => {
+ setSelectedProject(project)
+ setTemplateDialogOpen(true)
+ }, [])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ onDetailClick: () => {},
+ onTemplateManage: handleTemplateManage
+ }),
+ [setRowAction, handleTemplateManage]
+ )
+
+ const filterFields: DataTableFilterField<Project>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<Project>[] = [
+ {
+ id: "code",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "name",
+ label: "Project Name",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ 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,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ProjectTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <CoverTemplateDialog
+ open={templateDialogOpen}
+ onOpenChange={setTemplateDialogOpen}
+ project={selectedProject}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/cover/validation.ts b/lib/cover/validation.ts
new file mode 100644
index 00000000..ed1cc9a1
--- /dev/null
+++ b/lib/cover/validation.ts
@@ -0,0 +1,36 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Project } from "@/db/schema";
+
+export const searchParamsProjectsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Project>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ code: parseAsString.withDefault(""),
+ name: parseAsString.withDefault(""),
+ type: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+
+export type GetProjectListsSchema = Awaited<ReturnType<typeof searchParamsProjectsCache.parse>>