diff options
65 files changed, 9748 insertions, 2811 deletions
diff --git a/.env.production b/.env.production index 6e6b81b0..03693589 100644 --- a/.env.production +++ b/.env.production @@ -180,6 +180,7 @@ READONLY_DB_URL="postgresql://readonly:tempReadOnly_123@localhost:5432/evcp" # ํ NEXT_PUBLIC_DEBUG=false SWP_BASE_URL=http://60.100.99.217/DDP/Services/VNDRService.svc +DDC_BASE_URL=http://60.100.99.217/DDC/Services/WebService.svc # POS (EMLS, Documentum) DOCUMENTUM_NFS="/mnt/nfs-documentum/" # ํ์ง/์ด์ ๊ณตํต diff --git a/app/[lng]/evcp/(evcp)/permissions/page.tsx b/app/[lng]/evcp/(evcp)/permissions/page.tsx new file mode 100644 index 00000000..2d7b94e2 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/permissions/page.tsx @@ -0,0 +1,72 @@ +// app/evcp/(evcp)/permissions/page.tsx + +"use client"; + +import { useState } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Shield, Users, Key, Menu, Search, Plus } from "lucide-react"; +import { RolePermissionManager } from "@/components/permissions/role-permission-manager"; +import { PermissionAssignmentManager } from "@/components/permissions/permission-assignment-manager"; +import { UserPermissionManager } from "@/components/permissions/user-permission-manager"; +import { MenuPermissionManager } from "@/components/permissions/menu-permission-manager"; + +export default function PermissionManagementPage() { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedTab, setSelectedTab] = useState("by-role"); + + return ( + <div className="container mx-auto p-6"> + <div className="mb-6"> + <h1 className="text-3xl font-bold mb-2">๊ถํ ๊ด๋ฆฌ</h1> + <p className="text-muted-foreground"> + ์์คํ
๊ถํ์ ์ญํ , ์ฌ์ฉ์, ๋ฉ๋ด๋ณ๋ก ๊ด๋ฆฌํฉ๋๋ค. + </p> + </div> + + <Tabs value={selectedTab} onValueChange={setSelectedTab}> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="by-role"> + <Users className="mr-2 h-4 w-4" /> + ์ญํ ๋ณ ๊ด๋ฆฌ + </TabsTrigger> + <TabsTrigger value="by-user"> + <Shield className="mr-2 h-4 w-4" /> + ์ฌ์ฉ์๋ณ ๊ด๋ฆฌ + </TabsTrigger> + <TabsTrigger value="by-permission"> + <Key className="mr-2 h-4 w-4" /> + ๊ถํ๋ณ ๊ด๋ฆฌ + </TabsTrigger> + <TabsTrigger value="by-menu"> + <Menu className="mr-2 h-4 w-4" /> + ๋ฉ๋ด๋ณ ๊ด๋ฆฌ + </TabsTrigger> + </TabsList> + + {/* ์ญํ ๋ณ ๊ถํ ๊ด๋ฆฌ */} + <TabsContent value="by-role"> + <RolePermissionManager /> + </TabsContent> + + {/* ์ฌ์ฉ์๋ณ ๊ถํ ๊ด๋ฆฌ */} + <TabsContent value="by-user"> + <UserPermissionManager /> + </TabsContent> + + {/* ๊ถํ๋ณ ์ฌ์ฉ์/์ญํ ๊ด๋ฆฌ */} + <TabsContent value="by-permission"> + <PermissionAssignmentManager /> + </TabsContent> + + {/* ๋ฉ๋ด๋ณ ๊ถํ ์ค์ */} + <TabsContent value="by-menu"> + <MenuPermissionManager /> + </TabsContent> + </Tabs> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/permissions/settings/page.tsx b/app/[lng]/evcp/(evcp)/permissions/settings/page.tsx new file mode 100644 index 00000000..e258124f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/permissions/settings/page.tsx @@ -0,0 +1,54 @@ +// app/(evcp)/admin/permissions/settings/page.tsx + +"use client"; + +import { useState } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Shield, Key, Settings, RefreshCw } from "lucide-react"; +import { PermissionCrudManager } from "@/components/permissions/permission-crud-manager"; +import { MenuBasedPermissionGenerator } from "@/components/permissions/menu-permission-generator"; +import { PermissionGroupManager } from "@/components/permissions/permission-group-manager"; + +export default function PermissionSettingsPage() { + return ( + <div className="container mx-auto p-6"> + <div className="mb-6"> + <h1 className="text-3xl font-bold mb-2">๊ถํ ์ค์ </h1> + <p className="text-muted-foreground"> + ์์คํ
๊ถํ์ ์์ฑ, ์์ , ์ญ์ ํ๊ณ ๋ฉ๋ด ๊ธฐ๋ฐ์ผ๋ก ๊ถํ์ ์๋ ์์ฑํฉ๋๋ค. + </p> + </div> + + <Tabs defaultValue="permissions" className="space-y-4"> + <TabsList> + <TabsTrigger value="permissions"> + <Key className="mr-2 h-4 w-4" /> + ๊ถํ ๊ด๋ฆฌ + </TabsTrigger> + <TabsTrigger value="generate"> + <RefreshCw className="mr-2 h-4 w-4" /> + ๋ฉ๋ด ๊ธฐ๋ฐ ์์ฑ + </TabsTrigger> + <TabsTrigger value="groups"> + <Shield className="mr-2 h-4 w-4" /> + ๊ถํ ๊ทธ๋ฃน + </TabsTrigger> + </TabsList> + + <TabsContent value="permissions"> + <PermissionCrudManager /> + </TabsContent> + + <TabsContent value="generate"> + <MenuBasedPermissionGenerator /> + </TabsContent> + + <TabsContent value="groups"> + <PermissionGroupManager /> + </TabsContent> + </Tabs> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/document-upload/page.tsx b/app/[lng]/partners/(partners)/document-upload/page.tsx index 9df82fd4..003b4984 100644 --- a/app/[lng]/partners/(partners)/document-upload/page.tsx +++ b/app/[lng]/partners/(partners)/document-upload/page.tsx @@ -56,7 +56,7 @@ export default async function StageSubmissionsPage({ {/* Header */} <div className="flex items-center justify-between"> <div> - <h1 className="text-3xl font-bold tracking-tight">My Stage Submissions</h1> + <h2 className="text-2xl font-bold tracking-tight">My Stage Submissions</h2> <p className="text-muted-foreground mt-1"> Manage document submissions for your approved stages </p> diff --git a/app/api/rfq-attachments/upload/route.ts b/app/api/rfq-attachments/upload/route.ts index 3343c905..0f9a1902 100644 --- a/app/api/rfq-attachments/upload/route.ts +++ b/app/api/rfq-attachments/upload/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import db from "@/db/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { rfqLastAttachments, rfqLastAttachmentRevisions } from "@/db/schema"; import { saveFile } from "@/lib/file-stroage"; @@ -10,19 +10,27 @@ import { saveFile } from "@/lib/file-stroage"; async function generateSerialNo(rfqId: number, attachmentType: string, index: number = 0): Promise<string> { const prefix = attachmentType === "์ค๊ณ" ? "DES" : "PUR"; - const existingAttachments = await db - .select({ id: rfqLastAttachments.id }) + // ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์ต๋ ์๋ฆฌ์ผ ๋ฒํธ ์ฐพ๊ธฐ + const maxSerialResult = await db + .select({ serialNo: rfqLastAttachments.serialNo }) .from(rfqLastAttachments) .where( and( eq(rfqLastAttachments.rfqId, rfqId), eq(rfqLastAttachments.attachmentType, attachmentType as "์ค๊ณ" | "๊ตฌ๋งค") ) - ); + ) + .orderBy(sql`CAST(SUBSTRING(${rfqLastAttachments.serialNo} FROM '[^-]+$') AS INTEGER) DESC`) + .limit(1); - const nextNumber = existingAttachments.length + 1 + index; - const paddedNumber = String(nextNumber).padStart(4, "0"); + let nextNumber = 1; + if (maxSerialResult.length > 0) { + const lastSerialNo = maxSerialResult[0].serialNo; + const lastNumber = parseInt(lastSerialNo.split('-').pop() || '0'); + nextNumber = lastNumber + 1; + } + const paddedNumber = String(nextNumber).padStart(4, "0"); return `${prefix}-${rfqId}-${paddedNumber}`; } diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx index 652bf77b..773ab7dd 100644 --- a/components/ProjectSelector.tsx +++ b/components/ProjectSelector.tsx @@ -12,12 +12,14 @@ interface ProjectSelectorProps { selectedProjectId?: number | null; onProjectSelect: (project: Project) => void; placeholder?: string; + filterType?: string; // ์ต์
์ผ๋ก ํํฐ ํ์
์ง์ ๊ฐ๋ฅ } export function ProjectSelector({ selectedProjectId, onProjectSelect, - placeholder = "ํ๋ก์ ํธ ์ ํ..." + placeholder = "ํ๋ก์ ํธ ์ ํ...", + filterType = "ship" // ๊ธฐ๋ณธ๊ฐ์ plant๋ก ์ค์ }: ProjectSelectorProps) { const [open, setOpen] = React.useState(false) const [searchTerm, setSearchTerm] = React.useState("") @@ -25,17 +27,24 @@ export function ProjectSelector({ const [isLoading, setIsLoading] = React.useState(false) const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) - // ๋ชจ๋ ํ๋ก์ ํธ ๋ฐ์ดํฐ ๋ก๋ (ํ ๋ฒ๋ง) + // ๋ชจ๋ ํ๋ก์ ํธ ๋ฐ์ดํฐ ๋ก๋ ํ plant ํ์
๋ง ํํฐ๋ง React.useEffect(() => { async function loadAllProjects() { setIsLoading(true); try { const allProjects = await getProjects(); - setProjects(allProjects); + + // filterType์ด ์ง์ ๋ ๊ฒฝ์ฐ ํด๋น ํ์
๋ง ํํฐ๋ง + const filteredByType = filterType + ? allProjects.filter(p => p.type === filterType) + : allProjects; + + console.log(`Loaded ${filteredByType.length} ${filterType || 'all'} projects`); + setProjects(filteredByType); // ์ด๊ธฐ ์ ํ๋ ํ๋ก์ ํธ๊ฐ ์์ผ๋ฉด ์ค์ if (selectedProjectId) { - const selected = allProjects.find(p => p.id === selectedProjectId); + const selected = filteredByType.find(p => p.id === selectedProjectId); if (selected) { setSelectedProject(selected); } @@ -48,7 +57,7 @@ export function ProjectSelector({ } loadAllProjects(); - }, [selectedProjectId]); + }, [selectedProjectId, filterType]); // ํด๋ผ์ด์ธํธ ์ธก์์ ๊ฒ์์ด๋ก ํํฐ๋ง const filteredProjects = React.useMemo(() => { @@ -77,10 +86,15 @@ export function ProjectSelector({ role="combobox" aria-expanded={open} className="w-full justify-between" + disabled={isLoading} > - {selectedProject - ? `${selectedProject.projectCode} - ${selectedProject.projectName}` - : placeholder} + {isLoading ? ( + "ํ๋ก์ ํธ ๋ก๋ฉ ์ค..." + ) : selectedProject ? ( + `${selectedProject.projectCode} - ${selectedProject.projectName}` + ) : ( + placeholder + )} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> @@ -90,14 +104,22 @@ export function ProjectSelector({ placeholder="ํ๋ก์ ํธ ์ฝ๋/์ด๋ฆ ๊ฒ์..." onValueChange={setSearchTerm} /> - <CommandList className="max-h-[300px]" onWheel={(e) => { - e.stopPropagation(); // ์ด๋ฒคํธ ์ ํ ์ฐจ๋จ - const target = e.currentTarget; - target.scrollTop += e.deltaY; // ์ง์ ์คํฌ๋กค ์ฒ๋ฆฌ - }}> - <CommandEmpty>๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค</CommandEmpty> + <CommandList + className="max-h-[300px]" + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} + > {isLoading ? ( <div className="py-6 text-center text-sm">๋ก๋ฉ ์ค...</div> + ) : filteredProjects.length === 0 ? ( + <CommandEmpty> + {searchTerm + ? "๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค" + : `${filterType || 'ํด๋น ํ์
์'} ํ๋ก์ ํธ๊ฐ ์์ต๋๋ค`} + </CommandEmpty> ) : ( <CommandGroup> {filteredProjects.map((project) => ( diff --git a/components/docu-list-rule/docu-list-rule-client.tsx b/components/docu-list-rule/docu-list-rule-client.tsx index 7e6c2bb1..ae3cdece 100644 --- a/components/docu-list-rule/docu-list-rule-client.tsx +++ b/components/docu-list-rule/docu-list-rule-client.tsx @@ -1,15 +1,7 @@ "use client" import * as React from "react" import { useRouter, useParams } from "next/navigation" - -import { getProjectLists } from "@/lib/projects/service" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" +import { ProjectSelector } from "../ProjectSelector" interface DocuListRuleClientProps { children: React.ReactNode @@ -38,10 +30,6 @@ export default function DocuListRuleClient({ projectIdFromUrl ) - // ํ๋ก์ ํธ ๋ชฉ๋ก ์ํ - const [projects, setProjects] = React.useState<Array<{ id: number; code: string; name: string; type: string }>>([]) - const [isLoading, setIsLoading] = React.useState(true) - // Update selectedProjectId when URL changes React.useEffect(() => { if (projectIdFromUrl) { @@ -49,40 +37,6 @@ export default function DocuListRuleClient({ } }, [projectIdFromUrl]) - // ํ๋ก์ ํธ ๋ชฉ๋ก ๋ก๋ - React.useEffect(() => { - const loadProjects = async () => { - try { - setIsLoading(true) - console.log("Loading projects...") - const result = await getProjectLists({ - page: 1, - perPage: 1000, - search: "", - sort: [], - filters: [], - joinOperator: "and", - flags: [], - code: "", - name: "", - type: "" - }) - console.log("Projects result:", result) - if (result.data) { - // plant ํ์
์ ํ๋ก์ ํธ๋ง ํํฐ๋ง - const plantProjects = result.data.filter(project => project.type === 'plant') - console.log("Plant projects:", plantProjects) - setProjects(plantProjects) - } - } catch (error) { - console.error("Failed to load projects:", error) - } finally { - setIsLoading(false) - } - } - loadProjects() - }, []) - // Handle project selection function handleSelectProject(projectId: number) { console.log("Selecting project:", projectId) @@ -107,28 +61,16 @@ export default function DocuListRuleClient({ </div> {/* ์ค๋ฅธ์ชฝ: ProjectSwitcher */} - <div className="flex items-center space-x-2"> - <Select - value={selectedProjectId ? String(selectedProjectId) : ""} - onValueChange={(value) => { - const projectId = Number(value) - if (projectId) { - handleSelectProject(projectId) - } + <div className="flex items-center space-x-2 max-w-[400px]"> + <ProjectSelector + selectedProjectId={selectedProjectId} + onProjectSelect={(project) => { + handleSelectProject(project.id) }} - disabled={isLoading} - > - <SelectTrigger className="max-w-[300px] whitespace-nowrap overflow-hidden text-ellipsis"> - <SelectValue placeholder={isLoading ? "ํ๋ก์ ํธ ๋ก๋ฉ ์ค..." : "ํ๋ก์ ํธ๋ฅผ ์ ํํ์ธ์"} /> - </SelectTrigger> - <SelectContent> - {projects.map((project) => ( - <SelectItem key={project.id} value={String(project.id)}> - {project.code} - {project.name} - </SelectItem> - ))} - </SelectContent> - </Select> + placeholder="ํ๋ก์ ํธ๋ฅผ ์ ํํ์ธ์" + filterType="plant" // ๋ช
์์ ์ผ๋ก plant ํ์
๋ง (์ ํ์ฌํญ, ๊ธฐ๋ณธ๊ฐ์ด plant) + + /> </div> </div> @@ -138,4 +80,4 @@ export default function DocuListRuleClient({ </section> </> ) -} +}
\ No newline at end of file diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx index 5a462d2b..a5d4db21 100644 --- a/components/form-data/add-formTag-dialog.tsx +++ b/components/form-data/add-formTag-dialog.tsx @@ -52,10 +52,10 @@ import { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { createTagInForm } from "@/lib/tags/service" -import { - getFormTagTypeMappings, - getTagTypeByDescription, - getSubfieldsByTagTypeForForm +import { + getFormTagTypeMappings, + getTagTypeByDescription, + getSubfieldsByTagTypeForForm } from "@/lib/forms/services" import { useTranslation } from "@/i18n/client"; @@ -100,10 +100,10 @@ interface AddFormTagDialogProps { onOpenChange?: (open: boolean) => void; } -export function AddFormTagDialog({ +export function AddFormTagDialog({ projectId, - formCode, - formName, + formCode, + formName, contractItemId, packageCode, open: externalOpen, @@ -138,7 +138,7 @@ export function AddFormTagDialog({ React.useEffect(() => { const loadMappings = async () => { if (!formCode || !projectId) return; - + setIsLoadingClasses(true); try { const result = await getFormTagTypeMappings(formCode, projectId); @@ -158,7 +158,7 @@ export function AddFormTagDialog({ setIsLoadingClasses(false); } }; - + loadMappings(); }, [formCode, projectId]); @@ -167,7 +167,7 @@ export function AddFormTagDialog({ if (isOpen) { const loadMappings = async () => { if (!formCode || !projectId) return; - + setIsLoadingClasses(true); try { const result = await getFormTagTypeMappings(formCode, projectId); @@ -187,7 +187,7 @@ export function AddFormTagDialog({ setIsLoadingClasses(false); } }; - + loadMappings(); } }, [isOpen, formCode, projectId]); @@ -256,13 +256,13 @@ export function AddFormTagDialog({ // --------------- async function handleSelectClass(classLabel: string) { form.setValue("class", classLabel); - + // Find the mapping for this class const mapping = mappings.find(m => m.classLabel === classLabel); if (mapping) { setSelectedTagTypeLabel(mapping.tagTypeLabel); form.setValue("tagType", mapping.tagTypeLabel); - + // Get the tagTypeCode for this tagTypeLabel try { const tagType = await getTagTypeByDescription(mapping.tagTypeLabel, projectId); @@ -285,28 +285,28 @@ export function AddFormTagDialog({ if (subFields.length === 0) { return; } - + const subscription = form.watch((value) => { if (!value.rows || subFields.length === 0) { return; } - + const rows = [...value.rows]; rows.forEach((row, rowIndex) => { if (!row) return; - + let combined = ""; subFields.forEach((sf, idx) => { const fieldValue = row[sf.name] || ""; - + // delimiter๋ฅผ ์์ ๋ถ์ด๊ธฐ (์ฒซ ๋ฒ์งธ ํ๋๊ฐ ์๋๊ณ , ํ์ฌ ํ๋์ ๊ฐ์ด ์๊ณ , delimiter๊ฐ ์๋ ๊ฒฝ์ฐ) if (idx > 0 && fieldValue && sf.delimiter) { combined += sf.delimiter; } - + combined += fieldValue; }); - + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); if (currentTagNo !== combined) { form.setValue(`rows.${rowIndex}.tagNo`, combined, { @@ -317,7 +317,7 @@ export function AddFormTagDialog({ } }); }); - + return () => subscription.unsubscribe(); }, [subFields, form]); // --------------- @@ -364,11 +364,16 @@ export function AddFormTagDialog({ try { const res = await createTagInForm(tagData, contractItemId, formCode, packageCode); - if ("error" in res) { + if (res && "error" in res) { failedTags.push({ tag: row.tagNo, error: res.error }); - } else { + } else if (res && res.success) { successfulTags.push(row.tagNo); + } else { + // ์์์น ๋ชปํ ์๋ต ์ฒ๋ฆฌ + console.error("Unexpected response:", res); + failedTags.push({ tag: row.tagNo, error: "Unexpected response format" }); } + } catch (err) { failedTags.push({ tag: row.tagNo, error: "Unknown error" }); } @@ -381,7 +386,22 @@ export function AddFormTagDialog({ if (failedTags.length > 0) { console.log("Failed tags:", failedTags); - toast.error(`${failedTags.length}๊ฐ์ ํ๊ทธ ์์ฑ์ ์คํจํ์ต๋๋ค.`); + + // ์ ์ฒด ์๋ฌ ๋ฉ์์ง ํ์ + const errorMessage = failedTags + .map(f => `${f.tag}: ${f.error}`) + .join('\n'); + + toast.error( + <div> + <p>{failedTags.length}๊ฐ์ ํ๊ทธ ์์ฑ ์คํจ:</p> + <ul className="text-sm mt-1"> + {failedTags.map((f, idx) => ( + <li key={idx}>โข {f.tag}: {f.error}</li> + ))} + </ul> + </div> + ); } // Refresh the page @@ -445,7 +465,7 @@ export function AddFormTagDialog({ // --------------- // Render Class field // --------------- - function renderClassField(field: any) { + function renderClassField(field: any) { const [popoverOpen, setPopoverOpen] = React.useState(false) const buttonId = React.useMemo( diff --git a/components/permissions/menu-permission-generator.tsx b/components/permissions/menu-permission-generator.tsx new file mode 100644 index 00000000..4c8d60d0 --- /dev/null +++ b/components/permissions/menu-permission-generator.tsx @@ -0,0 +1,404 @@ +// components/permissions/menu-permission-generator-optimized.tsx + +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + RefreshCw, + AlertCircle, + CheckCircle, + Plus, + Search, + ChevronDown, + ChevronUp +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { analyzeMenuPermissions, generateMenuPermissions } from "@/lib/permissions/permission-settings-actions"; + +export function MenuBasedPermissionGenerator() { + const [analysis, setAnalysis] = useState<MenuPermissionAnalysis[]>([]); + const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set()); + const [selectedPermissions, setSelectedPermissions] = useState<Map<string, Set<string>>>(new Map()); + const [loading, setLoading] = useState(false); + const [generating, setGenerating] = useState(false); + + // ํํฐ๋ง๊ณผ ํ์ด์ง๋ค์ด์
+ const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState<"all" | "configured" | "unconfigured">("unconfigured"); + const [currentPage, setCurrentPage] = useState(1); + const [expandedRow, setExpandedRow] = useState<string | null>(null); // ํ ๋ฒ์ ํ๋๋ง ํ์ฅ + const itemsPerPage = 20; + + // ํํฐ๋ง๋ ๋ฐ์ดํฐ + const filteredAnalysis = useMemo(() => { + let filtered = analysis; + + if (searchQuery) { + filtered = filtered.filter(m => + m.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + m.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + if (filterType === "configured") { + filtered = filtered.filter(m => m.existingPermissions.length > 0); + } else if (filterType === "unconfigured") { + filtered = filtered.filter(m => m.existingPermissions.length === 0); + } + + return filtered; + }, [analysis, searchQuery, filterType]); + + // ํ์ด์ง๋ค์ด์
์ ์ฉ + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * itemsPerPage; + return filteredAnalysis.slice(start, start + itemsPerPage); + }, [filteredAnalysis, currentPage, itemsPerPage]); + + const totalPages = Math.ceil(filteredAnalysis.length / itemsPerPage); + + // ํํฐ ๋ณ๊ฒฝ ์ ์ฒซ ํ์ด์ง๋ก + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, filterType]); + + const loadAnalysis = async () => { + setLoading(true); + try { + const data = await analyzeMenuPermissions(); + setAnalysis(data); + + // ์ด๊ธฐ์๋ ์ ํํ์ง ์์ (์ฌ์ฉ์๊ฐ ํ์ํ ๊ฒ๋ง ์ ํ) + setSelectedMenus(new Set()); + setSelectedPermissions(new Map()); + } catch (error) { + toast.error("๋ฉ๋ด ๋ถ์์ ์คํจํ์ต๋๋ค."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAnalysis(); + }, []); + + const toggleMenu = useCallback((menuPath: string) => { + setSelectedMenus(prev => { + const newSelected = new Set(prev); + if (newSelected.has(menuPath)) { + newSelected.delete(menuPath); + setSelectedPermissions(perms => { + const newPerms = new Map(perms); + newPerms.delete(menuPath); + return newPerms; + }); + } else { + newSelected.add(menuPath); + const menu = analysis.find(m => m.menuPath === menuPath); + if (menu) { + setSelectedPermissions(perms => { + const newPerms = new Map(perms); + newPerms.set( + menuPath, + new Set(menu.suggestedPermissions.map(p => p.permissionKey)) + ); + return newPerms; + }); + } + } + return newSelected; + }); + }, [analysis]); + + const togglePermission = useCallback((menuPath: string, permissionKey: string) => { + setSelectedPermissions(prev => { + const newPerms = new Map(prev); + const menuPerms = newPerms.get(menuPath) || new Set(); + + if (menuPerms.has(permissionKey)) { + menuPerms.delete(permissionKey); + } else { + menuPerms.add(permissionKey); + } + + newPerms.set(menuPath, menuPerms); + return newPerms; + }); + }, []); + + const handleGenerate = async () => { + const permissionsToGenerate = []; + + for (const [menuPath, permKeys] of selectedPermissions.entries()) { + const menu = analysis.find(m => m.menuPath === menuPath); + if (menu) { + for (const permKey of permKeys) { + const perm = menu.suggestedPermissions.find(p => p.permissionKey === permKey); + if (perm) { + permissionsToGenerate.push({ + ...perm, + menuPath, + }); + } + } + } + } + + if (permissionsToGenerate.length === 0) { + toast.error("์์ฑํ ๊ถํ์ ์ ํํด์ฃผ์ธ์."); + return; + } + + setGenerating(true); + try { + const result = await generateMenuPermissions(permissionsToGenerate); + toast.success(`${result.created}๊ฐ์ ๊ถํ์ด ์์ฑ๋์์ต๋๋ค.`); + loadAnalysis(); + } catch (error) { + toast.error("๊ถํ ์์ฑ์ ์คํจํ์ต๋๋ค."); + } finally { + setGenerating(false); + } + }; + + const selectAllInPage = () => { + paginatedData.forEach(menu => { + if (!selectedMenus.has(menu.menuPath)) { + toggleMenu(menu.menuPath); + } + }); + }; + + const deselectAllInPage = () => { + paginatedData.forEach(menu => { + if (selectedMenus.has(menu.menuPath)) { + toggleMenu(menu.menuPath); + } + }); + }; + + const totalSelected = Array.from(selectedPermissions.values()) + .reduce((sum, set) => sum + set.size, 0); + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>๋ฉ๋ด ๊ธฐ๋ฐ ๊ถํ ์๋ ์์ฑ</CardTitle> + <CardDescription> + ๋ฑ๋ก๋ ๋ฉ๋ด๋ฅผ ๋ถ์ํ์ฌ ํ์ํ ๊ถํ์ ์๋์ผ๋ก ์์ฑํฉ๋๋ค. + </CardDescription> + </div> + <div className="flex gap-2"> + <Button variant="outline" onClick={loadAnalysis} disabled={loading}> + <RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} /> + ๋ถ์ + </Button> + <Button + onClick={handleGenerate} + disabled={totalSelected === 0 || generating} + > + <Plus className="mr-2 h-4 w-4" /> + {totalSelected}๊ฐ ๊ถํ ์์ฑ + </Button> + </div> + </div> + </CardHeader> + <CardContent> + {/* ์์ฝ ์ ๋ณด */} + <div className="grid grid-cols-3 gap-4 mb-6"> + <Card className="cursor-pointer" onClick={() => setFilterType("all")}> + <CardContent className="pt-6"> + <div className="text-2xl font-bold">{analysis.length}</div> + <p className="text-xs text-muted-foreground">์ ์ฒด ๋ฉ๋ด</p> + </CardContent> + </Card> + <Card className="cursor-pointer" onClick={() => setFilterType("configured")}> + <CardContent className="pt-6"> + <div className="text-2xl font-bold"> + {analysis.filter(m => m.existingPermissions.length > 0).length} + </div> + <p className="text-xs text-muted-foreground">๊ถํ ์ค์ ๋จ</p> + </CardContent> + </Card> + <Card className="cursor-pointer" onClick={() => setFilterType("unconfigured")}> + <CardContent className="pt-6"> + <div className="text-2xl font-bold text-orange-600"> + {analysis.filter(m => m.existingPermissions.length === 0).length} + </div> + <p className="text-xs text-muted-foreground">๊ถํ ๋ฏธ์ค์ </p> + </CardContent> + </Card> + </div> + + {/* ํํฐ ์น์
*/} + <div className="flex gap-4 mb-4"> + <div className="flex-1 relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="๋ฉ๋ด๋ช
, ๊ฒฝ๋ก๋ก ๊ฒ์..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + <Select value={filterType} onValueChange={(v: any) => setFilterType(v)}> + <SelectTrigger className="w-[150px]"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">์ ์ฒด</SelectItem> + <SelectItem value="unconfigured">๋ฏธ์ค์ </SelectItem> + <SelectItem value="configured">์ค์ ๋จ</SelectItem> + </SelectContent> + </Select> + </div> + + {/* ์ผ๊ด ์ ํ ๋ฒํผ */} + <div className="flex justify-between items-center mb-4"> + <span className="text-sm text-muted-foreground"> + {filteredAnalysis.length}๊ฐ ์ค {paginatedData.length}๊ฐ ํ์ + </span> + <div className="flex gap-2"> + <Button size="sm" variant="outline" onClick={selectAllInPage}> + ํ์ฌ ํ์ด์ง ์ ์ฒด ์ ํ + </Button> + <Button size="sm" variant="outline" onClick={deselectAllInPage}> + ํ์ฌ ํ์ด์ง ์ ์ฒด ํด์ + </Button> + </div> + </div> + + {/* ๋ฉ๋ด ๋ฆฌ์คํธ */} + <div className="border rounded-lg divide-y"> + {paginatedData.map(menu => { + const isSelected = selectedMenus.has(menu.menuPath); + const isExpanded = expandedRow === menu.menuPath; + const menuPermissions = selectedPermissions.get(menu.menuPath); + + return ( + <div key={menu.menuPath} className="p-4"> + <div className="flex items-center gap-4"> + <Checkbox + checked={isSelected} + onCheckedChange={() => toggleMenu(menu.menuPath)} + /> + + <div className="flex-1"> + <div className="font-medium">{menu.menuTitle}</div> + <div className="text-xs text-muted-foreground"> + {menu.menuPath} + </div> + </div> + + <Badge variant="outline">{menu.domain}</Badge> + + {menu.existingPermissions.length > 0 ? ( + <div className="flex items-center gap-1 text-green-600"> + <CheckCircle className="h-4 w-4" /> + <span className="text-sm">{menu.existingPermissions.length}๊ฐ</span> + </div> + ) : ( + <div className="flex items-center gap-1 text-orange-600"> + <AlertCircle className="h-4 w-4" /> + <span className="text-sm">๋ฏธ์ค์ </span> + </div> + )} + + {isSelected && menu.suggestedPermissions.length > 0 && ( + <Button + size="sm" + variant="ghost" + onClick={() => setExpandedRow(isExpanded ? null : menu.menuPath)} + > + {isExpanded ? <ChevronUp /> : <ChevronDown />} + <span className="ml-1">{menu.suggestedPermissions.length}๊ฐ ๊ถํ</span> + </Button> + )} + </div> + + {/* ๊ถํ ์์ธ (ํ์ฅ ์์๋ง ํ์) */} + {isExpanded && isSelected && ( + <div className="mt-4 pl-10 space-y-2"> + {menu.suggestedPermissions.map(perm => ( + <label + key={perm.permissionKey} + className="flex items-center gap-2 cursor-pointer" + > + <Checkbox + checked={menuPermissions?.has(perm.permissionKey) || false} + onCheckedChange={() => togglePermission(menu.menuPath, perm.permissionKey)} + /> + <span className="text-sm">{perm.name}</span> + <Badge variant="outline" className="text-xs"> + {perm.action} + </Badge> + </label> + ))} + </div> + )} + </div> + ); + })} + </div> + + {/* ํ์ด์ง๋ค์ด์
*/} + {totalPages > 1 && ( + <div className="flex items-center justify-center gap-2 mt-4"> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(1)} + disabled={currentPage === 1} + > + ์ฒ์ + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(p => Math.max(1, p - 1))} + disabled={currentPage === 1} + > + ์ด์ + </Button> + <span className="px-3 text-sm"> + {currentPage} / {totalPages} + </span> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} + disabled={currentPage === totalPages} + > + ๋ค์ + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setCurrentPage(totalPages)} + disabled={currentPage === totalPages} + > + ๋ง์ง๋ง + </Button> + </div> + )} + </CardContent> + </Card> + </div> + ); +}
\ No newline at end of file diff --git a/components/permissions/menu-permission-manager.tsx b/components/permissions/menu-permission-manager.tsx new file mode 100644 index 00000000..1b771520 --- /dev/null +++ b/components/permissions/menu-permission-manager.tsx @@ -0,0 +1,552 @@ +// components/permissions/menu-permission-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Menu, + Search, + Shield, + Lock, + Unlock, + Users, + User, + Settings, + FileText, + Eye, + Edit, + Trash, + Plus, + ChevronRight, + AlertCircle, + CheckCircle, + ExternalLink +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getMenuPermissions, + updateMenuPermissions, + getMenuManagers, + updateMenuManagers, +} from "@/lib/permissions/service"; + +// ๋ฉ๋ด ๊ตฌ์กฐ ํ์
+interface MenuItem { + menuPath: string; + menuTitle: string; + menuDescription?: string; + sectionTitle?: string; + menuGroup?: string; + domain: string; + isActive: boolean; + manager1?: { id: number; name: string; email: string; imageUrl?: string }; + manager2?: { id: number; name: string; email: string; imageUrl?: string }; + requiredPermissions: Array<{ + id: number; + permissionKey: string; + name: string; + description?: string; + isRequired: boolean; + }>; + accessCount?: number; // ์ ๊ทผ ํต๊ณ + lastAccessed?: Date; +} + +// ๋ฉ๋ด ์น์
๋ณ ๊ทธ๋ฃนํ +interface MenuSection { + title: string; + items: MenuItem[]; +} + +export function MenuPermissionManager() { + const [searchQuery, setSearchQuery] = useState(""); + const [menus, setMenus] = useState<MenuItem[]>([]); + const [selectedMenu, setSelectedMenu] = useState<MenuItem | null>(null); + const [availablePermissions, setAvailablePermissions] = useState<any[]>([]); + const [loading, setLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [selectedDomain, setSelectedDomain] = useState<string>("all"); + + useEffect(() => { + loadMenus(); + }, [selectedDomain]); + + const loadMenus = async () => { + setLoading(true); + try { + const data = await getMenuPermissions(selectedDomain); + setMenus(data.menus); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("๋ฉ๋ด ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } finally { + setLoading(false); + } + }; + + // ๋ฉ๋ด ๊ฒ์ ํํฐ๋ง + const filteredMenus = menus.filter(menu => + menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // ์น์
๋ณ๋ก ๋ฉ๋ด ๊ทธ๋ฃนํ + const groupedMenus = filteredMenus.reduce((acc, menu) => { + const section = menu.sectionTitle || "๊ธฐํ"; + if (!acc[section]) { + acc[section] = []; + } + acc[section].push(menu); + return acc; + }, {} as Record<string, MenuItem[]>); + + return ( + <div className="grid grid-cols-3 gap-6"> + {/* ๋ฉ๋ด ๋ชฉ๋ก */} + <Card className="col-span-1"> + <CardHeader> + <CardTitle>๋ฉ๋ด ๋ชฉ๋ก</CardTitle> + <CardDescription>๊ถํ์ ์ค์ ํ ๋ฉ๋ด๋ฅผ ์ ํํ์ธ์.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* ๋๋ฉ์ธ ํํฐ */} + <div className="flex gap-2"> + <Badge + variant={selectedDomain === "all" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("all")} + > + ์ ์ฒด + </Badge> + <Badge + variant={selectedDomain === "evcp" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("evcp")} + > + EVCP + </Badge> + <Badge + variant={selectedDomain === "partners" ? "default" : "outline"} + className="cursor-pointer" + onClick={() => setSelectedDomain("partners")} + > + Partners + </Badge> + </div> + + {/* ๊ฒ์ */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="๋ฉ๋ด๋ช
, ๊ฒฝ๋ก๋ก ๊ฒ์..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* ๋ฉ๋ด ํธ๋ฆฌ */} + <ScrollArea className="h-[500px]"> + <Accordion type="single" collapsible className="w-full"> + {Object.entries(groupedMenus).map(([section, items]) => ( + <AccordionItem key={section} value={section}> + <AccordionTrigger className="text-sm"> + <div className="flex items-center justify-between flex-1 mr-2"> + <span>{section}</span> + <Badge variant="secondary" className="text-xs"> + {items.length} + </Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-1"> + {items.map((menu) => ( + <button + key={menu.menuPath} + onClick={() => setSelectedMenu(menu)} + className={cn( + "w-full p-3 rounded-lg text-left transition-colors", + selectedMenu?.menuPath === menu.menuPath + ? "bg-primary/10 border border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-start justify-between"> + <div className="flex-1"> + <div className="font-medium text-sm">{menu.menuTitle}</div> + <div className="text-xs text-muted-foreground mt-1"> + {menu.menuPath} + </div> + </div> + <div className="flex flex-col items-end gap-1"> + {menu.requiredPermissions.length > 0 && ( + <Badge variant="outline" className="text-xs"> + {menu.requiredPermissions.length}๊ฐ ๊ถํ + </Badge> + )} + {!menu.isActive && ( + <Badge variant="destructive" className="text-xs"> + ๋นํ์ฑ + </Badge> + )} + </div> + </div> + </button> + ))} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </ScrollArea> + </div> + </CardContent> + </Card> + + {/* ๋ฉ๋ด ์์ธ ๋ฐ ๊ถํ ์ค์ */} + {selectedMenu ? ( + <Card className="col-span-2"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="flex items-center gap-2"> + {selectedMenu.menuTitle} + {!selectedMenu.isActive && ( + <Badge variant="destructive">๋นํ์ฑ</Badge> + )} + </CardTitle> + <CardDescription>{selectedMenu.menuPath}</CardDescription> + {selectedMenu.menuDescription && ( + <p className="text-sm text-muted-foreground mt-2"> + {selectedMenu.menuDescription} + </p> + )} + </div> + <Button + onClick={() => setEditDialogOpen(true)} + size="sm" + > + <Settings className="mr-2 h-4 w-4" /> + ์ค์ + </Button> + </div> + </CardHeader> + <CardContent> + <div className="space-y-6"> + {/* ๋ด๋น์ ์ ๋ณด */} + <div> + <h3 className="text-sm font-medium mb-3">๋ด๋น์</h3> + <div className="space-y-2"> + <ManagerInfo + label="์ฃผ ๋ด๋น์" + manager={selectedMenu.manager1} + onEdit={() => {/* ๋ด๋น์ ๋ณ๊ฒฝ ๋ค์ด์ผ๋ก๊ทธ */}} + /> + <ManagerInfo + label="๋ถ ๋ด๋น์" + manager={selectedMenu.manager2} + onEdit={() => {/* ๋ด๋น์ ๋ณ๊ฒฝ ๋ค์ด์ผ๋ก๊ทธ */}} + /> + </div> + </div> + + <Separator /> + + {/* ํ์ ๊ถํ */} + <div> + <h3 className="text-sm font-medium mb-3">ํ์ ๊ถํ</h3> + {selectedMenu.requiredPermissions.length > 0 ? ( + <div className="space-y-2"> + {selectedMenu.requiredPermissions.map((perm) => ( + <div + key={perm.id} + className="flex items-center justify-between p-3 border rounded-lg" + > + <div> + <div className="font-medium text-sm">{perm.name}</div> + <div className="text-xs text-muted-foreground"> + {perm.permissionKey} + </div> + {perm.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {perm.description} + </div> + )} + </div> + <div className="flex items-center gap-2"> + {perm.isRequired ? ( + <Badge variant="default">ํ์</Badge> + ) : ( + <Badge variant="outline">์ ํ</Badge> + )} + </div> + </div> + ))} + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + <Unlock className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">์ค์ ๋ ๊ถํ์ด ์์ต๋๋ค.</p> + <p className="text-xs mt-1">๋ชจ๋ ์ฌ์ฉ์๊ฐ ์ ๊ทผ ๊ฐ๋ฅํฉ๋๋ค.</p> + </div> + )} + </div> + + <Separator /> + + {/* ์ ๊ทผ ํต๊ณ */} + <div> + <h3 className="text-sm font-medium mb-3">์ ๊ทผ ํต๊ณ</h3> + <div className="grid grid-cols-2 gap-4"> + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="text-2xl font-bold"> + {selectedMenu.accessCount || 0} + </div> + <div className="text-xs text-muted-foreground"> + ์ด ์ ๊ทผ ํ์ + </div> + </div> + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="text-sm font-medium"> + {selectedMenu.lastAccessed + ? new Date(selectedMenu.lastAccessed).toLocaleDateString() + : "-"} + </div> + <div className="text-xs text-muted-foreground"> + ์ต๊ทผ ์ ๊ทผ์ผ + </div> + </div> + </div> + </div> + </div> + </CardContent> + </Card> + ) : ( + <Card className="col-span-2 flex items-center justify-center h-[600px]"> + <div className="text-center text-muted-foreground"> + <Menu className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>๋ฉ๋ด๋ฅผ ์ ํํ๋ฉด ๊ถํ ์ค์ ์ด ํ์๋ฉ๋๋ค.</p> + </div> + </Card> + )} + + {/* ๋ฉ๋ด ๊ถํ ํธ์ง ๋ค์ด์ผ๋ก๊ทธ */} + {selectedMenu && ( + <MenuPermissionEditDialog + open={editDialogOpen} + onOpenChange={setEditDialogOpen} + menu={selectedMenu} + availablePermissions={availablePermissions} + onSuccess={() => { + loadMenus(); + setEditDialogOpen(false); + }} + /> + )} + </div> + ); +} + +// ๋ด๋น์ ์ ๋ณด ์ปดํฌ๋ํธ +function ManagerInfo({ + label, + manager, + onEdit +}: { + label: string; + manager?: { id: number; name: string; email: string; imageUrl?: string }; + onEdit: () => void; +}) { + if (!manager) { + return ( + <div className="flex items-center justify-between p-2 border rounded-lg bg-muted/30"> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">{label}: ๋ฏธ์ง์ </span> + </div> + <Button size="sm" variant="ghost" onClick={onEdit}> + <Plus className="h-4 w-4" /> + </Button> + </div> + ); + } + + return ( + <div className="flex items-center justify-between p-2 border rounded-lg"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={manager.imageUrl} /> + <AvatarFallback>{manager.name[0]}</AvatarFallback> + </Avatar> + <div> + <div className="text-sm font-medium">{manager.name}</div> + <div className="text-xs text-muted-foreground">{manager.email}</div> + </div> + </div> + <Button size="sm" variant="ghost" onClick={onEdit}> + <Edit className="h-4 w-4" /> + </Button> + </div> + ); +} + +// ๋ฉ๋ด ๊ถํ ํธ์ง ๋ค์ด์ผ๋ก๊ทธ +function MenuPermissionEditDialog({ + open, + onOpenChange, + menu, + availablePermissions, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + menu: MenuItem; + availablePermissions: any[]; + onSuccess: () => void; +}) { + const [selectedPermissions, setSelectedPermissions] = useState< + Array<{ id: number; isRequired: boolean }> + >(() => menu.requiredPermissions.map(p => ({ id: p.id, isRequired: p.isRequired }))) + + + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + setSaving(true); + try { + await updateMenuPermissions(menu.menuPath, selectedPermissions); + toast.success("๋ฉ๋ด ๊ถํ์ด ์
๋ฐ์ดํธ๋์์ต๋๋ค."); + onSuccess(); + } catch (error) { + toast.error("๊ถํ ์
๋ฐ์ดํธ์ ์คํจํ์ต๋๋ค."); + } finally { + setSaving(false); + } + }; + + const togglePermission = (permissionId: number) => { + const existing = selectedPermissions.find(p => p.id === permissionId); + if (existing) { + setSelectedPermissions(selectedPermissions.filter(p => p.id !== permissionId)); + } else { + setSelectedPermissions([...selectedPermissions, { id: permissionId, isRequired: true }]); + } + }; + + const toggleRequired = (permissionId: number) => { + setSelectedPermissions( + selectedPermissions.map(p => + p.id === permissionId ? { ...p, isRequired: !p.isRequired } : p + ) + ); + }; + + // ๊ถํ์ ์นดํ
๊ณ ๋ฆฌ๋ณ๋ก ๊ทธ๋ฃนํ + const groupedPermissions = availablePermissions.reduce((acc, perm) => { + const category = perm.resource || "๊ธฐํ"; + if (!acc[category]) acc[category] = []; + acc[category].push(perm); + return acc; + }, {} as Record<string, any[]>); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{menu.menuTitle} ๊ถํ ์ค์ </DialogTitle> + <DialogDescription> + ์ด ๋ฉ๋ด์ ์ ๊ทผํ๊ธฐ ์ํ ํ์/์ ํ ๊ถํ์ ์ค์ ํฉ๋๋ค. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="h-[400px] pr-4"> + <div className="space-y-6"> + {Object.entries(groupedPermissions).map(([category, perms]) => ( + <div key={category}> + <h4 className="text-sm font-medium mb-3">{category}</h4> + <div className="space-y-2"> + {perms.map((permission) => { + const selected = selectedPermissions.find(p => p.id === permission.id); + return ( + <div + key={permission.id} + className={cn( + "flex items-center justify-between p-3 border rounded-lg", + selected && "bg-primary/5 border-primary" + )} + > + <div className="flex items-start gap-3"> + <Checkbox + checked={!!selected} + onCheckedChange={() => togglePermission(permission.id)} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + </div> + + {selected && ( + <div className="flex items-center gap-2"> + <Button + size="sm" + variant={selected.isRequired ? "default" : "outline"} + onClick={() => toggleRequired(permission.id)} + > + {selected.isRequired ? "ํ์" : "์ ํ"} + </Button> + </div> + )} + </div> + ); + })} + </div> + </div> + ))} + </div> + </ScrollArea> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + ์ทจ์ + </Button> + <Button onClick={handleSave} disabled={saving}> + {saving ? "์ ์ฅ ์ค..." : "์ ์ฅ"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-assignment-manager.tsx b/components/permissions/permission-assignment-manager.tsx new file mode 100644 index 00000000..3649631f --- /dev/null +++ b/components/permissions/permission-assignment-manager.tsx @@ -0,0 +1,319 @@ +// components/permissions/permission-assignment-manager.tsx (์
๋ฐ์ดํธ) + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Users, + User, + Plus, + X, + Search, + Shield, + Loader2 +} from "lucide-react"; +import { toast } from "sonner"; +import { + getPermissionAssignments, + assignPermissionToRoles, + assignPermissionToUsers, + removePermissionFromRole, + removePermissionFromUser, +} from "@/lib/permissions/permission-assignment-actions"; +import { cn } from "@/lib/utils"; + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; +} + +interface AssignedRole { + id: number; + name: string; + domain: string; + userCount: number; +} + +interface AssignedUser { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + isGrant: boolean; + reason?: string; +} + +export function PermissionAssignmentManager() { + const [permissions, setPermissions] = useState<Permission[]>([]); + const [selectedPermission, setSelectedPermission] = useState<Permission | null>(null); + const [assignedRoles, setAssignedRoles] = useState<AssignedRole[]>([]); + const [assignedUsers, setAssignedUsers] = useState<AssignedUser[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + loadPermissions(); + }, []); + + useEffect(() => { + if (selectedPermission) { + loadAssignments(selectedPermission.id); + } + }, [selectedPermission]); + + const loadPermissions = async () => { + setLoading(true); + try { + const data = await getPermissionAssignments(); + setPermissions(data.permissions); + } catch (error) { + toast.error("๊ถํ ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } finally { + setLoading(false); + } + }; + + const loadAssignments = async (permissionId: number) => { + try { + const data = await getPermissionAssignments(permissionId); + setAssignedRoles(data.roles); + setAssignedUsers(data.users); + } catch (error) { + toast.error("ํ ๋น ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } + }; + + const handleRemoveRole = async (roleId: number) => { + if (!selectedPermission) return; + + try { + await removePermissionFromRole(selectedPermission.id, roleId); + toast.success("์ญํ ์์ ๊ถํ์ด ์ ๊ฑฐ๋์์ต๋๋ค."); + loadAssignments(selectedPermission.id); + } catch (error) { + toast.error("๊ถํ ์ ๊ฑฐ์ ์คํจํ์ต๋๋ค."); + } + }; + + const handleRemoveUser = async (userId: number) => { + if (!selectedPermission) return; + + try { + await removePermissionFromUser(selectedPermission.id, userId); + toast.success("์ฌ์ฉ์์์ ๊ถํ์ด ์ ๊ฑฐ๋์์ต๋๋ค."); + loadAssignments(selectedPermission.id); + } catch (error) { + toast.error("๊ถํ ์ ๊ฑฐ์ ์คํจํ์ต๋๋ค."); + } + }; + + // ๊ถํ ํํฐ๋ง + const filteredPermissions = permissions.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.permissionKey.toLowerCase().includes(searchQuery.toLowerCase()) || + p.resource.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // ๋ฆฌ์์ค๋ณ ๊ถํ ๊ทธ๋ฃนํ + const groupedPermissions = filteredPermissions.reduce((acc, perm) => { + const group = perm.resource; + if (!acc[group]) acc[group] = []; + acc[group].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + return ( + <div className="grid grid-cols-2 gap-6"> + {/* ๊ถํ ๋ชฉ๋ก */} + <Card> + <CardHeader> + <CardTitle>๊ถํ ๋ชฉ๋ก</CardTitle> + <CardDescription>๊ถํ์ ์ ํํ์ฌ ํ ๋น์ ๊ด๋ฆฌํ์ธ์.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* ๊ฒ์ */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="๊ถํ ๊ฒ์..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* ๊ถํ ๋ชฉ๋ก */} + {loading ? ( + <div className="flex justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + </div> + ) : ( + <ScrollArea className="h-[500px]"> + <div className="space-y-4"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <div key={resource}> + <h4 className="font-medium mb-2 text-sm text-muted-foreground"> + {resource} + </h4> + <div className="space-y-1"> + {perms.map(permission => ( + <button + key={permission.id} + onClick={() => setSelectedPermission(permission)} + className={cn( + "w-full text-left p-3 rounded-lg border transition-colors", + selectedPermission?.id === permission.id + ? "bg-primary/10 border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-start justify-between"> + <div> + <div className="font-medium text-sm">{permission.name}</div> + <div className="text-xs text-muted-foreground mt-1"> + <code>{permission.permissionKey}</code> + </div> + </div> + <div className="flex gap-1"> + <Badge variant="outline" className="text-xs"> + {permission.permissionType} + </Badge> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + </div> + </button> + ))} + </div> + </div> + ))} + </div> + </ScrollArea> + )} + </div> + </CardContent> + </Card> + + {/* ํ ๋น ๊ด๋ฆฌ */} + {selectedPermission ? ( + <Card> + <CardHeader> + <CardTitle>{selectedPermission.name}</CardTitle> + <CardDescription> + <div className="flex gap-2 mt-2"> + <Badge>{selectedPermission.permissionKey}</Badge> + <Badge variant="outline">{selectedPermission.permissionType}</Badge> + <Badge variant="secondary">{selectedPermission.scope}</Badge> + </div> + </CardDescription> + </CardHeader> + <CardContent> + <Tabs defaultValue="roles"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="roles"> + <Users className="mr-2 h-4 w-4" /> + ์ญํ ({assignedRoles.length}) + </TabsTrigger> + <TabsTrigger value="users"> + <User className="mr-2 h-4 w-4" /> + ์ฌ์ฉ์ ({assignedUsers.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="roles" className="mt-4"> + <div className="space-y-4"> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + ์ญํ ์ถ๊ฐ + </Button> + <div className="space-y-2"> + {assignedRoles.map((role) => ( + <div key={role.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div> + <div className="font-medium">{role.name}</div> + <div className="text-sm text-muted-foreground"> + {role.domain} โข {role.userCount}๋ช
์ฌ์ฉ์ + </div> + </div> + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveRole(role.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + </TabsContent> + + <TabsContent value="users" className="mt-4"> + <div className="space-y-4"> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + ์ฌ์ฉ์ ์ถ๊ฐ + </Button> + <div className="space-y-2"> + {assignedUsers.map((user) => ( + <div key={user.id} className="flex items-center justify-between p-3 border rounded-lg"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div> + <div className="font-medium">{user.name}</div> + <div className="text-sm text-muted-foreground">{user.email}</div> + </div> + </div> + <div className="flex items-center gap-2"> + {user.isGrant ? ( + <Badge variant="success">๋ถ์ฌ</Badge> + ) : ( + <Badge variant="destructive">์ ํ</Badge> + )} + <Button + size="sm" + variant="ghost" + onClick={() => handleRemoveUser(user.id)} + > + <X className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </div> + </TabsContent> + </Tabs> + </CardContent> + </Card> + ) : ( + <Card className="flex items-center justify-center"> + <div className="text-center text-muted-foreground"> + <Shield className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>๊ถํ์ ์ ํํ๋ฉด ํ ๋น ์ ๋ณด๊ฐ ํ์๋ฉ๋๋ค.</p> + </div> + </Card> + )} + </div> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-crud-manager.tsx b/components/permissions/permission-crud-manager.tsx new file mode 100644 index 00000000..01c9959f --- /dev/null +++ b/components/permissions/permission-crud-manager.tsx @@ -0,0 +1,562 @@ +// components/permissions/permission-crud-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Plus, + Edit, + Trash2, + MoreVertical, + Search, + Filter, + Key, + Shield, + Copy, + CheckCircle +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getAllPermissions, + createPermission, + updatePermission, + deletePermission, + getPermissionCategories, +} from "@/lib/permissions/permission-settings-actions"; + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; + uiElement?: string; + isSystem: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export function PermissionCrudManager() { + const [permissions, setPermissions] = useState<Permission[]>([]); + const [filteredPermissions, setFilteredPermissions] = useState<Permission[]>([]); + const [categories, setCategories] = useState<{ resource: string; count: number }[]>([]); + const [selectedCategory, setSelectedCategory] = useState<string>("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingPermission, setEditingPermission] = useState<Permission | null>(null); + + useEffect(() => { + loadPermissions(); + loadCategories(); + }, []); + + useEffect(() => { + filterPermissions(); + }, [permissions, selectedCategory, searchQuery]); + + const loadPermissions = async () => { + setLoading(true); + try { + const data = await getAllPermissions(); + setPermissions(data); + } catch (error) { + toast.error("๊ถํ ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } finally { + setLoading(false); + } + }; + + const loadCategories = async () => { + try { + const data = await getPermissionCategories(); + setCategories(data); + } catch (error) { + console.error("์นดํ
๊ณ ๋ฆฌ ๋ก๋ ์คํจ:", error); + } + }; + + const filterPermissions = () => { + let filtered = permissions; + + if (selectedCategory !== "all") { + filtered = filtered.filter(p => p.resource === selectedCategory); + } + + if (searchQuery) { + filtered = filtered.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.permissionKey.toLowerCase().includes(searchQuery.toLowerCase()) || + p.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + setFilteredPermissions(filtered); + }; + + const handleDelete = async (id: number) => { + if (!confirm("์ด ๊ถํ์ ์ญ์ ํ์๊ฒ ์ต๋๊น? ๊ด๋ จ๋ ๋ชจ๋ ํ ๋น์ด ์ ๊ฑฐ๋ฉ๋๋ค.")) { + return; + } + + try { + await deletePermission(id); + toast.success("๊ถํ์ด ์ญ์ ๋์์ต๋๋ค."); + loadPermissions(); + } catch (error) { + toast.error("๊ถํ ์ญ์ ์ ์คํจํ์ต๋๋ค."); + } + }; + + return ( + <div className="space-y-6"> + {/* ํค๋ ๋ฐ ํํฐ */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>๊ถํ ๋ชฉ๋ก</CardTitle> + <CardDescription> + ์์คํ
์ ๋ฑ๋ก๋ ๋ชจ๋ ๊ถํ์ ๊ด๋ฆฌํฉ๋๋ค. + </CardDescription> + </div> + <Button onClick={() => setCreateDialogOpen(true)}> + <Plus className="mr-2 h-4 w-4" /> + ๊ถํ ์ถ๊ฐ + </Button> + </div> + </CardHeader> + <CardContent> + <div className="flex gap-4 mb-4"> + {/* ๊ฒ์ */} + <div className="flex-1"> + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="๊ถํ๋ช
, ํค๋ก ๊ฒ์..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + </div> + + {/* ์นดํ
๊ณ ๋ฆฌ ํํฐ */} + <Select value={selectedCategory} onValueChange={setSelectedCategory}> + <SelectTrigger className="w-[200px]"> + <SelectValue placeholder="์นดํ
๊ณ ๋ฆฌ ์ ํ" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">์ ์ฒด ({permissions.length})</SelectItem> + {categories.map(cat => ( + <SelectItem key={cat.resource} value={cat.resource}> + {cat.resource} ({cat.count}) + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* ๊ถํ ํ
์ด๋ธ */} + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>๊ถํ๋ช
</TableHead> + <TableHead>๊ถํ ํค</TableHead> + <TableHead>ํ์
</TableHead> + <TableHead>๋ฆฌ์์ค</TableHead> + <TableHead>์ก์
</TableHead> + <TableHead>๋ฒ์</TableHead> + <TableHead>์ํ</TableHead> + <TableHead className="text-right">์์
</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredPermissions.map(permission => ( + <TableRow key={permission.id}> + <TableCell> + <div> + <div className="font-medium">{permission.name}</div> + {permission.description && ( + <div className="text-xs text-muted-foreground"> + {permission.description} + </div> + )} + </div> + </TableCell> + <TableCell> + <code className="text-xs bg-muted px-1 py-0.5 rounded"> + {permission.permissionKey} + </code> + </TableCell> + <TableCell> + <Badge variant="outline">{permission.permissionType}</Badge> + </TableCell> + <TableCell>{permission.resource}</TableCell> + <TableCell>{permission.action}</TableCell> + <TableCell> + <Badge variant="secondary">{permission.scope}</Badge> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + {permission.isActive ? ( + <Badge variant="success">ํ์ฑ</Badge> + ) : ( + <Badge variant="destructive">๋นํ์ฑ</Badge> + )} + {permission.isSystem && ( + <Badge variant="outline">์์คํ
</Badge> + )} + </div> + </TableCell> + <TableCell className="text-right"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => { + navigator.clipboard.writeText(permission.permissionKey); + toast.success("๊ถํ ํค๊ฐ ๋ณต์ฌ๋์์ต๋๋ค."); + }} + > + <Copy className="mr-2 h-4 w-4" /> + ํค ๋ณต์ฌ + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setEditingPermission(permission)} + > + <Edit className="mr-2 h-4 w-4" /> + ์์ + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleDelete(permission.id)} + className="text-destructive" + disabled={permission.isSystem} + > + <Trash2 className="mr-2 h-4 w-4" /> + ์ญ์ + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </CardContent> + </Card> + + {/* ๊ถํ ์์ฑ/์์ ๋ค์ด์ผ๋ก๊ทธ */} + <PermissionFormDialog + open={createDialogOpen || !!editingPermission} + onOpenChange={(open) => { + if (!open) { + setCreateDialogOpen(false); + setEditingPermission(null); + } + }} + permission={editingPermission} + onSuccess={() => { + setCreateDialogOpen(false); + setEditingPermission(null); + loadPermissions(); + }} + /> + </div> + ); +} + +// ๊ถํ ์์ฑ/์์ ํผ ๋ค์ด์ผ๋ก๊ทธ +function PermissionFormDialog({ + open, + onOpenChange, + permission, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + permission?: Permission | null; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + permissionKey: "", + name: "", + description: "", + permissionType: "action", + resource: "", + action: "", + scope: "own", + menuPath: "", + uiElement: "", + isActive: true, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (permission) { + setFormData({ + permissionKey: permission.permissionKey, + name: permission.name, + description: permission.description || "", + permissionType: permission.permissionType, + resource: permission.resource, + action: permission.action, + scope: permission.scope, + menuPath: permission.menuPath || "", + uiElement: permission.uiElement || "", + isActive: permission.isActive, + }); + } else { + setFormData({ + permissionKey: "", + name: "", + description: "", + permissionType: "action", + resource: "", + action: "", + scope: "own", + menuPath: "", + uiElement: "", + isActive: true, + }); + } + }, [permission]); + + const handleSubmit = async () => { + if (!formData.permissionKey || !formData.name || !formData.resource || !formData.action) { + toast.error("ํ์ ํญ๋ชฉ์ ์
๋ ฅํด์ฃผ์ธ์."); + return; + } + + setSaving(true); + try { + if (permission) { + await updatePermission(permission.id, formData); + toast.success("๊ถํ์ด ์์ ๋์์ต๋๋ค."); + } else { + await createPermission(formData); + toast.success("๊ถํ์ด ์์ฑ๋์์ต๋๋ค."); + } + onSuccess(); + } catch (error: any) { + toast.error(error.message || "๊ถํ ์ ์ฅ์ ์คํจํ์ต๋๋ค."); + } finally { + setSaving(false); + } + }; + + // ๊ถํ ํค ์๋ ์์ฑ + const generatePermissionKey = () => { + if (formData.resource && formData.action) { + const key = `${formData.resource}.${formData.action}`.toLowerCase().replace(/\s+/g, '_'); + setFormData({ ...formData, permissionKey: key }); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{permission ? "๊ถํ ์์ " : "๊ถํ ์์ฑ"}</DialogTitle> + <DialogDescription> + ์๋ก์ด ๊ถํ์ ์์ฑํ๊ฑฐ๋ ๊ธฐ์กด ๊ถํ์ ์์ ํฉ๋๋ค. + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>๊ถํ ํค*</Label> + <div className="flex gap-2"> + <Input + value={formData.permissionKey} + onChange={(e) => setFormData({ ...formData, permissionKey: e.target.value })} + placeholder="์: rfq.vendor.create" + /> + {!permission && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={generatePermissionKey} + > + ์๋ + </Button> + )} + </div> + </div> + <div> + <Label>๊ถํ๋ช
*</Label> + <Input + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + placeholder="์: RFQ ๋ฒค๋ ์ถ๊ฐ" + /> + </div> + </div> + + <div> + <Label>์ค๋ช
</Label> + <Textarea + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="๊ถํ์ ๋ํ ์์ธ ์ค๋ช
" + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>๊ถํ ํ์
*</Label> + <Select + value={formData.permissionType} + onValueChange={(v) => setFormData({ ...formData, permissionType: v })} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="menu_access">๋ฉ๋ด ์ ๊ทผ</SelectItem> + <SelectItem value="action">์ก์
์คํ</SelectItem> + <SelectItem value="data_read">๋ฐ์ดํฐ ์ฝ๊ธฐ</SelectItem> + <SelectItem value="data_write">๋ฐ์ดํฐ ์ฐ๊ธฐ</SelectItem> + <SelectItem value="data_delete">๋ฐ์ดํฐ ์ญ์ </SelectItem> + <SelectItem value="approve">์น์ธ</SelectItem> + <SelectItem value="export">๋ด๋ณด๋ด๊ธฐ</SelectItem> + <SelectItem value="import">๊ฐ์ ธ์ค๊ธฐ</SelectItem> + </SelectContent> + </Select> + </div> + <div> + <Label>๋ฒ์*</Label> + <Select + value={formData.scope} + onValueChange={(v) => setFormData({ ...formData, scope: v })} + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">์ ์ฒด</SelectItem> + <SelectItem value="domain">๋๋ฉ์ธ</SelectItem> + <SelectItem value="assigned">๋ด๋น</SelectItem> + <SelectItem value="own">๋ณธ์ธ</SelectItem> + <SelectItem value="department">๋ถ์</SelectItem> + <SelectItem value="company">ํ์ฌ</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>๋ฆฌ์์ค*</Label> + <Input + value={formData.resource} + onChange={(e) => setFormData({ ...formData, resource: e.target.value })} + placeholder="์: rfq_vendor" + /> + </div> + <div> + <Label>์ก์
*</Label> + <Input + value={formData.action} + onChange={(e) => setFormData({ ...formData, action: e.target.value })} + placeholder="์: create" + /> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label>๋ฉ๋ด ๊ฒฝ๋ก</Label> + <Input + value={formData.menuPath} + onChange={(e) => setFormData({ ...formData, menuPath: e.target.value })} + placeholder="์: /evcp/rfq-last" + /> + </div> + <div> + <Label>UI ์์</Label> + <Input + value={formData.uiElement} + onChange={(e) => setFormData({ ...formData, uiElement: e.target.value })} + placeholder="์: btn-add-vendor" + /> + </div> + </div> + + <div className="flex items-center gap-2"> + <input + type="checkbox" + id="isActive" + checked={formData.isActive} + onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })} + /> + <Label htmlFor="isActive">ํ์ฑ ์ํ</Label> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + ์ทจ์ + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "์ ์ฅ ์ค..." : permission ? "์์ " : "์์ฑ"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/permission-group-manager.tsx b/components/permissions/permission-group-manager.tsx new file mode 100644 index 00000000..11aac6cf --- /dev/null +++ b/components/permissions/permission-group-manager.tsx @@ -0,0 +1,799 @@ +// components/permissions/permission-group-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Shield, + Plus, + Edit, + Trash2, + Copy, + Users, + Key, + MoreVertical, + Package, + ChevronRight, + Loader2, + Search +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getPermissionGroups, + createPermissionGroup, + updatePermissionGroup, + deletePermissionGroup, + getGroupPermissions, + updateGroupPermissions, + clonePermissionGroup, + getGroupAssignments, +} from "@/lib/permissions/permission-group-actions"; + +interface PermissionGroup { + id: number; + groupKey: string; + name: string; + description?: string; + domain?: string; + isActive: boolean; + permissionCount: number; + roleCount: number; + userCount: number; + createdAt: Date; + updatedAt: Date; +} + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + resource: string; + action: string; + permissionType: string; + scope: string; +} + +export function PermissionGroupManager() { + const [groups, setGroups] = useState<PermissionGroup[]>([]); + const [selectedGroup, setSelectedGroup] = useState<PermissionGroup | null>(null); + const [groupPermissions, setGroupPermissions] = useState<Permission[]>([]); + const [availablePermissions, setAvailablePermissions] = useState<Permission[]>([]); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState<PermissionGroup | null>(null); + const [permissionDialogOpen, setPermissionDialogOpen] = useState(false); + + useEffect(() => { + loadGroups(); + }, []); + + useEffect(() => { + if (selectedGroup) { + loadGroupPermissions(selectedGroup.id); + } + }, [selectedGroup]); + + const loadGroups = async () => { + setLoading(true); + try { + const data = await getPermissionGroups(); + setGroups(data); + } catch (error) { + toast.error("๊ถํ ๊ทธ๋ฃน์ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } finally { + setLoading(false); + } + }; + + const loadGroupPermissions = async (groupId: number) => { + try { + const data = await getGroupPermissions(groupId); + setGroupPermissions(data.permissions); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("๊ถํ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } + }; + + const handleDelete = async (id: number) => { + if (!confirm("์ด ๊ถํ ๊ทธ๋ฃน์ ์ญ์ ํ์๊ฒ ์ต๋๊น? ๊ด๋ จ๋ ๋ชจ๋ ํ ๋น์ด ์ ๊ฑฐ๋ฉ๋๋ค.")) { + return; + } + + try { + await deletePermissionGroup(id); + toast.success("๊ถํ ๊ทธ๋ฃน์ด ์ญ์ ๋์์ต๋๋ค."); + if (selectedGroup?.id === id) { + setSelectedGroup(null); + } + loadGroups(); + } catch (error) { + toast.error("๊ถํ ๊ทธ๋ฃน ์ญ์ ์ ์คํจํ์ต๋๋ค."); + } + }; + + const handleClone = async (group: PermissionGroup) => { + try { + const cloned = await clonePermissionGroup(group.id); + toast.success(`"${cloned.name}" ๊ทธ๋ฃน์ด ์์ฑ๋์์ต๋๋ค.`); + loadGroups(); + } catch (error) { + toast.error("๊ถํ ๊ทธ๋ฃน ๋ณต์ ์ ์คํจํ์ต๋๋ค."); + } + }; + + // ๊ฒ์ ํํฐ๋ง + const filteredGroups = groups.filter(group => + group.name.toLowerCase().includes(searchQuery.toLowerCase()) || + group.groupKey.toLowerCase().includes(searchQuery.toLowerCase()) || + group.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <div className="space-y-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>๊ถํ ๊ทธ๋ฃน</CardTitle> + <CardDescription> + ๊ด๋ จ๋ ๊ถํ๋ค์ ๊ทธ๋ฃน์ผ๋ก ๋ฌถ์ด ํจ์จ์ ์ผ๋ก ๊ด๋ฆฌํฉ๋๋ค. + </CardDescription> + </div> + <Button onClick={() => setCreateDialogOpen(true)}> + <Plus className="mr-2 h-4 w-4" /> + ๊ทธ๋ฃน ์์ฑ + </Button> + </div> + </CardHeader> + <CardContent> + {/* ๊ฒ์ */} + <div className="mb-4"> + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="๊ทธ๋ฃน๋ช
, ํค๋ก ๊ฒ์..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + </div> + + {/* ๊ทธ๋ฃน ๋ชฉ๋ก */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + {filteredGroups.map(group => ( + <GroupCard + key={group.id} + group={group} + isSelected={selectedGroup?.id === group.id} + onSelect={() => setSelectedGroup(group)} + onEdit={() => setEditingGroup(group)} + onClone={() => handleClone(group)} + onDelete={() => handleDelete(group.id)} + onManagePermissions={() => { + setSelectedGroup(group); + setPermissionDialogOpen(true); + }} + /> + ))} + </div> + </CardContent> + </Card> + + {/* ์ ํ๋ ๊ทธ๋ฃน ์์ธ */} + {selectedGroup && ( + <GroupDetailCard + group={selectedGroup} + permissions={groupPermissions} + onEditPermissions={() => setPermissionDialogOpen(true)} + /> + )} + + {/* ๊ทธ๋ฃน ์์ฑ/์์ ๋ค์ด์ผ๋ก๊ทธ */} + <GroupFormDialog + open={createDialogOpen || !!editingGroup} + onOpenChange={(open) => { + if (!open) { + setCreateDialogOpen(false); + setEditingGroup(null); + } + }} + group={editingGroup} + onSuccess={() => { + setCreateDialogOpen(false); + setEditingGroup(null); + loadGroups(); + }} + /> + + {/* ๊ถํ ๊ด๋ฆฌ ๋ค์ด์ผ๋ก๊ทธ */} + {selectedGroup && ( + <GroupPermissionsDialog + open={permissionDialogOpen} + onOpenChange={setPermissionDialogOpen} + group={selectedGroup} + groupPermissions={groupPermissions} + availablePermissions={availablePermissions} + onSuccess={() => { + loadGroupPermissions(selectedGroup.id); + loadGroups(); + }} + /> + )} + </div> + ); +} + +// ๊ทธ๋ฃน ์นด๋ ์ปดํฌ๋ํธ +function GroupCard({ + group, + isSelected, + onSelect, + onEdit, + onClone, + onDelete, + onManagePermissions, +}: { + group: PermissionGroup; + isSelected: boolean; + onSelect: () => void; + onEdit: () => void; + onClone: () => void; + onDelete: () => void; + onManagePermissions: () => void; +}) { + return ( + <Card + className={cn( + "cursor-pointer transition-colors", + isSelected && "ring-2 ring-primary" + )} + onClick={onSelect} + > + <CardHeader className="pb-3"> + <div className="flex items-start justify-between"> + <div> + <CardTitle className="text-lg">{group.name}</CardTitle> + <div className="flex items-center gap-2 mt-1"> + <code className="text-xs bg-muted px-1 py-0.5 rounded"> + {group.groupKey} + </code> + {group.domain && ( + <Badge variant="outline" className="text-xs"> + {group.domain} + </Badge> + )} + </div> + </div> + <DropdownMenu> + <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> + <Button variant="ghost" className="h-8 w-8 p-0"> + <MoreVertical className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onManagePermissions(); + }}> + <Key className="mr-2 h-4 w-4" /> + ๊ถํ ๊ด๋ฆฌ + </DropdownMenuItem> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onEdit(); + }}> + <Edit className="mr-2 h-4 w-4" /> + ์์ + </DropdownMenuItem> + <DropdownMenuItem onClick={(e) => { + e.stopPropagation(); + onClone(); + }}> + <Copy className="mr-2 h-4 w-4" /> + ๋ณต์ + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={(e) => { + e.stopPropagation(); + onDelete(); + }} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + ์ญ์ + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </CardHeader> + <CardContent> + {group.description && ( + <p className="text-sm text-muted-foreground mb-3"> + {group.description} + </p> + )} + <div className="flex items-center gap-4 text-sm"> + <div className="flex items-center gap-1"> + <Key className="h-3 w-3" /> + <span>{group.permissionCount}๊ฐ ๊ถํ</span> + </div> + <div className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + <span>{group.roleCount}๊ฐ ์ญํ </span> + </div> + <div className="flex items-center gap-1"> + <Shield className="h-3 w-3" /> + <span>{group.userCount}๋ช
</span> + </div> + </div> + </CardContent> + </Card> + ); +} + +// ๊ทธ๋ฃน ์์ธ ์นด๋ +function GroupDetailCard({ + group, + permissions, + onEditPermissions, +}: { + group: PermissionGroup; + permissions: Permission[]; + onEditPermissions: () => void; +}) { + // ๋ฆฌ์์ค๋ณ๋ก ๊ถํ ๊ทธ๋ฃนํ + const groupedPermissions = permissions.reduce((acc, perm) => { + const resource = perm.resource; + if (!acc[resource]) acc[resource] = []; + acc[resource].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>{group.name} ๊ถํ ๋ชฉ๋ก</CardTitle> + <CardDescription>์ด ๊ทธ๋ฃน์ ํฌํจ๋ ๋ชจ๋ ๊ถํ์
๋๋ค.</CardDescription> + </div> + <Button onClick={onEditPermissions}> + <Edit className="mr-2 h-4 w-4" /> + ๊ถํ ํธ์ง + </Button> + </div> + </CardHeader> + <CardContent> + <Accordion type="multiple" className="w-full"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <AccordionItem key={resource} value={resource}> + <AccordionTrigger> + <div className="flex items-center justify-between flex-1 mr-2"> + <span className="font-medium">{resource}</span> + <Badge variant="secondary">{perms.length}๊ฐ</Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-2"> + {perms.map(permission => ( + <div key={permission.id} className="flex items-start gap-3 p-2"> + <Badge variant="outline" className="mt-0.5"> + {permission.action} + </Badge> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + ))} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </CardContent> + </Card> + ); +} + +// ๊ทธ๋ฃน ์์ฑ/์์ ํผ ๋ค์ด์ผ๋ก๊ทธ +function GroupFormDialog({ + open, + onOpenChange, + group, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group?: PermissionGroup | null; + onSuccess: () => void; +}) { + const [formData, setFormData] = useState({ + groupKey: "", + name: "", + description: "", + domain: "", + isActive: true, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (group) { + setFormData({ + groupKey: group.groupKey, + name: group.name, + description: group.description || "", + domain: group.domain || "", + isActive: group.isActive, + }); + } else { + setFormData({ + groupKey: "", + name: "", + description: "", + domain: "", + isActive: true, + }); + } + }, [group]); + + const handleSubmit = async () => { + if (!formData.groupKey || !formData.name) { + toast.error("ํ์ ํญ๋ชฉ์ ์
๋ ฅํด์ฃผ์ธ์."); + return; + } + + setSaving(true); + try { + if (group) { + await updatePermissionGroup(group.id, formData); + toast.success("๊ถํ ๊ทธ๋ฃน์ด ์์ ๋์์ต๋๋ค."); + } else { + await createPermissionGroup(formData); + toast.success("๊ถํ ๊ทธ๋ฃน์ด ์์ฑ๋์์ต๋๋ค."); + } + onSuccess(); + } catch (error: any) { + toast.error(error.message || "๊ถํ ๊ทธ๋ฃน ์ ์ฅ์ ์คํจํ์ต๋๋ค."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{group ? "๊ถํ ๊ทธ๋ฃน ์์ " : "๊ถํ ๊ทธ๋ฃน ์์ฑ"}</DialogTitle> + <DialogDescription> + ๊ถํ ๊ทธ๋ฃน ์ ๋ณด๋ฅผ ์
๋ ฅํ์ธ์. + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + <div> + <Label>๊ทธ๋ฃน ํค*</Label> + <Input + value={formData.groupKey} + onChange={(e) => setFormData({ ...formData, groupKey: e.target.value })} + placeholder="์: rfq_manager" + /> + </div> + + <div> + <Label>๊ทธ๋ฃน๋ช
*</Label> + <Input + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + placeholder="์: RFQ ๊ด๋ฆฌ์ ๊ถํ" + /> + </div> + + <div> + <Label>์ค๋ช
</Label> + <Textarea + value={formData.description} + onChange={(e) => setFormData({ ...formData, description: e.target.value })} + placeholder="๊ทธ๋ฃน์ ๋ํ ์ค๋ช
" + /> + </div> + + <div> + <Label>๋๋ฉ์ธ</Label> + <Select + value={formData.domain} + onValueChange={(v) => setFormData({ ...formData, domain: v })} + > + <SelectTrigger> + <SelectValue placeholder="๋๋ฉ์ธ ์ ํ" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="">์ ์ฒด</SelectItem> + <SelectItem value="evcp">EVCP</SelectItem> + <SelectItem value="partners">Partners</SelectItem> + <SelectItem value="procurement">Procurement</SelectItem> + <SelectItem value="sales">Sales</SelectItem> + <SelectItem value="engineering">Engineering</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="flex items-center gap-2"> + <Checkbox + id="isActive" + checked={formData.isActive} + onCheckedChange={(v) => setFormData({ ...formData, isActive: !!v })} + /> + <Label htmlFor="isActive">ํ์ฑ ์ํ</Label> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + ์ทจ์ + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "์ ์ฅ ์ค..." : group ? "์์ " : "์์ฑ"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// ๊ทธ๋ฃน ๊ถํ ๊ด๋ฆฌ ๋ค์ด์ผ๋ก๊ทธ +function GroupPermissionsDialog({ + open, + onOpenChange, + group, + groupPermissions, + availablePermissions, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + group: PermissionGroup; + groupPermissions: Permission[]; + availablePermissions: Permission[]; + onSuccess: () => void; +}) { + const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>( + new Set(groupPermissions.map(p => p.id)) + ); + const [saving, setSaving] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + // ๊ฒ์ ํํฐ๋ง + const filteredPermissions = availablePermissions.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.permissionKey.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // ๋ฆฌ์์ค๋ณ๋ก ๊ถํ ๊ทธ๋ฃนํ + const groupedPermissions = filteredPermissions.reduce((acc, perm) => { + const resource = perm.resource; + if (!acc[resource]) acc[resource] = []; + acc[resource].push(perm); + return acc; + }, {} as Record<string, Permission[]>); + + const handleSave = async () => { + setSaving(true); + try { + await updateGroupPermissions(group.id, Array.from(selectedPermissions)); + toast.success("๊ถํ์ด ์
๋ฐ์ดํธ๋์์ต๋๋ค."); + onSuccess(); + onOpenChange(false); + } catch (error) { + toast.error("๊ถํ ์
๋ฐ์ดํธ์ ์คํจํ์ต๋๋ค."); + } finally { + setSaving(false); + } + }; + + const togglePermission = (permissionId: number) => { + const newSet = new Set(selectedPermissions); + if (newSet.has(permissionId)) { + newSet.delete(permissionId); + } else { + newSet.add(permissionId); + } + setSelectedPermissions(newSet); + }; + + const toggleResource = (resource: string) => { + const resourcePerms = groupedPermissions[resource] || []; + const allSelected = resourcePerms.every(p => selectedPermissions.has(p.id)); + + const newSet = new Set(selectedPermissions); + resourcePerms.forEach(p => { + if (allSelected) { + newSet.delete(p.id); + } else { + newSet.add(p.id); + } + }); + setSelectedPermissions(newSet); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>{group.name} ๊ถํ ์ค์ </DialogTitle> + <DialogDescription> + ์ด ๊ทธ๋ฃน์ ํฌํจํ ๊ถํ์ ์ ํํ์ธ์. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* ๊ฒ์ */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="๊ถํ ๊ฒ์..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* ์ ํ ์ ๋ณด */} + <div className="flex items-center justify-between p-2 bg-muted rounded"> + <span className="text-sm"> + {selectedPermissions.size}๊ฐ ๊ถํ ์ ํ๋จ + </span> + <div className="flex gap-2"> + <Button + size="sm" + variant="outline" + onClick={() => setSelectedPermissions(new Set())} + > + ์ ์ฒด ํด์ + </Button> + <Button + size="sm" + variant="outline" + onClick={() => setSelectedPermissions(new Set(availablePermissions.map(p => p.id)))} + > + ์ ์ฒด ์ ํ + </Button> + </div> + </div> + + {/* ๊ถํ ๋ชฉ๋ก */} + <ScrollArea className="h-[400px] pr-4"> + <Accordion type="multiple" className="w-full"> + {Object.entries(groupedPermissions).map(([resource, perms]) => { + const allSelected = perms.every(p => selectedPermissions.has(p.id)); + const someSelected = perms.some(p => selectedPermissions.has(p.id)); + + return ( + <AccordionItem key={resource} value={resource}> + <AccordionTrigger> + <div className="flex items-center gap-2 flex-1"> + <Checkbox + checked={allSelected} + indeterminate={!allSelected && someSelected} + onCheckedChange={() => toggleResource(resource)} + onClick={(e) => e.stopPropagation()} + /> + <span className="font-medium">{resource}</span> + <Badge variant="secondary" className="ml-auto mr-2"> + {perms.filter(p => selectedPermissions.has(p.id)).length}/{perms.length} + </Badge> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-2 pl-6"> + {perms.map(permission => ( + <label + key={permission.id} + className="flex items-start gap-3 cursor-pointer p-2 hover:bg-muted rounded" + > + <Checkbox + checked={selectedPermissions.has(permission.id)} + onCheckedChange={() => togglePermission(permission.id)} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.permissionKey} + </div> + {permission.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {permission.description} + </div> + )} + </div> + <div className="flex gap-1"> + <Badge variant="outline" className="text-xs"> + {permission.permissionType} + </Badge> + <Badge variant="secondary" className="text-xs"> + {permission.scope} + </Badge> + </div> + </label> + ))} + </div> + </AccordionContent> + </AccordionItem> + ); + })} + </Accordion> + </ScrollArea> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + ์ทจ์ + </Button> + <Button onClick={handleSave} disabled={saving}> + {saving ? "์ ์ฅ ์ค..." : "์ ์ฅ"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/components/permissions/role-permission-manager.tsx b/components/permissions/role-permission-manager.tsx new file mode 100644 index 00000000..b229ec57 --- /dev/null +++ b/components/permissions/role-permission-manager.tsx @@ -0,0 +1,178 @@ +// components/permissions/role-permission-manager.tsx + +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; +import { assignPermissionsToRole, getRolePermissions } from "@/lib/permissions/service"; + +export function RolePermissionManager() { + const [selectedRole, setSelectedRole] = useState<string>(""); + const [permissions, setPermissions] = useState<any[]>([]); + const [selectedPermissions, setSelectedPermissions] = useState<Set<number>>(new Set()); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (selectedRole) { + loadRolePermissions(selectedRole); + } + }, [selectedRole]); + + const loadRolePermissions = async (roleId: string) => { + try { + const data = await getRolePermissions(parseInt(roleId)); + setPermissions(data.permissions); + setSelectedPermissions(new Set(data.assignedPermissionIds)); + } catch (error) { + toast.error("๊ถํ ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } + }; + + const handleSave = async () => { + if (!selectedRole) { + toast.error("์ญํ ์ ์ ํํด์ฃผ์ธ์."); + return; + } + + try { + setLoading(true); + await assignPermissionsToRole( + parseInt(selectedRole), + Array.from(selectedPermissions) + ); + toast.success("๊ถํ์ด ์ฑ๊ณต์ ์ผ๋ก ์ ์ฅ๋์์ต๋๋ค."); + } catch (error) { + toast.error("๊ถํ ์ ์ฅ์ ์คํจํ์ต๋๋ค."); + } finally { + setLoading(false); + } + }; + + const togglePermission = (permissionId: number) => { + const newSet = new Set(selectedPermissions); + if (newSet.has(permissionId)) { + newSet.delete(permissionId); + } else { + newSet.add(permissionId); + } + setSelectedPermissions(newSet); + }; + + // ๊ถํ ๊ทธ๋ฃน๋ณ๋ก ์ ๋ฆฌ + const groupedPermissions = permissions.reduce((acc, perm) => { + const group = perm.menuPath || "๊ธฐํ"; + if (!acc[group]) acc[group] = []; + acc[group].push(perm); + return acc; + }, {} as Record<string, any[]>); + + return ( + <Card> + <CardHeader> + <CardTitle>์ญํ ๋ณ ๊ถํ ์ค์ </CardTitle> + <CardDescription> + ์ญํ ์ ์ ํํ๊ณ ํด๋น ์ญํ ์ ๋ถ์ฌํ ๊ถํ์ ์ ํํ์ธ์. + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-6"> + {/* ์ญํ ์ ํ */} + <div className="flex items-center gap-4"> + <Select value={selectedRole} onValueChange={setSelectedRole}> + <SelectTrigger className="w-[300px]"> + <SelectValue placeholder="์ญํ ์ ํ..." /> + </SelectTrigger> + <SelectContent> + <SelectItem value="1">EVCP Admin</SelectItem> + <SelectItem value="2">EVCP Manager</SelectItem> + <SelectItem value="3">EVCP User</SelectItem> + <SelectItem value="4">Partner Admin</SelectItem> + <SelectItem value="5">Partner User</SelectItem> + </SelectContent> + </Select> + + <Button + onClick={handleSave} + disabled={!selectedRole || loading} + > + ๊ถํ ์ ์ฅ + </Button> + </div> + + {/* ๊ถํ ๋ชฉ๋ก */} + {selectedRole && ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">์ ํ</TableHead> + <TableHead>๋ฉ๋ด/๊ทธ๋ฃน</TableHead> + <TableHead>๊ถํ๋ช
</TableHead> + <TableHead>ํ์
</TableHead> + <TableHead>๋ฒ์</TableHead> + <TableHead>์ค๋ช
</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {Object.entries(groupedPermissions).map(([group, perms]) => ( + <> + <TableRow key={group} className="bg-muted/50"> + <TableCell colSpan={6} className="font-medium"> + {group} + </TableCell> + </TableRow> + {perms.map((permission) => ( + <TableRow key={permission.id}> + <TableCell> + <Checkbox + checked={selectedPermissions.has(permission.id)} + onCheckedChange={() => togglePermission(permission.id)} + /> + </TableCell> + <TableCell> + <Badge variant="outline">{permission.resource}</Badge> + </TableCell> + <TableCell className="font-medium"> + {permission.name} + </TableCell> + <TableCell> + <Badge>{permission.permissionType}</Badge> + </TableCell> + <TableCell> + <Badge variant="secondary">{permission.scope}</Badge> + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {permission.description} + </TableCell> + </TableRow> + ))} + </> + ))} + </TableBody> + </Table> + </div> + )} + </div> + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/components/permissions/user-permission-manager.tsx b/components/permissions/user-permission-manager.tsx new file mode 100644 index 00000000..9c23b122 --- /dev/null +++ b/components/permissions/user-permission-manager.tsx @@ -0,0 +1,573 @@ +// components/permissions/user-permission-manager.tsx + +"use client"; + +import { useState, useEffect, useTransition } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Search, + UserPlus, + Shield, + Clock, + AlertTriangle, + CheckCircle, + XCircle, + CalendarIcon, + Plus, + Minus, + Settings, + Key, + Users, + Building, + ChevronRight +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + getUserPermissionDetails, + grantPermissionToUser, + revokePermissionFromUser, + searchUsers, + getUserRoles, +} from "@/lib/permissions/service"; + +interface User { + id: number; + name: string; + email: string; + imageUrl?: string; + domain: string; + companyName?: string; + roles: { id: number; name: string }[]; +} + +interface Permission { + id: number; + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; +} + +interface UserPermission extends Permission { + source: "role" | "direct"; + roleName?: string; + grantedBy?: string; + grantedAt?: Date; + expiresAt?: Date; + reason?: string; + isGrant: boolean; +} + +export function UserPermissionManager() { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedUser, setSelectedUser] = useState<User | null>(null); + const [users, setUsers] = useState<User[]>([]); + const [userPermissions, setUserPermissions] = useState<UserPermission[]>([]); + const [availablePermissions, setAvailablePermissions] = useState<Permission[]>([]); + const [loading, setLoading] = useState(false); + const [isPending, startTransition] = useTransition(); + const [addPermissionDialogOpen, setAddPermissionDialogOpen] = useState(false); + + // ์ฌ์ฉ์ ๊ฒ์ + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery) { + searchUsersData(searchQuery); + } + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // ์ ํ๋ ์ฌ์ฉ์์ ๊ถํ ๋ก๋ + useEffect(() => { + if (selectedUser) { + loadUserPermissions(selectedUser.id); + } + }, [selectedUser]); + + const searchUsersData = async (query: string) => { + try { + const data = await searchUsers(query); + setUsers(data); + } catch (error) { + toast.error("์ฌ์ฉ์ ๊ฒ์์ ์คํจํ์ต๋๋ค."); + } + }; + + const loadUserPermissions = async (userId: number) => { + setLoading(true); + try { + const data = await getUserPermissionDetails(userId); + setUserPermissions(data.permissions); + setAvailablePermissions(data.availablePermissions); + } catch (error) { + toast.error("๊ถํ ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ค๋๋ฐ ์คํจํ์ต๋๋ค."); + } finally { + setLoading(false); + } + }; + + // ์ญํ ๊ธฐ๋ฐ ๊ถํ๊ณผ ์ง์ ๋ถ์ฌ ๊ถํ ๋ถ๋ฆฌ + const rolePermissions = userPermissions.filter(p => p.source === "role"); + const directPermissions = userPermissions.filter(p => p.source === "direct"); + + return ( + <div className="grid grid-cols-3 gap-6"> + {/* ์ฌ์ฉ์ ๊ฒ์ ๋ฐ ์ ํ */} + <Card className="col-span-1"> + <CardHeader> + <CardTitle>์ฌ์ฉ์ ์ ํ</CardTitle> + <CardDescription>๊ถํ์ ๊ด๋ฆฌํ ์ฌ์ฉ์๋ฅผ ๊ฒ์ํ์ธ์.</CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* ๊ฒ์ ์
๋ ฅ */} + <div className="relative"> + <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> + <Input + placeholder="์ด๋ฆ, ์ด๋ฉ์ผ๋ก ๊ฒ์..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + {/* ์ฌ์ฉ์ ๋ชฉ๋ก */} + <ScrollArea className="h-[500px]"> + <div className="space-y-2"> + {users.map((user) => ( + <button + key={user.id} + onClick={() => setSelectedUser(user)} + className={cn( + "w-full p-3 rounded-lg border text-left transition-colors", + selectedUser?.id === user.id + ? "bg-primary/10 border-primary" + : "hover:bg-muted" + )} + > + <div className="flex items-center gap-3"> + <Avatar> + <AvatarImage src={user.imageUrl} /> + <AvatarFallback>{user.name[0]}</AvatarFallback> + </Avatar> + <div className="flex-1 min-w-0"> + <div className="font-medium truncate">{user.name}</div> + <div className="text-sm text-muted-foreground truncate"> + {user.email} + </div> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="text-xs"> + {user.domain} + </Badge> + {user.companyName && ( + <span className="text-xs text-muted-foreground"> + {user.companyName} + </span> + )} + </div> + </div> + </div> + </button> + ))} + </div> + </ScrollArea> + </div> + </CardContent> + </Card> + + {/* ๊ถํ ์์ธ */} + {selectedUser ? ( + <Card className="col-span-2"> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>{selectedUser.name}์ ๊ถํ</CardTitle> + <CardDescription> + {selectedUser.email} โข {selectedUser.domain} + </CardDescription> + </div> + <AddPermissionDialog + userId={selectedUser.id} + availablePermissions={availablePermissions.filter( + p => !userPermissions.some(up => up.id === p.id) + )} + onSuccess={() => loadUserPermissions(selectedUser.id)} + /> + </div> + </CardHeader> + <CardContent> + <Tabs defaultValue="all" className="w-full"> + <TabsList> + <TabsTrigger value="all"> + ์ ์ฒด ๊ถํ ({userPermissions.length}) + </TabsTrigger> + <TabsTrigger value="role"> + <Users className="mr-2 h-4 w-4" /> + ์ญํ ๊ธฐ๋ฐ ({rolePermissions.length}) + </TabsTrigger> + <TabsTrigger value="direct"> + <Shield className="mr-2 h-4 w-4" /> + ์ง์ ๋ถ์ฌ ({directPermissions.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="all" className="mt-4"> + <PermissionList + permissions={userPermissions} + userId={selectedUser.id} + onRevoke={() => loadUserPermissions(selectedUser.id)} + /> + </TabsContent> + + <TabsContent value="role" className="mt-4"> + <div className="space-y-4"> + {/* ์ญํ ํ์ */} + <div className="p-4 bg-muted/50 rounded-lg"> + <h4 className="text-sm font-medium mb-2">๋ณด์ ์ญํ </h4> + <div className="flex flex-wrap gap-2"> + {selectedUser.roles.map(role => ( + <Badge key={role.id} variant="secondary"> + {role.name} + </Badge> + ))} + </div> + </div> + <PermissionList + permissions={rolePermissions} + userId={selectedUser.id} + readOnly + /> + </div> + </TabsContent> + + <TabsContent value="direct" className="mt-4"> + <PermissionList + permissions={directPermissions} + userId={selectedUser.id} + onRevoke={() => loadUserPermissions(selectedUser.id)} + /> + </TabsContent> + </Tabs> + </CardContent> + </Card> + ) : ( + <Card className="col-span-2 flex items-center justify-center h-[600px]"> + <div className="text-center text-muted-foreground"> + <Shield className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>์ฌ์ฉ์๋ฅผ ์ ํํ๋ฉด ๊ถํ ์ ๋ณด๊ฐ ํ์๋ฉ๋๋ค.</p> + </div> + </Card> + )} + </div> + ); +} + +// ๊ถํ ๋ชฉ๋ก ์ปดํฌ๋ํธ +function PermissionList({ + permissions, + userId, + readOnly = false, + onRevoke +}: { + permissions: UserPermission[]; + userId: number; + readOnly?: boolean; + onRevoke?: () => void; +}) { + const [revoking, setRevoking] = useState<number | null>(null); + + const handleRevoke = async (permissionId: number) => { + if (!confirm("์ด ๊ถํ์ ์ ๊ฑฐํ์๊ฒ ์ต๋๊น?")) return; + + setRevoking(permissionId); + try { + await revokePermissionFromUser(userId, permissionId); + toast.success("๊ถํ์ด ์ ๊ฑฐ๋์์ต๋๋ค."); + onRevoke?.(); + } catch (error) { + toast.error("๊ถํ ์ ๊ฑฐ์ ์คํจํ์ต๋๋ค."); + } finally { + setRevoking(null); + } + }; + + // ๊ถํ์ ๋ฆฌ์์ค๋ณ๋ก ๊ทธ๋ฃนํ + const groupedPermissions = permissions.reduce((acc, perm) => { + const group = perm.resource; + if (!acc[group]) acc[group] = []; + acc[group].push(perm); + return acc; + }, {} as Record<string, UserPermission[]>); + + return ( + <div className="space-y-4"> + {Object.entries(groupedPermissions).map(([resource, perms]) => ( + <div key={resource} className="border rounded-lg"> + <div className="px-4 py-2 bg-muted/30 font-medium text-sm"> + {resource} + </div> + <div className="divide-y"> + {perms.map((permission) => ( + <div key={permission.id} className="p-4"> + <div className="flex items-start justify-between"> + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="font-medium">{permission.name}</span> + {permission.source === "role" && ( + <Badge variant="outline" className="text-xs"> + ์ญํ : {permission.roleName} + </Badge> + )} + {permission.isGrant === false && ( + <Badge variant="destructive" className="text-xs"> + ์ ํ + </Badge> + )} + </div> + <div className="text-sm text-muted-foreground"> + {permission.description} + </div> + <div className="flex items-center gap-4 text-xs text-muted-foreground"> + <span>ํ์
: {permission.permissionType}</span> + <span>๋ฒ์: {permission.scope}</span> + {permission.expiresAt && ( + <span className="text-orange-600"> + <Clock className="inline h-3 w-3 mr-1" /> + {format(new Date(permission.expiresAt), "yyyy-MM-dd")} ๋ง๋ฃ + </span> + )} + </div> + {permission.reason && ( + <div className="text-xs text-muted-foreground mt-2"> + ์ฌ์ : {permission.reason} + </div> + )} + </div> + + {!readOnly && permission.source === "direct" && ( + <Button + variant="ghost" + size="sm" + onClick={() => handleRevoke(permission.id)} + disabled={revoking === permission.id} + > + <XCircle className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + </div> + ))} + </div> + ); +} + +// ๊ถํ ์ถ๊ฐ ๋ค์ด์ผ๋ก๊ทธ +function AddPermissionDialog({ + userId, + availablePermissions, + onSuccess +}: { + userId: number; + availablePermissions: Permission[]; + onSuccess: () => void; +}) { + const [open, setOpen] = useState(false); + const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]); + const [reason, setReason] = useState(""); + const [expiresAt, setExpiresAt] = useState<Date | undefined>(); + const [isGrant, setIsGrant] = useState(true); + const [saving, setSaving] = useState(false); + + const handleSubmit = async () => { + if (selectedPermissions.length === 0) { + toast.error("๊ถํ์ ์ ํํด์ฃผ์ธ์."); + return; + } + + setSaving(true); + try { + await grantPermissionToUser({ + userId, + permissionIds: selectedPermissions, + isGrant, + reason, + expiresAt, + }); + toast.success("๊ถํ์ด ์ถ๊ฐ๋์์ต๋๋ค."); + setOpen(false); + onSuccess(); + // Reset form + setSelectedPermissions([]); + setReason(""); + setExpiresAt(undefined); + setIsGrant(true); + } catch (error) { + toast.error("๊ถํ ์ถ๊ฐ์ ์คํจํ์ต๋๋ค."); + } finally { + setSaving(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button> + <Plus className="mr-2 h-4 w-4" /> + ๊ถํ ์ถ๊ฐ + </Button> + </DialogTrigger> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>๊ถํ ์ถ๊ฐ</DialogTitle> + <DialogDescription> + ์ฌ์ฉ์์๊ฒ ์ง์ ๊ถํ์ ๋ถ์ฌํ๊ฑฐ๋ ์ ํํฉ๋๋ค. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* ๊ถํ ํ์
์ ํ */} + <div className="flex items-center space-x-4"> + <Label>๊ถํ ํ์
</Label> + <div className="flex gap-4"> + <label className="flex items-center gap-2"> + <input + type="radio" + checked={isGrant} + onChange={() => setIsGrant(true)} + /> + <span className="text-sm">๋ถ์ฌ</span> + </label> + <label className="flex items-center gap-2"> + <input + type="radio" + checked={!isGrant} + onChange={() => setIsGrant(false)} + /> + <span className="text-sm text-destructive">์ ํ</span> + </label> + </div> + </div> + + {/* ๊ถํ ์ ํ */} + <div> + <Label>๊ถํ ์ ํ</Label> + <ScrollArea className="h-[200px] border rounded-md p-4 mt-2"> + <div className="space-y-2"> + {availablePermissions.map(permission => ( + <label + key={permission.id} + className="flex items-start gap-2 cursor-pointer" + > + <Checkbox + checked={selectedPermissions.includes(permission.id)} + onCheckedChange={(checked) => { + if (checked) { + setSelectedPermissions([...selectedPermissions, permission.id]); + } else { + setSelectedPermissions( + selectedPermissions.filter(id => id !== permission.id) + ); + } + }} + /> + <div className="flex-1"> + <div className="text-sm font-medium">{permission.name}</div> + <div className="text-xs text-muted-foreground"> + {permission.description} + </div> + </div> + </label> + ))} + </div> + </ScrollArea> + </div> + + {/* ์ฌ์ */} + <div> + <Label>์ฌ์ </Label> + <Textarea + placeholder="๊ถํ ๋ถ์ฌ/์ ํ ์ฌ์ ๋ฅผ ์
๋ ฅํ์ธ์." + value={reason} + onChange={(e) => setReason(e.target.value)} + className="mt-2" + /> + </div> + + {/* ๋ง๋ฃ์ผ */} + <div> + <Label>๋ง๋ฃ์ผ (์ ํ)</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal mt-2", + !expiresAt && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {expiresAt ? format(expiresAt, "PPP", { locale: ko }) : "๋ง๋ฃ์ผ ์ ํ"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={expiresAt} + onSelect={setExpiresAt} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + ์ทจ์ + </Button> + <Button onClick={handleSubmit} disabled={saving}> + {saving ? "์ ์ฅ ์ค..." : "๊ถํ ์ถ๊ฐ"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/config/menuConfig.ts b/config/menuConfig.ts index afc22b1d..3511ce84 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -474,11 +474,11 @@ export const mainNav: MenuSection[] = [ href: "/evcp/email-template", groupKey: "groups.email" }, - { - titleKey: "menu.information_system.email_receiver", - href: "/evcp/email-receiver", - groupKey: "groups.email" - }, + // { + // titleKey: "menu.information_system.email_receiver", + // href: "/evcp/email-receiver", + // groupKey: "groups.email" + // }, { titleKey: "menu.information_system.email_log", href: "/evcp/email-log", diff --git a/config/vendorContactsColumnsConfig.ts b/config/vendorContactsColumnsConfig.ts index 744c3a7d..d3723af4 100644 --- a/config/vendorContactsColumnsConfig.ts +++ b/config/vendorContactsColumnsConfig.ts @@ -29,42 +29,50 @@ export interface VendorColumnConfig { export const vendorContactsColumnsConfig: VendorColumnConfig[] = [ { id: "contactName", - label: "Contact Name", - excelHeader: "Contact Name", + label: "๋ด๋น์๋ช
", + excelHeader: "๋ด๋น์๋ช
", + group: "๊ธฐ๋ณธ ์ ๋ณด", }, { id: "contactPosition", - label: "Contact Position", - excelHeader: "Contact Position", + label: "์ง๊ธ", + excelHeader: "์ง๊ธ", + group: "๊ธฐ๋ณธ ์ ๋ณด", }, { - id: "contactEmail", - label: "Contact Email", - excelHeader: "Contact Email", + id: "contactDepartment", + label: "๋ถ์", + excelHeader: "๋ถ์", + group: "๊ธฐ๋ณธ ์ ๋ณด", }, { - id: "contactPhone", - label: "Contact Phone", - excelHeader: "Contact Phone", - // type: "string[]", // ํ์ํ๋ฉด ์ถ๊ฐ + id: "contactTask", + label: "๋ด๋น์
๋ฌด", + excelHeader: "๋ด๋น์
๋ฌด", + group: "๊ธฐ๋ณธ ์ ๋ณด", + }, + { + id: "contactEmail", + label: "์ด๋ฉ์ผ", + excelHeader: "์ด๋ฉ์ผ", + group: "์ฐ๋ฝ์ฒ", }, - // ํ์ ์ createdAt๋ ์กฐ์ธํด์ ๊ฐ์ ธ์๋ค๋ฉด ์๋์ฒ๋ผ ์ถ๊ฐ { - id: "isPrimary", - label: "isPrimary", - excelHeader: "isPrimary", - // group: "Metadata", + id: "contactPhone", + label: "์ ํ๋ฒํธ", + excelHeader: "์ ํ๋ฒํธ", + group: "์ฐ๋ฝ์ฒ", }, { id: "createdAt", - label: "Created At", - excelHeader: "Created At", - // group: "Metadata", + label: "๋ฑ๋ก์ผ", + excelHeader: "๋ฑ๋ก์ผ", + group: "์์คํ
", }, { id: "updatedAt", - label: "Updated At", - excelHeader: "Updated At", - // group: "Metadata", + label: "์์ ์ผ", + excelHeader: "์์ ์ผ", + group: "์์คํ
", }, ];
\ No newline at end of file diff --git a/db/schema/consent.ts b/db/schema/consent.ts index c67f4b7d..613e92f0 100644 --- a/db/schema/consent.ts +++ b/db/schema/consent.ts @@ -38,7 +38,7 @@ import { users } from "./users"; export const policyVersions = pgTable("policy_versions", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), policyType: policyTypeEnum("policy_type").notNull(), - locale: varchar("locale", { length: 10 }).notNull(), // ko, en + locale: varchar("locale", { length: 10 }).default("ko"), // ko, en version: varchar("version", { length: 20 }).notNull(), content: text("content").notNull(), effectiveDate: timestamp("effective_date", { withTimezone: true }).notNull(), diff --git a/db/schema/index.ts b/db/schema/index.ts index efd38e71..61d477e6 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -44,6 +44,8 @@ export * from './generalContract'; export * from './rfqLastTBE'; export * from './pcr'; +export * from './permissions'; + export * from './fileSystem'; // ๋ถ์๋ณ ๋๋ฉ์ธ ํ ๋น ๊ด๋ฆฌ diff --git a/db/schema/permissions.ts b/db/schema/permissions.ts new file mode 100644 index 00000000..5f641f83 --- /dev/null +++ b/db/schema/permissions.ts @@ -0,0 +1,204 @@ +// db/schema/permissions.ts + +import {unique, pgTable, varchar, text, timestamp, boolean, integer, uniqueIndex, index, primaryKey, pgEnum } from "drizzle-orm/pg-core"; +import { userDomainEnum, users, roles, menuAssignments } from "@/db/schema"; +import { sql } from "drizzle-orm"; + +// ๊ถํ ํ์
enum +export const permissionTypeEnum = pgEnum("permission_type", [ + "menu_access", // ๋ฉ๋ด ์ ๊ทผ + "action", // ์ก์
์คํ (๋ฒํผ ํด๋ฆญ ๋ฑ) + "data_read", // ๋ฐ์ดํฐ ์ฝ๊ธฐ + "data_write", // ๋ฐ์ดํฐ ์ฐ๊ธฐ + "data_delete", // ๋ฐ์ดํฐ ์ญ์ + "approve", // ์น์ธ + "export", // ๋ด๋ณด๋ด๊ธฐ + "import" // ๊ฐ์ ธ์ค๊ธฐ +]); + +// ๊ถํ ๋ฒ์ enum +export const permissionScopeEnum = pgEnum("permission_scope", [ + "all", // ์ ์ฒด ๋ฐ์ดํฐ + "domain", // ๋๋ฉ์ธ ๋ด ์ ์ฒด + "assigned", // ๋ด๋น์ ํ ๋น๋ ๊ฒ๋ง + "own", // ๋ณธ์ธ ๊ฒ๋ง + "department", // ๋ถ์ ๋ด + "company" // ํ์ฌ ๋ด +]); + +// ํ์ฅ๋ ๊ถํ ํ
์ด๋ธ +export const permissions = pgTable("permissions", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + permissionKey: varchar("permission_key", { length: 255 }).notNull().unique(), + permissionType: permissionTypeEnum("permission_type").notNull(), + resource: varchar("resource", { length: 100 }).notNull(), + action: varchar("action", { length: 50 }).notNull(), + scope: permissionScopeEnum("permission_scope").default("own"), + + // ๋ฉ๋ด ์ฐ๊ด + menuPath: varchar("menu_path", { length: 255 }) + .references(() => menuAssignments.menuPath, { onDelete: "set null" }), + + // UI ์์ ๋งคํ + uiElement: varchar("ui_element", { length: 255 }), // ๋ฒํผ ID, ์ปดํฌ๋ํธ ์ด๋ฆ ๋ฑ + + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + isSystem: boolean("is_system").default(false).notNull(), // ์์คํ
๊ถํ ์ฌ๋ถ + isActive: boolean("is_active").default(true).notNull(), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (table) => ({ + permissionKeyIdx: uniqueIndex("permissions_key_idx").on(table.permissionKey), + resourceActionIdx: index("permissions_resource_action_idx").on(table.resource, table.action), + menuPathIdx: index("permissions_menu_path_idx").on(table.menuPath), + typeIdx: index("permissions_type_idx").on(table.permissionType), +})); + +// ๊ถํ ๊ทธ๋ฃน (๊ถํ ๋ฌถ์) +export const permissionGroups = pgTable("permission_groups", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + groupKey: varchar("group_key", { length: 100 }).notNull().unique(), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + domain: userDomainEnum("domain"), + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}, (table) => ({ + groupKeyIdx: uniqueIndex("permission_groups_key_idx").on(table.groupKey), + domainIdx: index("permission_groups_domain_idx").on(table.domain), +})); + +// ๊ถํ ๊ทธ๋ฃน ๋ฉค๋ฒ์ญ +export const permissionGroupMembers = pgTable("permission_group_members", { + groupId: integer("group_id") + .references(() => permissionGroups.id, { onDelete: "cascade" }) + .notNull(), + permissionId: integer("permission_id") + .references(() => permissions.id, { onDelete: "cascade" }) + .notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.groupId, table.permissionId] }), + groupIdx: index("pgm_group_idx").on(table.groupId), + permissionIdx: index("pgm_permission_idx").on(table.permissionId), +})); + +// ์ญํ -๊ถํ ๋งคํ (๊ธฐ์กด rolePermissions ๋์ฒด) +export const rolePermissions = pgTable("role_permissions", { + roleId: integer("role_id") + .references(() => roles.id, { onDelete: "cascade" }) + .notNull(), + permissionId: integer("permission_id") + .references(() => permissions.id, { onDelete: "cascade" }), + permissionGroupId: integer("permission_group_id") + .references(() => permissionGroups.id, { onDelete: "cascade" }), + + // ๊ถํ ๋ถ์ฌ ์ ๋ณด + grantedBy: integer("granted_by") + .references(() => users.id, { onDelete: "set null" }), + grantedAt: timestamp("granted_at").defaultNow().notNull(), + expiresAt: timestamp("expires_at"), // ๊ถํ ๋ง๋ฃ์ผ + + isActive: boolean("is_active").default(true).notNull(), + notes: text("notes"), +}, (table) => ({ + pk: primaryKey({ + columns: [table.roleId, table.permissionId, table.permissionGroupId] + }), + roleIdx: index("role_permissions_role_idx").on(table.roleId), + permissionIdx: index("role_permissions_permission_idx").on(table.permissionId), + groupIdx: index("role_permissions_group_idx").on(table.permissionGroupId), +})); + +// ์ฌ์ฉ์ ์ง์ ๊ถํ (์ญํ ์ธ ์ถ๊ฐ ๊ถํ) +export const userPermissions = pgTable("user_permissions", { + userId: integer("user_id") + .references(() => users.id, { onDelete: "cascade" }) + .notNull(), + + // ์ง์ ๋ถ์ฌ๋ฅผ ๊ทธ๋ฃน ์์ด๋ ๊ฐ๋ฅํ๊ฒ ํ๋ ค๋ฉด permissionId๋ PK์ ํฌํจ๋๋ฏ๋ก notNull ๊ถ์ฅ + permissionId: integer("permission_id") + .references(() => permissions.id, { onDelete: "cascade" }) + .notNull(), + + // ์ด์ nullable (์ง์ ๋ถ์ฌ ์ NULL) + permissionGroupId: integer("permission_group_id") + .references(() => permissionGroups.id, { onDelete: "cascade" }), + + // ๊ถํ ํ์
+ isGrant: boolean("is_grant").default(true).notNull(), // true: ๋ถ์ฌ, false: ์ ํ + + // ๊ถํ ๋ถ์ฌ ์ ๋ณด + grantedBy: integer("granted_by") + .references(() => users.id, { onDelete: "set null" }), + grantedAt: timestamp("granted_at").defaultNow().notNull(), + expiresAt: timestamp("expires_at"), + + isActive: boolean("is_active").default(true).notNull(), + reason: text("reason"), +}, (table) => ({ + // PK์์ group ์ ๊ฑฐ โ (user_id, permission_id) + pk: primaryKey({ columns: [table.userId, table.permissionId] }), + + // ๊ทธ๋ฃน์ด ์๋ ํ์ ๋ํด์๋ง ์ ๋ํฌ ๋ณด์ฅ (NULL์ ์ ๋ํฌ ๊ณ ๋ ค์์ ์ ์ธ๋จ) + userPermGroupUnique: uniqueIndex("user_permissions_user_perm_group_unique") + .on(table.userId, table.permissionId, table.permissionGroupId) + .where(sql`permission_group_id IS NOT NULL`), + + userIdx: index("user_permissions_user_idx").on(table.userId), + permissionIdx: index("user_permissions_permission_idx").on(table.permissionId), + groupIdx: index("user_permissions_group_idx").on(table.permissionGroupId), +})); + +// ๋ฉ๋ด๋ณ ๊ธฐ๋ณธ ํ์ ๊ถํ +export const menuRequiredPermissions = pgTable("menu_required_permissions", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + menuPath: varchar("menu_path", { length: 255 }) + .references(() => menuAssignments.menuPath, { onDelete: "cascade" }) + .notNull(), + permissionId: integer("permission_id") + .references(() => permissions.id, { onDelete: "cascade" }) + .notNull(), + isRequired: boolean("is_required").default(true).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + uq: unique("uq_menu_path_permission_id").on(table.menuPath, table.permissionId), + menuPathIdx: index("mrp_menu_path_idx").on(table.menuPath), + permissionIdx: index("mrp_permission_idx").on(table.permissionId), +})); + + +// ๊ถํ ๊ฐ์ฌ ๋ก๊ทธ +export const permissionAuditLogs = pgTable("permission_audit_logs", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // ๋์ + targetType: varchar("target_type", { length: 50 }).notNull(), // 'user', 'role' + targetId: integer("target_id").notNull(), + + // ๊ถํ + permissionId: integer("permission_id") + .references(() => permissions.id, { onDelete: "set null" }), + permissionGroupId: integer("permission_group_id") + .references(() => permissionGroups.id, { onDelete: "set null" }), + + // ์ก์
+ action: varchar("action", { length: 50 }).notNull(), // 'grant', 'revoke', 'expire' + + // ์คํ์ + performedBy: integer("performed_by") + .references(() => users.id, { onDelete: "set null" }), + performedAt: timestamp("performed_at").defaultNow().notNull(), + + // ์ถ๊ฐ ์ ๋ณด + reason: text("reason"), + metadata: text("metadata"), // JSON ํํ์ ์ถ๊ฐ ์ ๋ณด + ipAddress: varchar("ip_address", { length: 45 }), +}, (table) => ({ + targetIdx: index("pal_target_idx").on(table.targetType, table.targetId), + performedByIdx: index("pal_performed_by_idx").on(table.performedBy), + performedAtIdx: index("pal_performed_at_idx").on(table.performedAt), +}));
\ No newline at end of file diff --git a/db/schema/rfqLast.ts b/db/schema/rfqLast.ts index b58341c5..21d94b39 100644 --- a/db/schema/rfqLast.ts +++ b/db/schema/rfqLast.ts @@ -260,6 +260,11 @@ export const rfqPrItems = pgTable( grossWeight: numeric("gross_weight", { precision: 12, scale: 2 }) .$type<number>() .default(1), + // ๊ตฌ๋งค ์๊ตฌ์ฌํญ: ์์์ 3์๋ฆฌ๋ก ๋ณ๊ฒฝ ์์ฒญ. + // ํด๋น ์คํค๋ง ์ ์ฉ ์ drop prItemsLastView ํ ์ฌ์์ฑ ํ์. + // grossWeight: numeric("gross_weight", { precision: 12, scale: 3 }) + // .$type<number>() + // .default(1), gwUom: varchar("gw_uom", { length: 50 }), // ๋จ์ specNo: varchar("spec_no", { length: 255 }), diff --git a/db/schema/users.ts b/db/schema/users.ts index f01db8db..1d963228 100644 --- a/db/schema/users.ts +++ b/db/schema/users.ts @@ -229,12 +229,12 @@ export const otps = pgTable('otps', { otpExpires: timestamp('otp_expires').notNull(), // null ๋ถ๊ฐ๋ฅ }); -export const permissions = pgTable("permissions", { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - permissionKey: text("permission_key").notNull(), - description: text("description"), - createdAt: timestamp("created_at").default(sql`now()`), -}); +// export const permissions = pgTable("permissions", { +// id: integer("id").primaryKey().generatedAlwaysAsIdentity(), +// permissionKey: text("permission_key").notNull(), +// description: text("description"), +// createdAt: timestamp("created_at").default(sql`now()`), +// }); export const roles = pgTable("roles", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), @@ -246,19 +246,19 @@ export const roles = pgTable("roles", { createdAt: timestamp("created_at").default(sql`now()`), }); -export const rolePermissions = pgTable("role_permissions", { - roleId: integer("role_id") - .references(() => roles.id, { onDelete: "cascade" }) - .notNull(), - permissionId: integer("permission_id") - .references(() => permissions.id, { onDelete: "cascade" }) - .notNull(), -}, (table) => { - return [{ - pk: primaryKey({ columns: [table.roleId, table.permissionId] }), - pkWithCustomName: primaryKey({ name: 'rolePermissions_pk', columns: [table.roleId, table.permissionId] }), - }]; -}); +// export const rolePermissions = pgTable("role_permissions", { +// roleId: integer("role_id") +// .references(() => roles.id, { onDelete: "cascade" }) +// .notNull(), +// permissionId: integer("permission_id") +// .references(() => permissions.id, { onDelete: "cascade" }) +// .notNull(), +// }, (table) => { +// return [{ +// pk: primaryKey({ columns: [table.roleId, table.permissionId] }), +// pkWithCustomName: primaryKey({ name: 'rolePermissions_pk', columns: [table.roleId, table.permissionId] }), +// }]; +// }); export const userRoles = pgTable("user_roles", { userId: integer("user_id") @@ -360,4 +360,4 @@ export const roleView = pgView("role_view").as((qb) => { export type UserView = typeof userView.$inferSelect; export type RoleView = typeof roleView.$inferSelect; -export type RolePermission = typeof rolePermissions.$inferSelect;
\ No newline at end of file +// export type RolePermission = typeof rolePermissions.$inferSelect;
\ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index 635993fb..759f7cac 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -177,8 +177,11 @@ const canCompleteCurrentContract = React.useMemo(() => { const handleOpenChange = (isOpen: boolean) => { if (!isOpen && !allCompleted && completedCount > 0) { // ์๋ฃ๋์ง ์์ ๊ณ์ฝ์๊ฐ ์์ผ๋ฉด ํ์ธ ๋ํ์์ + // const confirmClose = window.confirm( + // `${completedCount}/${totalCount}๊ฐ ๊ณ์ฝ์๊ฐ ์๋ฃ๋์์ต๋๋ค. ์ ๋ง ๋๊ฐ์๊ฒ ์ต๋๊น?` + // ); const confirmClose = window.confirm( - `${completedCount}/${totalCount}๊ฐ ๊ณ์ฝ์๊ฐ ์๋ฃ๋์์ต๋๋ค. ์ ๋ง ๋๊ฐ์๊ฒ ์ต๋๊น?` + `์ ๋ง ๋๊ฐ์๊ฒ ์ต๋๊น?` ); if (!confirmClose) return; } @@ -618,7 +621,7 @@ const canCompleteCurrentContract = React.useMemo(() => { )} {dialogTitle} {/* ์งํ ์ํฉ ํ์ */} - <Badge + {/* <Badge variant="outline" className={cn( "ml-3", @@ -628,7 +631,7 @@ const canCompleteCurrentContract = React.useMemo(() => { )} > {completedCount}/{totalCount} ์๋ฃ - </Badge> + </Badge> */} {/* ์ถ๊ฐ ํ์ผ ๋ก๋ฉ ํ์ */} {isLoadingAttachments && ( <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" /> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx index c1471a69..d0f85b14 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -79,7 +79,7 @@ export function BiddingDetailVendorCreateDialog({ // ๋ฒค๋ ๋ก๋ const loadVendors = React.useCallback(async () => { try { - const result = await searchVendorsForBidding('', biddingId, 50) // ๋น ๊ฒ์์ด๋ก ๋ชจ๋ ๋ฒค๋ ๋ก๋ + const result = await searchVendorsForBidding('', biddingId) // ๋น ๊ฒ์์ด๋ก ๋ชจ๋ ๋ฒค๋ ๋ก๋ setVendorList(result || []) } catch (error) { console.error('Failed to load vendors:', error) diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index cb91a984..e99ac06f 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -137,6 +137,7 @@ export function CreateBiddingDialog() { const [activeTab, setActiveTab] = React.useState<TabType>("basic") const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // ์ถ๊ฐ const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // ์ถ๊ฐ + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) // ๋ซ๊ธฐ ํ์ธ ๋ค์ด์ผ๋ก๊ทธ ์ํ // Procurement ๋ฐ์ดํฐ ์ํ๋ค const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([]) @@ -686,9 +687,23 @@ export function CreateBiddingDialog() { // ๋ค์ด์ผ๋ก๊ทธ ํธ๋ค๋ฌ function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { + // ๋ซ์ผ๋ ค ํ ๋ ํ์ธ ์ฐฝ์ ๋จผ์ ๋์ + setShowCloseConfirmDialog(true) + } else { + // ์ด ๋๋ ๋ฐ๋ก ์ ์ฉ + setOpen(nextOpen) + } + } + + // ๋ซ๊ธฐ ํ์ธ ํธ๋ค๋ฌ + const handleCloseConfirm = (confirmed: boolean) => { + setShowCloseConfirmDialog(false) + if (confirmed) { + // ์ฌ์ฉ์๊ฐ "์"๋ฅผ ์ ํํ ๊ฒฝ์ฐ ์ค์ ๋ก ๋ซ๊ธฐ resetAllStates() + setOpen(false) } - setOpen(nextOpen) + // "์๋์ค"๋ฅผ ์ ํํ ๊ฒฝ์ฐ๋ ์๋ฌด๊ฒ๋ ํ์ง ์์ (๋ค์ด์ผ๋ก๊ทธ ์ ์ง) } // ์
์ฐฐ ์์ฑ ๋ฒํผ ํด๋ฆญ ํธ๋ค๋ฌ ์ถ๊ฐ @@ -2172,10 +2187,7 @@ export function CreateBiddingDialog() { <Button type="button" variant="outline" - onClick={() => { - resetAllStates() - setOpen(false) - }} + onClick={() => setShowCloseConfirmDialog(true)} disabled={isSubmitting} > ์ทจ์ @@ -2227,6 +2239,27 @@ export function CreateBiddingDialog() { </DialogContent> </Dialog> + {/* ๋ซ๊ธฐ ํ์ธ ๋ค์ด์ผ๋ก๊ทธ */} + <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>์
์ฐฐ ์์ฑ์ ์ทจ์ํ์๊ฒ ์ต๋๊น?</AlertDialogTitle> + <AlertDialogDescription> + ํ์ฌ ์
๋ ฅ ์ค์ธ ๋ด์ฉ์ด ๋ชจ๋ ์ญ์ ๋๋ฉฐ, ์์ฑ๋์ง ์์ต๋๋ค. + ์ ๋ง๋ก ์ทจ์ํ์๊ฒ ์ต๋๊น? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => handleCloseConfirm(false)}> + ์๋์ค (๊ณ์ ์
๋ ฅ) + </AlertDialogCancel> + <AlertDialogAction onClick={() => handleCloseConfirm(true)}> + ์ (์ทจ์) + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}> <AlertDialogContent> <AlertDialogHeader> diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx index 9ca7deb6..bd078192 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx @@ -68,7 +68,7 @@ export function BiddingPreQuoteVendorCreateDialog({ // ๋ฒค๋ ๋ก๋ const loadVendors = React.useCallback(async () => { try { - const result = await searchVendorsForBidding('', biddingId, 50) // ๋น ๊ฒ์์ด๋ก ๋ชจ๋ ๋ฒค๋ ๋ก๋ + const result = await searchVendorsForBidding('', biddingId) // ๋น ๊ฒ์์ด๋ก ๋ชจ๋ ๋ฒค๋ ๋ก๋ setVendorList(result || []) } catch (error) { console.error('Failed to load vendors:', error) diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 68efe165..8cbe2a2b 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -1381,7 +1381,7 @@ export async function getActiveContractTemplates() { } // ์
์ฐฐ์ ์ฐธ์ฌํ์ง ์์ ๋ฒค๋๋ง ๊ฒ์ (์ค๋ณต ๋ฐฉ์ง) -export async function searchVendorsForBidding(searchTerm: string = "", biddingId: number, limit: number = 100) { +export async function searchVendorsForBidding(searchTerm: string = "", biddingId: number) { try { let whereCondition; @@ -1419,8 +1419,8 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId // eq(vendorsWithTypesView.status, "ACTIVE"), ) ) - .orderBy(asc(vendorsWithTypesView.vendorName)) - .limit(limit); + .orderBy(asc(vendorsWithTypesView.vendorName)); + return result; } catch (error) { diff --git a/lib/docu-list-rule/number-type-configs/service.ts b/lib/docu-list-rule/number-type-configs/service.ts index c29af464..b644c43a 100644 --- a/lib/docu-list-rule/number-type-configs/service.ts +++ b/lib/docu-list-rule/number-type-configs/service.ts @@ -166,12 +166,12 @@ export async function getNumberTypeConfigs(input: GetNumberTypeConfigsSchema) { } } -// Number Type Config ์์ฑ export async function createNumberTypeConfig(input: { documentNumberTypeId: number codeGroupId: number | null sdq: number description?: string + delimiter?: string remark?: string }) { try { @@ -198,6 +198,7 @@ export async function createNumberTypeConfig(input: { codeGroupId: input.codeGroupId, sdq: input.sdq, description: input.description, + delimiter: input.delimiter, remark: input.remark, }) .returning({ id: documentNumberTypeConfigs.id }) @@ -218,12 +219,12 @@ export async function createNumberTypeConfig(input: { } } -// Number Type Config ์์ export async function updateNumberTypeConfig(input: { id: number codeGroupId: number | null sdq: number description?: string + delimiter?: string remark?: string }) { try { @@ -263,6 +264,7 @@ export async function updateNumberTypeConfig(input: { codeGroupId: input.codeGroupId, sdq: input.sdq, description: input.description, + delimiter: input.delimiter, remark: input.remark, updatedAt: new Date(), }) @@ -284,7 +286,6 @@ export async function updateNumberTypeConfig(input: { } } } - // Number Type Config ์์ ๋ณ๊ฒฝ (๊ฐ๋จํ ๋ฐฉ์) export async function updateNumberTypeConfigOrder(input: { id: number diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx index cd2d6fc8..ad3478ff 100644 --- a/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx @@ -2,6 +2,9 @@ import * as React from "react" import { Loader2 } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -14,6 +17,14 @@ import { DialogTitle, } from "@/components/ui/dialog" import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Select, SelectContent, SelectItem, @@ -21,18 +32,30 @@ import { SelectValue, } from "@/components/ui/select" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { updateNumberTypeConfig, getActiveCodeGroups } from "@/lib/docu-list-rule/number-type-configs/service" import { NumberTypeConfig } from "@/lib/docu-list-rule/types" +const formSchema = z.object({ + codeGroupId: z.string().min(1, "Code Group์ ์ ํํด์ฃผ์ธ์."), + sdq: z.string().min(1, "์์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.").refine( + (val) => !isNaN(Number(val)) && Number(val) > 0, + { message: "์์๋ 1 ์ด์์ ์ซ์์ฌ์ผ ํฉ๋๋ค." } + ), + description: z.string().optional(), + delimiter: z.string().max(10).optional(), + remark: z.string().optional(), +}) + +type FormData = z.infer<typeof formSchema> + interface NumberTypeConfigsEditDialogProps { open: boolean onOpenChange: (open: boolean) => void data: NumberTypeConfig | null onSuccess?: () => void - existingConfigs?: NumberTypeConfig[] // ๊ธฐ์กด configs ๋ชฉ๋ก ์ถ๊ฐ + existingConfigs?: NumberTypeConfig[] selectedProjectId?: number | null } @@ -41,29 +64,35 @@ export function NumberTypeConfigsEditDialog({ onOpenChange, data, onSuccess, - existingConfigs = [], // ๊ธฐ๋ณธ๊ฐ ์ถ๊ฐ + existingConfigs = [], selectedProjectId, }: NumberTypeConfigsEditDialogProps) { const [isLoading, setIsLoading] = React.useState(false) const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([]) - const [formData, setFormData] = React.useState({ - codeGroupId: "", - sdq: "", - description: "", - remark: "" + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + codeGroupId: "", + sdq: "", + description: "", + delimiter: "", + remark: "", + }, }) // ๋ฐ์ดํฐ๊ฐ ๋ณ๊ฒฝ๋ ๋ ํผ ์ด๊ธฐํ React.useEffect(() => { if (data) { - setFormData({ - codeGroupId: data.codeGroupId?.toString() || "", // null ์ฒดํฌ ์ถ๊ฐ + form.reset({ + codeGroupId: data.codeGroupId?.toString() || "", sdq: data.sdq.toString(), description: data.description || "", + delimiter: data.delimiter || "", remark: data.remark || "" }) } - }, [data]) + }, [data, form]) // Code Groups ๋ก๋ React.useEffect(() => { @@ -79,21 +108,23 @@ export function NumberTypeConfigsEditDialog({ })() }, [selectedProjectId]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!data || !formData.codeGroupId || !formData.sdq) { - toast.error("ํ์ ํ๋๋ฅผ ๋ชจ๋ ์
๋ ฅํด์ฃผ์ธ์.") + const onSubmit = async (values: FormData) => { + if (!data) { + toast.error("๋ฐ์ดํฐ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.") return } - const newSdq = parseInt(formData.sdq) + const newSdq = parseInt(values.sdq) // ์์ ์ค๋ณต ๊ฒ์ฆ (ํ์ฌ ์์ ์ค์ธ ํญ๋ชฉ ์ ์ธ) const existingSdq = existingConfigs.find(config => config.sdq === newSdq && config.id !== data.id ) if (existingSdq) { - toast.error(`์์ ${newSdq}๋ฒ์ ์ด๋ฏธ ์ฌ์ฉ ์ค์
๋๋ค. ๋ค๋ฅธ ์์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.`) + form.setError("sdq", { + type: "manual", + message: `์์ ${newSdq}๋ฒ์ ์ด๋ฏธ ์ฌ์ฉ ์ค์
๋๋ค. ๋ค๋ฅธ ์์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.` + }) return } @@ -101,11 +132,11 @@ export function NumberTypeConfigsEditDialog({ try { const result = await updateNumberTypeConfig({ id: data.id, - codeGroupId: parseInt(formData.codeGroupId), - + codeGroupId: parseInt(values.codeGroupId), sdq: newSdq, - description: formData.description || undefined, - remark: formData.remark || undefined, + description: values.description || undefined, + delimiter: values.delimiter || undefined, + remark: values.remark || undefined, }) if (result.success) { @@ -135,91 +166,127 @@ export function NumberTypeConfigsEditDialog({ <span className="text-red-500 mt-1 block text-sm">* ํ์๋ ํญ๋ชฉ์ ํ์ ์
๋ ฅ์ฌํญ์
๋๋ค.</span> </DialogDescription> </DialogHeader> - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="grid gap-4 py-2"> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="codeGroup" className="text-right"> - Code Group <span className="text-red-500">*</span> - </Label> - <div className="col-span-3"> - <Select - value={formData.codeGroupId} - onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))} - > - <SelectTrigger> - <SelectValue placeholder="Code Group ์ ํ" /> - </SelectTrigger> - <SelectContent> - {codeGroups.map((codeGroup) => ( - <SelectItem key={codeGroup.id} value={codeGroup.id.toString()}> - {codeGroup.description} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="sdq" className="text-right"> - ์์ <span className="text-red-500">*</span> - </Label> - <div className="col-span-3"> - <Input - id="sdq" - type="number" - value={formData.sdq} - onChange={(e) => setFormData(prev => ({ ...prev, sdq: e.target.value }))} - min="1" - /> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="description" className="text-right"> - Description - </Label> - <div className="col-span-3"> - <Input - id="description" - value={formData.description} - onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} - placeholder="์: PROJECT NO" - /> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="remark" className="text-right"> - Remark - </Label> - <div className="col-span-3"> - <Textarea - id="remark" - value={formData.remark} - onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))} - placeholder="๋น๊ณ ์ฌํญ" - rows={3} - /> - </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4 py-2"> + <FormField + control={form.control} + name="codeGroupId" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right"> + Code Group <span className="text-red-500">*</span> + </FormLabel> + <div className="col-span-3"> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger> + <SelectValue placeholder="Code Group ์ ํ" /> + </SelectTrigger> + <SelectContent> + {codeGroups.map((codeGroup) => ( + <SelectItem key={codeGroup.id} value={codeGroup.id.toString()}> + {codeGroup.description} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="sdq" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right"> + ์์ <span className="text-red-500">*</span> + </FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} type="number" min="1" /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Description</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="์: PROJECT NO" /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="delimiter" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Delimiter</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="์: -, _, /" maxLength={10} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Remark</FormLabel> + <div className="col-span-3"> + <FormControl> + <Textarea {...field} placeholder="๋น๊ณ ์ฌํญ" rows={3} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> </div> - </div> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isLoading} - > - ์ทจ์ - </Button> - <Button - type="submit" - disabled={isLoading} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "์์ ์ค..." : "์์ "} - </Button> - </DialogFooter> - </form> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + ์ทจ์ + </Button> + <Button + type="submit" + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "์์ ์ค..." : "์์ "} + </Button> + </DialogFooter> + </form> + </Form> </DialogContent> </Dialog> ) -}
\ No newline at end of file +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx index 572d05cd..243dff73 100644 --- a/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx @@ -2,6 +2,9 @@ import * as React from "react" import { Plus, Loader2 } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" import { Button } from "@/components/ui/button" import { Dialog, @@ -13,6 +16,14 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Select, SelectContent, SelectItem, @@ -20,7 +31,6 @@ import { SelectValue, } from "@/components/ui/select" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { toast } from "sonner" @@ -28,6 +38,15 @@ import { createNumberTypeConfig, getActiveCodeGroups } from "@/lib/docu-list-rul import { DeleteNumberTypeConfigsDialog } from "@/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog" import { NumberTypeConfig } from "@/lib/docu-list-rule/types" +const formSchema = z.object({ + codeGroupId: z.string().min(1, "Code Group์ ์ ํํด์ฃผ์ธ์."), + description: z.string().optional(), + delimiter: z.string().max(10).optional(), + remark: z.string().optional(), +}) + +type FormData = z.infer<typeof formSchema> + interface NumberTypeConfigsToolbarActionsProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any table: any @@ -46,15 +65,23 @@ export function NumberTypeConfigsToolbarActions({ }: NumberTypeConfigsToolbarActionsProps) { const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false) - const [formData, setFormData] = React.useState({ codeGroupId: "", description: "", remark: "" }) const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([]) const [allOptions, setAllOptions] = React.useState<{ id: string; name: string }[]>([]) + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + codeGroupId: "", + description: "", + delimiter: "", + remark: "", + }, + }) + const loadCodeGroups = React.useCallback(async () => { try { const result = await getActiveCodeGroups(selectedProjectId || undefined) if (result.success && result.data) { - // ์ด๋ฏธ ์ถ๊ฐ๋ Code Group๋ค์ ์ ์ธํ๊ณ ํํฐ๋ง const usedCodeGroupIds = configsData.map(config => config.codeGroupId) const availableCodeGroups = result.data.filter(codeGroup => @@ -86,17 +113,23 @@ export function NumberTypeConfigsToolbarActions({ combineOptions() }, [combineOptions]) - // ๋ค์ด์ผ๋ก๊ทธ๊ฐ ์ด๋ฆด ๋๋ง๋ค Code Groups ๋ค์ ๋ก๋ + // ๋ค์ด์ผ๋ก๊ทธ๊ฐ ์ด๋ฆด ๋๋ง๋ค Code Groups ๋ค์ ๋ก๋ ๋ฐ ํผ ๋ฆฌ์
React.useEffect(() => { if (isAddDialogOpen) { loadCodeGroups() + form.reset() } - }, [isAddDialogOpen, loadCodeGroups, configsData]) + }, [isAddDialogOpen, loadCodeGroups, configsData, form]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!selectedNumberType || !formData.codeGroupId) { - toast.error("ํ์ ํ๋๋ฅผ ๋ชจ๋ ์
๋ ฅํด์ฃผ์ธ์.") + const getNextSdq = () => { + if (configsData.length === 0) return 1 + const maxSdq = Math.max(...configsData.map(config => config.sdq)) + return maxSdq + 1 + } + + const onSubmit = async (values: FormData) => { + if (!selectedNumberType) { + toast.error("Number Type์ ์ ํํด์ฃผ์ธ์.") return } @@ -105,21 +138,21 @@ export function NumberTypeConfigsToolbarActions({ try { // Code Group ID ์ถ์ถ - const codeGroupId = parseInt(formData.codeGroupId.replace('cg_', '')) + const codeGroupId = parseInt(values.codeGroupId.replace('cg_', '')) const result = await createNumberTypeConfig({ documentNumberTypeId: selectedNumberType, codeGroupId: codeGroupId, - sdq: sdq, - description: formData.description || undefined, - remark: formData.remark || undefined, + description: values.description || undefined, + delimiter: values.delimiter || undefined, + remark: values.remark || undefined, }) if (result.success) { toast.success("Number Type Config๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ถ๊ฐ๋์์ต๋๋ค.") setIsAddDialogOpen(false) - setFormData({ codeGroupId: "", description: "", remark: "" }) + form.reset() onSuccess?.() } else { toast.error(result.error || "์ถ๊ฐ์ ์คํจํ์ต๋๋ค.") @@ -132,12 +165,6 @@ export function NumberTypeConfigsToolbarActions({ } } - const getNextSdq = () => { - if (configsData.length === 0) return 1 - const maxSdq = Math.max(...configsData.map(config => config.sdq)) - return maxSdq + 1 - } - return ( <div className="flex items-center gap-2"> {/** 1) ์ ํ๋ ๋ก์ฐ๊ฐ ์์ผ๋ฉด ์ญ์ ๋ค์ด์ผ๋ก๊ทธ */} @@ -170,84 +197,116 @@ export function NumberTypeConfigsToolbarActions({ <span className="text-red-500 mt-1 block text-sm">* ํ์๋ ํญ๋ชฉ์ ํ์ ์
๋ ฅ์ฌํญ์
๋๋ค.</span> </DialogDescription> </DialogHeader> - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="grid gap-4 py-2"> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="codeGroup" className="text-right"> - Code Group <span className="text-red-500">*</span> - </Label> - <div className="col-span-3"> - <Select - value={formData.codeGroupId} - onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))} - > - <SelectTrigger> - <SelectValue placeholder="Code Group ์ ํ" /> - </SelectTrigger> - <SelectContent> - {allOptions.length > 0 ? ( - allOptions.map((option) => ( - <SelectItem key={option.id} value={option.id}> - {option.name} - </SelectItem> - )) - ) : ( - <div className="px-2 py-1.5 text-sm text-muted-foreground"> - ์ฌ์ฉ ๊ฐ๋ฅํ ์ต์
์ด ์์ต๋๋ค. - </div> - )} - </SelectContent> - </Select> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="description" className="text-right"> - Description - </Label> - <div className="col-span-3"> - <Input - id="description" - value={formData.description} - onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} - placeholder="์: PROJECT NO" - /> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="remark" className="text-right"> - Remark - </Label> - <div className="col-span-3"> - <Textarea - id="remark" - value={formData.remark} - onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))} - placeholder="๋น๊ณ ์ฌํญ" - rows={3} - /> - </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4 py-2"> + <FormField + control={form.control} + name="codeGroupId" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right"> + Code Group <span className="text-red-500">*</span> + </FormLabel> + <div className="col-span-3"> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger> + <SelectValue placeholder="Code Group ์ ํ" /> + </SelectTrigger> + <SelectContent> + {allOptions.length > 0 ? ( + allOptions.map((option) => ( + <SelectItem key={option.id} value={option.id}> + {option.name} + </SelectItem> + )) + ) : ( + <div className="px-2 py-1.5 text-sm text-muted-foreground"> + ์ฌ์ฉ ๊ฐ๋ฅํ ์ต์
์ด ์์ต๋๋ค. + </div> + )} + </SelectContent> + </Select> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Description</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="์: PROJECT NO" /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="delimiter" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Delimiter</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="์: -, _, /" maxLength={10} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Remark</FormLabel> + <div className="col-span-3"> + <FormControl> + <Textarea {...field} placeholder="๋น๊ณ ์ฌํญ" rows={3} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> </div> - </div> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setIsAddDialogOpen(false)} - disabled={isLoading} - > - ์ทจ์ - </Button> - <Button - type="submit" - disabled={isLoading} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "์ถ๊ฐ ์ค..." : "์ถ๊ฐ"} - </Button> - </DialogFooter> - </form> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setIsAddDialogOpen(false)} + disabled={isLoading} + > + ์ทจ์ + </Button> + <Button + type="submit" + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "์ถ๊ฐ ์ค..." : "์ถ๊ฐ"} + </Button> + </DialogFooter> + </form> + </Form> </DialogContent> </Dialog> </div> ) -}
\ No newline at end of file +}
\ No newline at end of file diff --git a/lib/docu-list-rule/types.ts b/lib/docu-list-rule/types.ts index ef3f90d3..2baa72a5 100644 --- a/lib/docu-list-rule/types.ts +++ b/lib/docu-list-rule/types.ts @@ -6,6 +6,7 @@ export interface NumberTypeConfig { codeGroupId: number | null sdq: number description: string | null + delimiter:string | null remark: string | null isActive: boolean | null createdAt: Date diff --git a/lib/email-template/editor/template-content-editor.tsx b/lib/email-template/editor/template-content-editor.tsx index 08de53d2..e6091d0f 100644 --- a/lib/email-template/editor/template-content-editor.tsx +++ b/lib/email-template/editor/template-content-editor.tsx @@ -48,12 +48,6 @@ export function TemplateContentEditor({ template, onUpdate }: TemplateContentEdi getEditor: () => any }>(null) - React.useEffect(() => { - if (!session?.user?.id) { - toast.error("๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค"); - } - }, [session]); - // ์๋ ๋ฏธ๋ฆฌ๋ณด๊ธฐ (๋๋ฐ์ด์ค) - ์๊ฐ ๋๋ฆผ React.useEffect(() => { if (!autoPreview) return diff --git a/lib/email-template/table/create-template-sheet.tsx b/lib/email-template/table/create-template-sheet.tsx index 199e20ab..1997cae8 100644 --- a/lib/email-template/table/create-template-sheet.tsx +++ b/lib/email-template/table/create-template-sheet.tsx @@ -65,12 +65,6 @@ export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) { const router = useRouter() const { data: session } = useSession(); - // ๋๋ ๋ ์์ ํ๊ฒ - if (!session?.user?.id) { - toast.error("๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค") - return - } - const form = useForm<CreateTemplateSchema>({ resolver: zodResolver(createTemplateSchema), defaultValues: { @@ -82,8 +76,8 @@ export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) { }) // ์ด๋ฆ ์
๋ ฅ ์ ์๋์ผ๋ก slug ์์ฑ - const watchedName = form.watch("name") React.useEffect(() => { + const watchedName = form.watch("name") if (watchedName && !form.formState.dirtyFields.slug) { const autoSlug = watchedName .toLowerCase() @@ -95,7 +89,7 @@ export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) { form.setValue("slug", autoSlug, { shouldValidate: false }) } - }, [watchedName, form]) + }, [form]) // ๊ธฐ๋ณธ ํ
ํ๋ฆฟ ๋ด์ฉ ์์ฑ const getDefaultContent = (category: string, name: string) => { diff --git a/lib/email-template/table/update-template-sheet.tsx b/lib/email-template/table/update-template-sheet.tsx index d3df93f0..6a8c9a4a 100644 --- a/lib/email-template/table/update-template-sheet.tsx +++ b/lib/email-template/table/update-template-sheet.tsx @@ -58,12 +58,6 @@ export function UpdateTemplateSheet({ template, ...props }: UpdateTemplateSheetP const [isUpdatePending, startUpdateTransition] = React.useTransition() const { data: session } = useSession(); - // ๋๋ ๋ ์์ ํ๊ฒ - if (!session?.user?.id) { - toast.error("๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค") - return - } - const form = useForm<UpdateTemplateSchema>({ resolver: zodResolver(updateTemplateSchema), defaultValues: { diff --git a/lib/permissions/permission-assignment-actions.ts b/lib/permissions/permission-assignment-actions.ts new file mode 100644 index 00000000..75181c40 --- /dev/null +++ b/lib/permissions/permission-assignment-actions.ts @@ -0,0 +1,83 @@ +// app/actions/permission-assignment-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and ,sql} from "drizzle-orm"; +import { + permissions, + roles, + rolePermissions, + users, + userPermissions, + userRoles +} from "@/db/schema"; + +// ๊ถํ๋ณ ํ ๋น ์ ๋ณด ์กฐํ +export async function getPermissionAssignments(permissionId?: number) { + if (!permissionId) { + // ๋ชจ๋ ๊ถํ ๋ชฉ๋ก + const allPermissions = await db.select().from(permissions) + .where(eq(permissions.isActive, true)) + .orderBy(permissions.resource, permissions.name); + + return { permissions: allPermissions, roles: [], users: [] }; + } + + // ํน์ ๊ถํ์ ํ ๋น ์ ๋ณด + const assignedRoles = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + userCount: sql<number>`count(distinct ${userRoles.userId})`.mapWith(Number), + }) + .from(rolePermissions) + .innerJoin(roles, eq(roles.id, rolePermissions.roleId)) + .leftJoin(userRoles, eq(userRoles.roleId, roles.id)) + .where(eq(rolePermissions.permissionId, permissionId)) + .groupBy(roles.id); + + const assignedUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + isGrant: userPermissions.isGrant, + reason: userPermissions.reason, + }) + .from(userPermissions) + .innerJoin(users, eq(users.id, userPermissions.userId)) + .where(eq(userPermissions.permissionId, permissionId)); + + return { + permissions: [], + roles: assignedRoles, + users: assignedUsers, + }; +} + +// ์ญํ ์์ ๊ถํ ์ ๊ฑฐ +export async function removePermissionFromRole(permissionId: number, roleId: number) { + await db.delete(rolePermissions) + .where( + and( + eq(rolePermissions.permissionId, permissionId), + eq(rolePermissions.roleId, roleId) + ) + ); +} + +// ์ฌ์ฉ์์์ ๊ถํ ์ ๊ฑฐ +export async function removePermissionFromUser(permissionId: number, userId: number) { + await db.update(userPermissions) + .set({ isActive: false }) + .where( + and( + eq(userPermissions.permissionId, permissionId), + eq(userPermissions.userId, userId) + ) + ); +}
\ No newline at end of file diff --git a/lib/permissions/permission-group-actions.ts b/lib/permissions/permission-group-actions.ts new file mode 100644 index 00000000..51e3c2c0 --- /dev/null +++ b/lib/permissions/permission-group-actions.ts @@ -0,0 +1,270 @@ +// app/actions/permission-group-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; +import { + permissionGroups, + permissionGroupMembers, + permissions, + rolePermissions, + userPermissions, + roles, + users +} from "@/db/schema"; +import { checkUserPermission } from "./service"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// ๊ถํ ๊ทธ๋ฃน ๋ชฉ๋ก ์กฐํ +export async function getPermissionGroups() { + const groups = await db + .select({ + id: permissionGroups.id, + groupKey: permissionGroups.groupKey, + name: permissionGroups.name, + description: permissionGroups.description, + domain: permissionGroups.domain, + isActive: permissionGroups.isActive, + createdAt: permissionGroups.createdAt, + updatedAt: permissionGroups.updatedAt, + permissionCount: sql<number>`count(distinct ${permissionGroupMembers.permissionId})`.mapWith(Number), + }) + .from(permissionGroups) + .leftJoin(permissionGroupMembers, eq(permissionGroupMembers.groupId, permissionGroups.id)) + .groupBy(permissionGroups.id) + .orderBy(permissionGroups.name); + + // ๊ฐ ๊ทธ๋ฃน์ ์ญํ ๋ฐ ์ฌ์ฉ์ ์ ๊ณ์ฐ + const groupsWithCounts = await Promise.all( + groups.map(async (group) => { + const roleCount = await db + .selectDistinct({ roleId: rolePermissions.roleId }) + .from(rolePermissions) + .where(eq(rolePermissions.permissionGroupId, group.id)); + + const userCount = await db + .selectDistinct({ userId: userPermissions.userId }) + .from(userPermissions) + .where(eq(userPermissions.permissionGroupId, group.id)); + + return { + ...group, + roleCount: roleCount.length, + userCount: userCount.length, + }; + }) + ); + + return groupsWithCounts; +} + +// ๊ถํ ๊ทธ๋ฃน ์์ฑ +export async function createPermissionGroup(data: { + groupKey: string; + name: string; + description?: string; + domain?: string; + isActive: boolean; +}) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + // ์ค๋ณต ์ฒดํฌ + const existing = await db.select() + .from(permissionGroups) + .where(eq(permissionGroups.groupKey, data.groupKey)) + .limit(1); + + if (existing.length > 0) { + throw new Error("์ด๋ฏธ ์กด์ฌํ๋ ๊ทธ๋ฃน ํค์
๋๋ค."); + } + + const [created] = await db.insert(permissionGroups).values(data).returning(); + return created; +} + +// ๊ถํ ๊ทธ๋ฃน ์์ +export async function updatePermissionGroup(id: number, data: any) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + const [updated] = await db.update(permissionGroups) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(permissionGroups.id, id)) + .returning(); + + return updated; +} + +// ๊ถํ ๊ทธ๋ฃน ์ญ์ +export async function deletePermissionGroup(id: number) { + const currentUser = await getCurrentUser(); + if (!currentUser) throw new Error("Unauthorized"); + + if (!await checkUserPermission(currentUser.id, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + await db.transaction(async (tx) => { + // ๊ทธ๋ฃน ๋ฉค๋ฒ ์ญ์ + await tx.delete(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, id)); + + // ๊ทธ๋ฃน ์ญ์ + await tx.delete(permissionGroups) + .where(eq(permissionGroups.id, id)); + }); +} + +// ๊ทธ๋ฃน์ ๊ถํ ์กฐํ +export async function getGroupPermissions(groupId: number) { + const groupPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + resource: permissions.resource, + action: permissions.action, + permissionType: permissions.permissionType, + scope: permissions.scope, + }) + .from(permissionGroupMembers) + .innerJoin(permissions, eq(permissions.id, permissionGroupMembers.permissionId)) + .where(eq(permissionGroupMembers.groupId, groupId)); + + const allPermissions = await db.select().from(permissions) + .where(eq(permissions.isActive, true)); + + return { + permissions: groupPermissions, + availablePermissions: allPermissions, + }; +} + +// ๊ทธ๋ฃน ๊ถํ ์
๋ฐ์ดํธ +export async function updateGroupPermissions(groupId: number, permissionIds: number[]) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + await db.transaction(async (tx) => { + // ๊ธฐ์กด ๊ถํ ์ญ์ + await tx.delete(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + // ์ ๊ถํ ์ถ๊ฐ + if (permissionIds.length > 0) { + await tx.insert(permissionGroupMembers).values( + permissionIds.map(permissionId => ({ + groupId, + permissionId, + })) + ); + } + }); +} + +// ๊ถํ ๊ทธ๋ฃน ๋ณต์ +export async function clonePermissionGroup(groupId: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + // ์๋ณธ ๊ทธ๋ฃน ์กฐํ + const [originalGroup] = await db.select() + .from(permissionGroups) + .where(eq(permissionGroups.id, groupId)); + + if (!originalGroup) { + throw new Error("๊ทธ๋ฃน์ ์ฐพ์ ์ ์์ต๋๋ค."); + } + + // ์๋ณธ ๊ทธ๋ฃน์ ๊ถํ ์กฐํ + const originalPermissions = await db.select() + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + // ์ ๊ทธ๋ฃน ์์ฑ + const timestamp = Date.now(); + const [newGroup] = await db.insert(permissionGroups).values({ + groupKey: `${originalGroup.groupKey}_copy_${timestamp}`, + name: `${originalGroup.name} (๋ณต์ฌ๋ณธ)`, + description: originalGroup.description, + domain: originalGroup.domain, + isActive: originalGroup.isActive, + }).returning(); + + // ๊ถํ ๋ณต์ฌ + if (originalPermissions.length > 0) { + await db.insert(permissionGroupMembers).values( + originalPermissions.map(p => ({ + groupId: newGroup.id, + permissionId: p.permissionId, + })) + ); + } + + return newGroup; +} + +// ๊ทธ๋ฃน ํ ๋น ์ ๋ณด ์กฐํ +export async function getGroupAssignments(groupId: number) { + const assignedRoles = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + }) + .from(rolePermissions) + .innerJoin(roles, eq(roles.id, rolePermissions.roleId)) + .where(eq(rolePermissions.permissionGroupId, groupId)); + + const assignedUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + }) + .from(userPermissions) + .innerJoin(users, eq(users.id, userPermissions.userId)) + .where(eq(userPermissions.permissionGroupId, groupId)); + + return { + roles: assignedRoles, + users: assignedUsers, + }; +}
\ No newline at end of file diff --git a/lib/permissions/permission-settings-actions.ts b/lib/permissions/permission-settings-actions.ts new file mode 100644 index 00000000..5d04a1d3 --- /dev/null +++ b/lib/permissions/permission-settings-actions.ts @@ -0,0 +1,229 @@ +// app/actions/permission-settings-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; +import { + permissions, + menuAssignments, + menuRequiredPermissions +} from "@/db/schema"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { checkUserPermission } from "./service"; + +// ๋ชจ๋ ๊ถํ ์กฐํ +export async function getAllPermissions() { + return await db.select().from(permissions).orderBy(permissions.resource, permissions.action); +} + +// ๊ถํ ์นดํ
๊ณ ๋ฆฌ ์กฐํ +export async function getPermissionCategories() { + const result = await db + .select({ + resource: permissions.resource, + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(permissions) + .groupBy(permissions.resource) + .orderBy(permissions.resource); + + return result; +} + +// ๊ถํ ์์ฑ +export async function createPermission(data: { + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; + uiElement?: string; + isActive: boolean; +}) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + // ์ค๋ณต ์ฒดํฌ + const existing = await db.select() + .from(permissions) + .where(eq(permissions.permissionKey, data.permissionKey)) + .limit(1); + + if (existing.length > 0) { + throw new Error("์ด๋ฏธ ์กด์ฌํ๋ ๊ถํ ํค์
๋๋ค."); + } + + const [created] = await db.insert(permissions).values({ + ...data, + isSystem: false, + }).returning(); + + return created; +} + +// ๊ถํ ์์ +export async function updatePermission(id: number, data: any) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + const [updated] = await db.update(permissions) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(permissions.id, id)) + .returning(); + + return updated; +} + +// ๊ถํ ์ญ์ +export async function deletePermission(id: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + await db.delete(permissions).where(eq(permissions.id, id)); +} + +// ๋ฉ๋ด ๊ถํ ๋ถ์ +export async function analyzeMenuPermissions() { + const menus = await db.select().from(menuAssignments); + + const analysis = await Promise.all( + menus.map(async (menu) => { + // ๊ธฐ์กด ๊ถํ ์กฐํ + const existing = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + }) + .from(menuRequiredPermissions) + .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) + .where(eq(menuRequiredPermissions.menuPath, menu.menuPath)); + + // ์ ์ํ ๊ถํ ์์ฑ + const suggestedPermissions = []; + const resourceName = menu.menuPath.split('/').pop() || 'unknown'; + + // ๊ธฐ๋ณธ ๋ฉ๋ด ์ ๊ทผ ๊ถํ + suggestedPermissions.push({ + permissionKey: `${resourceName}.menu_access`, + name: `${menu.menuTitle} ์ ๊ทผ`, + permissionType: "menu_access", + action: "access", + scope: "assigned", + }); + + // CRUD ๊ถํ ์ ์ + const actions = [ + { action: "view", name: "์กฐํ", type: "data_read" }, + { action: "create", name: "์์ฑ", type: "data_write" }, + { action: "update", name: "์์ ", type: "data_write" }, + { action: "delete", name: "์ญ์ ", type: "data_delete" }, + ]; + + actions.forEach(({ action, name, type }) => { + suggestedPermissions.push({ + permissionKey: `${resourceName}.${action}`, + name: `${menu.menuTitle} ${name}`, + permissionType: type, + action, + scope: "assigned", + }); + }); + + return { + menuPath: menu.menuPath, + menuTitle: menu.menuTitle, + domain: menu.domain, + existingPermissions: existing, + suggestedPermissions: suggestedPermissions.filter( + sp => !existing.some(ep => ep.permissionKey === sp.permissionKey) + ), + }; + }) + ); + + return analysis; +} + +// ๋ฉ๋ด ๊ธฐ๋ฐ ๊ถํ ์์ฑ +export async function generateMenuPermissions( + permissionsToCreate: Array<{ + permissionKey: string; + name: string; + permissionType: string; + action: string; + scope: string; + menuPath: string; + }> +) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + let created = 0; + let skipped = 0; + + await db.transaction(async (tx) => { + for (const perm of permissionsToCreate) { + // ์ค๋ณต ์ฒดํฌ + const existing = await tx.select() + .from(permissions) + .where(eq(permissions.permissionKey, perm.permissionKey)) + .limit(1); + + if (existing.length === 0) { + const resource = perm.menuPath.split('/').pop() || 'unknown'; + + await tx.insert(permissions).values({ + permissionKey: perm.permissionKey, + name: perm.name, + permissionType: perm.permissionType, + resource, + action: perm.action, + scope: perm.scope, + menuPath: perm.menuPath, + isSystem: false, + isActive: true, + }); + created++; + } else { + skipped++; + } + } + }); + + return { created, skipped }; +}
\ No newline at end of file diff --git a/lib/permissions/service.ts b/lib/permissions/service.ts new file mode 100644 index 00000000..3ef1ff04 --- /dev/null +++ b/lib/permissions/service.ts @@ -0,0 +1,434 @@ +// lib/permission/servicee.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, or, ilike } from "drizzle-orm"; +import { + permissions, + rolePermissions, + userPermissions, + permissionAuditLogs, + userRoles, + menuAssignments, + menuRequiredPermissions, + users, + vendors, + roles, +} from "@/db/schema"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// ์ญํ ์ ๊ถํ ํ ๋น +export async function assignPermissionsToRole( + roleId: number, + permissionIds: number[] +) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + + const currentUserId = Number(session.user.id) + + // ๊ถํ ์ฒดํฌ + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("๊ถํ ๊ด๋ฆฌ ๊ถํ์ด ์์ต๋๋ค."); + } + + await db.transaction(async (tx) => { + // ๊ธฐ์กด ๊ถํ ์ญ์ + await tx.delete(rolePermissions) + .where(eq(rolePermissions.roleId, roleId)); + + // ์ ๊ถํ ์ถ๊ฐ + if (permissionIds.length > 0) { + await tx.insert(rolePermissions).values( + permissionIds.map(permissionId => ({ + roleId, + permissionId, + grantedBy: currentUserId, + })) + ); + + // ๊ฐ์ฌ ๋ก๊ทธ + await tx.insert(permissionAuditLogs).values( + permissionIds.map(permissionId => ({ + targetType: "role", + targetId: roleId, + permissionId, + action: "grant", + performedBy: currentUserId, + reason: "์ญํ ๊ถํ ์ผ๊ด ์
๋ฐ์ดํธ", + })) + ); + } + }); + + return { success: true }; +} + + +// ์ญํ ์ ๊ถํ ๋ชฉ๋ก ์กฐํ +export async function getRolePermissions(roleId: number) { + const allPermissions = await db.select().from(permissions) + .where(eq(permissions.isActive, true)); + + const rolePerms = await db.select({ + permissionId: rolePermissions.permissionId, + }) + .from(rolePermissions) + .where(eq(rolePermissions.roleId, roleId)); + + return { + permissions: allPermissions, + assignedPermissionIds: rolePerms.map(rp => rp.permissionId), + }; +} + +// ๊ถํ ์ฒดํฌ ํจ์ +export async function checkUserPermission( + userId: number, + permissionKey: string +): Promise<boolean> { + // ์ญํ ๊ธฐ๋ฐ ๊ถํ + const roleBasedPerms = await db + .selectDistinct({ permissionKey: permissions.permissionKey }) + .from(userRoles) + .innerJoin(rolePermissions, eq(rolePermissions.roleId, userRoles.roleId)) + .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId)) + .where( + and( + eq(userRoles.userId, userId), + eq(permissions.permissionKey, permissionKey), + eq(permissions.isActive, true), + eq(rolePermissions.isActive, true) + ) + ); + + if (roleBasedPerms.length > 0) return true; + + // ์ฌ์ฉ์ ์ง์ ๊ถํ + const directPerms = await db + .selectDistinct({ permissionKey: permissions.permissionKey }) + .from(userPermissions) + .innerJoin(permissions, eq(permissions.id, userPermissions.permissionId)) + .where( + and( + eq(userPermissions.userId, userId), + eq(permissions.permissionKey, permissionKey), + eq(permissions.isActive, true), + eq(userPermissions.isActive, true), + eq(userPermissions.isGrant, true) // ๋ถ์ฌ๋ ๊ถํ๋ง + ) + ); + + return directPerms.length > 0; +} + +// ๋ฉ๋ด ์ ๊ทผ ๊ถํ ์ฒดํฌ +export async function checkMenuAccess( + userId: number, + menuPath: string +): Promise<boolean> { + // ๋ฉ๋ด ๋ด๋น์์ธ ๊ฒฝ์ฐ ์๋ ํ์ฉ + const isManager = await db + .selectDistinct({ id: menuAssignments.id }) + .from(menuAssignments) + .where( + and( + eq(menuAssignments.menuPath, menuPath), + or( + eq(menuAssignments.manager1Id, userId), + eq(menuAssignments.manager2Id, userId) + ) + ) + ); + + if (isManager.length > 0) return true; + + // ๋ฉ๋ด ํ์ ๊ถํ ์ฒดํฌ + const requiredPerms = await db + .select({ permissionKey: permissions.permissionKey }) + .from(menuRequiredPermissions) + .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) + .where( + and( + eq(menuRequiredPermissions.menuPath, menuPath), + eq(menuRequiredPermissions.isRequired, true) + ) + ); + + if (requiredPerms.length === 0) return true; // ํ์ ๊ถํ์ด ์์ผ๋ฉด ๋ชจ๋ ์ ๊ทผ ๊ฐ๋ฅ + + // ์ฌ์ฉ์๊ฐ ํ์ ๊ถํ์ ๋ชจ๋ ๊ฐ์ง๊ณ ์๋์ง ํ์ธ + for (const perm of requiredPerms) { + if (!await checkUserPermission(userId, perm.permissionKey)) { + return false; + } + } + + return true; +} + + +export async function searchUsers(query: string) { + const usersData = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + companyName: vendors.vendorName, + }) + .from(users) + .leftJoin(vendors, eq(vendors.id, users.companyId)) + .where( + or( + ilike(users.name, `%${query}%`), + ilike(users.email, `%${query}%`) + ) + ) + .limit(20); + + // ๊ฐ ์ฌ์ฉ์์ ์ญํ ์กฐํ + const usersWithRoles = await Promise.all( + usersData.map(async (user) => { + const userRolesData = await db + .select({ + id: roles.id, + name: roles.name, + }) + .from(userRoles) + .innerJoin(roles, eq(roles.id, userRoles.roleId)) + .where(eq(userRoles.userId, user.id)); + + return { + ...user, + roles: userRolesData, + }; + }) + ); + + return usersWithRoles; +} + +export async function getUserPermissionDetails(userId: number) { + // ์ญํ ๊ธฐ๋ฐ ๊ถํ + const rolePermissionsData = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + permissionType: permissions.permissionType, + resource: permissions.resource, + action: permissions.action, + scope: permissions.scope, + menuPath: permissions.menuPath, + roleName: roles.name, + }) + .from(userRoles) + .innerJoin(roles, eq(roles.id, userRoles.roleId)) + .innerJoin(rolePermissions, eq(rolePermissions.roleId, roles.id)) + .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId)) + .where(eq(userRoles.userId, userId)); + + // ์ง์ ๋ถ์ฌ๋ ๊ถํ + const directPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + permissionType: permissions.permissionType, + resource: permissions.resource, + action: permissions.action, + scope: permissions.scope, + menuPath: permissions.menuPath, + isGrant: userPermissions.isGrant, + grantedBy: users.name, + grantedAt: userPermissions.grantedAt, + expiresAt: userPermissions.expiresAt, + reason: userPermissions.reason, + }) + .from(userPermissions) + .innerJoin(permissions, eq(permissions.id, userPermissions.permissionId)) + .leftJoin(users, eq(users.id, userPermissions.grantedBy)) + .where(eq(userPermissions.userId, userId)); + + // ๋ชจ๋ ๊ถํ ๋ชฉ๋ก + const allPermissions = await db.select().from(permissions); + + return { + permissions: [ + ...rolePermissionsData.map(p => ({ ...p, source: "role" as const })), + ...directPermissions.map(p => ({ ...p, source: "direct" as const })), + ], + availablePermissions: allPermissions, + }; +} + +export async function grantPermissionToUser(params: { + userId: number; + permissionIds: number[]; + isGrant: boolean; + reason?: string; + expiresAt?: Date; +}) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + const currentUserId = Number(session.user.id) + + await db.transaction(async (tx) => { + for (const permissionId of params.permissionIds) { + await tx.insert(userPermissions).values({ + userId: params.userId, + permissionId, + isGrant: params.isGrant, + grantedBy: Number(session.user.id), + reason: params.reason, + expiresAt: params.expiresAt, + }).onConflictDoUpdate({ + target: [userPermissions.userId, userPermissions.permissionId], + set: { + isGrant: params.isGrant, + grantedBy: Number(session.user.id), + grantedAt: new Date(), + reason: params.reason, + expiresAt: params.expiresAt, + isActive: true, + } + }); + + // ๊ฐ์ฌ ๋ก๊ทธ + await tx.insert(permissionAuditLogs).values({ + targetType: "user", + targetId: params.userId, + permissionId, + action: params.isGrant ? "grant" : "restrict", + performedBy: currentUserId, + reason: params.reason, + }); + } + }); +} + +export async function revokePermissionFromUser(userId: number, permissionId: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("์ธ์ฆ์ด ํ์ํฉ๋๋ค.") + } + + await db.transaction(async (tx) => { + await tx.update(userPermissions) + .set({ isActive: false }) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionId, permissionId) + ) + ); + + // ๊ฐ์ฌ ๋ก๊ทธ + await tx.insert(permissionAuditLogs).values({ + targetType: "user", + targetId: userId, + permissionId, + action: "revoke", + performedBy: Number(session.user.id), + }); + }); +} + + +export async function getMenuPermissions(domain: string = "all") { + const menus = await db + .select({ + menuPath: menuAssignments.menuPath, + menuTitle: menuAssignments.menuTitle, + menuDescription: menuAssignments.menuDescription, + sectionTitle: menuAssignments.sectionTitle, + menuGroup: menuAssignments.menuGroup, + domain: menuAssignments.domain, + isActive: menuAssignments.isActive, + manager1Id: menuAssignments.manager1Id, + manager2Id: menuAssignments.manager2Id, + }) + .from(menuAssignments) + .where(domain === "all" ? undefined : eq(menuAssignments.domain, domain)); + + // ๊ฐ ๋ฉ๋ด์ ๊ถํ๊ณผ ๋ด๋น์ ์ ๋ณด ์กฐํ + const menusWithDetails = await Promise.all( + menus.map(async (menu) => { + // ํ์ ๊ถํ ์กฐํ + const requiredPerms = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + isRequired: menuRequiredPermissions.isRequired, + }) + .from(menuRequiredPermissions) + .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) + .where(eq(menuRequiredPermissions.menuPath, menu.menuPath)); + + // ๋ด๋น์ ์ ๋ณด ์กฐํ + const [manager1, manager2] = await Promise.all([ + menu.manager1Id ? db.select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + }).from(users).where(eq(users.id, menu.manager1Id)).then(r => r[0]) : null, + menu.manager2Id ? db.select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + }).from(users).where(eq(users.id, menu.manager2Id)).then(r => r[0]) : null, + ]); + + return { + ...menu, + requiredPermissions: requiredPerms, + manager1, + manager2, + }; + }) + ); + + // ์ฌ์ฉ ๊ฐ๋ฅํ ๋ชจ๋ ๊ถํ ๋ชฉ๋ก + const availablePermissions = await db.select().from(permissions); + + return { + menus: menusWithDetails, + availablePermissions, + }; +} + +export async function updateMenuPermissions( + menuPath: string, + permissions: Array<{ id: number; isRequired: boolean }> +) { + await db.transaction(async (tx) => { + // ๊ธฐ์กด ๊ถํ ์ญ์ + await tx.delete(menuRequiredPermissions) + .where(eq(menuRequiredPermissions.menuPath, menuPath)); + + // ์ ๊ถํ ์ถ๊ฐ + if (permissions.length > 0) { + await tx.insert(menuRequiredPermissions).values( + permissions.map(p => ({ + menuPath, + permissionId: p.id, + isRequired: p.isRequired, + })) + ); + } + }); +}
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index be8e13e6..f2894577 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -39,21 +39,23 @@ export async function getRfqs(input: GetRfqsSchema) { switch (input.rfqCategory) { case "general": // ์ผ๋ฐ๊ฒฌ์ : rfqType์ด ์๋ ๊ฒฝ์ฐ - typeFilter = and( - isNotNull(rfqsLastView.rfqType), - ne(rfqsLastView.rfqType, '') - ); + // typeFilter = and( + // isNotNull(rfqsLastView.rfqType), + // ne(rfqsLastView.rfqType, '') + // ); + // ์ผ๋ฐ๊ฒฌ์ : rfqCode๊ฐ F๋ก ์์ํ๋ ๊ฒฝ์ฐ + typeFilter = + like(rfqsLastView.rfqCode,'F%'); break; case "itb": // ITB: projectCompany๊ฐ ์๋ ๊ฒฝ์ฐ typeFilter = - like(rfqsLastView.rfqCode,'I%') - - ; + like(rfqsLastView.rfqCode,'I%'); break; case "rfq": // RFQ: prNumber๊ฐ ์๋ ๊ฒฝ์ฐ - typeFilter = like(rfqsLastView.rfqCode,'R%'); + typeFilter = + like(rfqsLastView.rfqCode,'R%'); break; } } @@ -244,7 +246,7 @@ export async function getRfqAllAttachments(rfqId: number) { } } } -// ์ฌ์ฉ์ ๋ชฉ๋ก ์กฐํ (ํํฐ์ฉ) +// ์ฌ์ฉ์ ๋ชฉ๋ก ์กฐํ (ํํฐ์ฉ), ๊ฒฌ์ ๋ด๋น์, ๊ตฌ๋งค๋ด๋น์ export async function getPUsersForFilter() { try { diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index f7515787..023c9f2a 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -76,7 +76,7 @@ const createGeneralRfqSchema = z.object({ }), picUserId: z.number().min(1, "๊ฒฌ์ ๋ด๋น์๋ฅผ ์ ํํด์ฃผ์ธ์"), remark: z.string().optional(), - items: z.array(itemSchema).min(1, "์ต์ ํ๋์ ์์ดํ
์ ์ถ๊ฐํด์ฃผ์ธ์"), + items: z.array(itemSchema).min(1, "์ต์ ํ๋์ ์์ฌ๋ฅผ ์ถ๊ฐํด์ฃผ์ธ์"), }) type CreateGeneralRfqFormValues = z.infer<typeof createGeneralRfqSchema> @@ -386,7 +386,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {field.value ? ( format(field.value, "yyyy-MM-dd") ) : ( - <span>์ ์ถ๋ง๊ฐ์ผ์ ์ ํํ์ธ์ (๋ฏธ์ ํ ์ ์์ฑ์ผ +7์ผ)</span> + <span>์ ์ถ๋ง๊ฐ์ผ์ ์ ํํ์ธ์</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> @@ -562,7 +562,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {/* ์์ดํ
์ ๋ณด ์น์
- ์ปดํฉํธํ UI */} <div className="space-y-4"> <div className="flex items-center justify-between"> - <h3 className="text-lg font-semibold">์์ดํ
์ ๋ณด</h3> + <h3 className="text-lg font-semibold">์์ฌ ์ ๋ณด</h3> <Button type="button" variant="outline" @@ -570,7 +570,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp onClick={handleAddItem} > <PlusCircle className="mr-2 h-4 w-4" /> - ์์ดํ
์ถ๊ฐ + ์์ฌ ์ถ๊ฐ </Button> </div> @@ -579,7 +579,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50"> <div className="flex items-center justify-between mb-3"> <span className="text-sm font-medium text-gray-700"> - ์์ดํ
#{index + 1} + ์์ฌ #{index + 1} </span> {fields.length > 1 && ( <Button @@ -623,7 +623,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp render={({ field }) => ( <FormItem> <FormLabel className="text-xs"> - ์์ฌ๋ช
<span className="text-red-500">*</span> + ์์ฌ๊ทธ๋ฃน(์์ฌ๊ทธ๋ฃน๋ช
) <span className="text-red-500">*</span> </FormLabel> <FormControl> <Input @@ -670,13 +670,29 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp <FormLabel className="text-xs"> ๋จ์ <span className="text-red-500">*</span> </FormLabel> - <FormControl> - <Input - placeholder="EA" - className="h-8 text-sm" - {...field} - /> - </FormControl> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger className="h-8 text-sm"> + <SelectValue placeholder="๋จ์ ์ ํ" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="EA">EA (Each)</SelectItem> + <SelectItem value="KG">KG (Kilogram)</SelectItem> + <SelectItem value="M">M (Meter)</SelectItem> + <SelectItem value="L">L (Liter)</SelectItem> + <SelectItem value="PC">PC (Piece)</SelectItem> + <SelectItem value="BOX">BOX (Box)</SelectItem> + <SelectItem value="SET">SET (Set)</SelectItem> + <SelectItem value="LOT">LOT (Lot)</SelectItem> + <SelectItem value="PCS">PCS (Pieces)</SelectItem> + <SelectItem value="TON">TON (Ton)</SelectItem> + <SelectItem value="G">G (Gram)</SelectItem> + <SelectItem value="ML">ML (Milliliter)</SelectItem> + <SelectItem value="CM">CM (Centimeter)</SelectItem> + <SelectItem value="MM">MM (Millimeter)</SelectItem> + </SelectContent> + </Select> <FormMessage /> </FormItem> )} @@ -693,7 +709,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp <FormLabel className="text-xs">๋น๊ณ </FormLabel> <FormControl> <Input - placeholder="์์ดํ
๋ณ ๋น๊ณ ์ฌํญ" + placeholder="์์ฌ๋ณ ๋น๊ณ ์ฌํญ" className="h-8 text-sm" {...field} /> diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx index fc7f4415..d0a9ee1e 100644 --- a/lib/rfq-last/table/rfq-table-columns.tsx +++ b/lib/rfq-last/table/rfq-table-columns.tsx @@ -140,7 +140,11 @@ export function getRfqColumns({ { accessorKey: "picUserName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="๊ตฌ๋งค๋ด๋น์" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + cell: ({ row }) => { + const name = row.original.picUserName || row.original.picName || "-"; + const picCode = row.original.picCode || ""; + return name === "-" ? "-" : `${name}(${picCode})`; + }, size: 100, }, @@ -473,7 +477,11 @@ export function getRfqColumns({ { accessorKey: "picUserName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="๊ตฌ๋งค๋ด๋น์" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + cell: ({ row }) => { + const name = row.original.picUserName || row.original.picName || "-"; + const picCode = row.original.picCode || ""; + return name === "-" ? "-" : `${name}(${picCode})`; + }, size: 100, }, @@ -1035,7 +1043,11 @@ export function getRfqColumns({ { accessorKey: "picUserName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="๊ตฌ๋งค๋ด๋น์" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + cell: ({ row }) => { + const name = row.original.picUserName || row.original.picName || "-"; + const picCode = row.original.picCode || ""; + return name === "-" ? "-" : `${name}(${picCode})`; + }, size: 100, }, diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx index 4a8960ff..26c3808a 100644 --- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -410,15 +410,15 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp <Input type="number" min="0" - step="0.01" + step="1" {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })} onChange={(e) => { - const value = Math.max(0, parseFloat(e.target.value) || 0) + const value = Math.max(0, Math.floor(parseFloat(e.target.value) || 0)) setValue(`quotationItems.${index}.unitPrice`, value) calculateTotal(index) }} className="w-[120px]" - placeholder="0.00" + placeholder="0" /> <span className="text-xs text-muted-foreground"> {currency} diff --git a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx index cfe24d73..2b3138d6 100644 --- a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx +++ b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx @@ -380,7 +380,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment )} {/* ์ ์ฒด ๋ค์ด๋ก๋ ๋ฒํผ ์ถ๊ฐ */} - {attachments.length > 0 && !isLoading && ( + {/* {attachments.length > 0 && !isLoading && ( <Button onClick={handleDownloadAll} disabled={isDownloadingAll} @@ -399,7 +399,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment </> )} </Button> - )} + )} */} </div> </DialogContent> </Dialog> diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx index 893fd9a3..ff3e27cc 100644 --- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -436,7 +436,9 @@ export function BatchUpdateConditionsDialog({ className="w-full justify-between" disabled={!fieldsToUpdate.currency} > + <span className="text-muted-foreground"> {field.value || "ํตํ ์ ํ"} + </span> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts index 38bf467c..651c8eda 100644 --- a/lib/rfqs/service.ts +++ b/lib/rfqs/service.ts @@ -1795,6 +1795,7 @@ export type Project = { id: number; projectCode: string; projectName: string; + type: string; } export async function getProjects(): Promise<Project[]> { @@ -1807,6 +1808,7 @@ export async function getProjects(): Promise<Project[]> { id: projects.id, projectCode: projects.code, // ํ
์ด๋ธ์ ์ค์ ์ปฌ๋ผ๋ช
์ ๋ง๊ฒ ์กฐ์ projectName: projects.name, // ํ
์ด๋ธ์ ์ค์ ์ปฌ๋ผ๋ช
์ ๋ง๊ฒ ์กฐ์ + type: projects.type, // ํ
์ด๋ธ์ ์ค์ ์ปฌ๋ผ๋ช
์ ๋ง๊ฒ ์กฐ์ }) .from(projects) .orderBy(projects.code); diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts index e3c3f6bb..904d27ba 100644 --- a/lib/sedp/sync-form.ts +++ b/lib/sedp/sync-form.ts @@ -913,8 +913,8 @@ async function getContractItemsByItemCodes(itemCodes: string[], projectId: numbe export async function saveFormMappingsAndMetas( projectId: number, projectCode: string, - registers: Register[], // legacy SEDP Register list (supplemental) - newRegisters: newRegister[] // AdapterDataMapping list (primary) + registers: Register[], + newRegisters: newRegister[] ): Promise<number> { try { /* ------------------------------------------------------------------ */ @@ -929,14 +929,17 @@ export async function saveFormMappingsAndMetas( const registerMap = new Map(registers.map(r => [r.TYPE_ID, r])); const attributeMap = await getAttributes(projectCode); - const codeListMap = await getCodeLists(projectCode); + // getCodeLists ํธ์ถ ์ ๊ฑฐ + // const codeListMap = await getCodeLists(projectCode); const uomMap = await getUOMs(projectCode); const defaultAttributes = await getDefaulTAttributes(); + // ์ฑ๋ฅ ํฅ์์ ์ํ ์ฝ๋ ๋ฆฌ์คํธ ์บ์ ์ถ๊ฐ (์ ํ์ฌํญ) + const codeListCache = new Map<string, CodeList | null>(); + /* ------------------------------------------------------------------ */ - /* 2. Contractโitem lookโup (SCOPES) - ์์ ๋ ๋ถ๋ถ */ + /* 2. Contractโitem lookโup (SCOPES) */ /* ------------------------------------------------------------------ */ - // SCOPES ๋ฐฐ์ด์์ ๋ชจ๋ uniqueํ itemCode๋ค์ ์ถ์ถ const uniqueItemCodes = [...new Set( newRegisters .filter(nr => nr.SCOPES && nr.SCOPES.length > 0) @@ -1002,9 +1005,26 @@ export async function saveFormMappingsAndMetas( ...(uomSymbol ? { uom: uomSymbol, uomId } : {}) }; + // ์์ ๋ ๋ถ๋ถ: getCodeListById ์ฌ์ฉ if (!defaultAttributes.includes(attId) && (attribute.VAL_TYPE === "LIST" || attribute.VAL_TYPE === "DYNAMICLIST") && attribute.CL_ID) { - const cl = codeListMap.get(attribute.CL_ID); - if (cl?.VALUES?.length) col.options = [...new Set(cl.VALUES.filter(v => v.USE_YN).map(v => v.VALUE))]; + // ์บ์ ํ์ธ + let cl = codeListCache.get(attribute.CL_ID); + + // ์บ์์ ์์ผ๋ฉด API ํธ์ถ + if (!codeListCache.has(attribute.CL_ID)) { + try { + cl = await getCodeListById(projectCode, attribute.CL_ID); + codeListCache.set(attribute.CL_ID, cl); // ์บ์์ ์ ์ฅ + } catch (error) { + console.warn(`์ฝ๋ ๋ฆฌ์คํธ ${attribute.CL_ID} ๊ฐ์ ธ์ค๊ธฐ ์คํจ:`, error); + cl = null; + codeListCache.set(attribute.CL_ID, null); // ์คํจ๋ ์บ์์ ์ ์ฅ + } + } + + if (cl?.VALUES?.length) { + col.options = [...new Set(cl.VALUES.filter(v => v.USE_YN).map(v => v.VALUE))]; + } } columns.push(col); @@ -1025,7 +1045,6 @@ export async function saveFormMappingsAndMetas( if (!cls) { console.warn(`ํด๋์ค ${classId} ์์`); return; } const tp = tagTypeMap.get(cls.tagTypeCode); if (!tp) { console.warn(`ํ๊ทธ ํ์
${cls.tagTypeCode} ์์`); return; } - // SCOPES ๋ฐฐ์ด์ ๋ฌธ์์ด๋ก ๋ณํํ์ฌ remark์ ์ ์ฅ const scopesRemark = newReg.SCOPES && newReg.SCOPES.length > 0 ? newReg.SCOPES.join(', ') : null; mappingsToSave.push({ projectId, @@ -1040,13 +1059,11 @@ export async function saveFormMappingsAndMetas( }); }); - /* ---------- 4โd. contractItem โ form - ์์ ๋ ๋ถ๋ถ -------------- */ + /* ---------- 4โd. contractItem โ form -------------------------- */ if (newReg.SCOPES && newReg.SCOPES.length > 0) { - // SCOPES ๋ฐฐ์ด์ ๊ฐ itemCode์ ๋ํด ์ฒ๋ฆฌ for (const itemCode of newReg.SCOPES) { const contractItemIds = itemCodeToContractItemIds.get(itemCode); if (contractItemIds && contractItemIds.length > 0) { - // ๋ชจ๋ contractItemId์ ๋ํด form ์์ฑ contractItemIds.forEach(cId => { contractItemIdsWithForms.add(cId); formsToSave.push({ @@ -1096,7 +1113,6 @@ export async function saveFormMappingsAndMetas( } } - // ๋ฉ์ธ ๋๊ธฐํ ํจ์ export async function syncTagFormMappings() { try { diff --git a/lib/tags/service.ts b/lib/tags/service.ts index cef20209..028cde42 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -393,69 +393,89 @@ export async function createTagInForm( formCode: string, packageCode: string ) { + // 1. ์ด๊ธฐ ๊ฒ์ฆ if (!selectedPackageId) { - return { error: "No selectedPackageId provided" } + console.error("[CREATE TAG] No selectedPackageId provided"); + return { + success: false, + error: "No selectedPackageId provided" + }; } - // Validate formData - const validated = createTagSchema.safeParse(formData) + // 2. FormData ๊ฒ์ฆ + const validated = createTagSchema.safeParse(formData); if (!validated.success) { - return { error: validated.error.flatten().formErrors.join(", ") } + const errorMsg = validated.error.flatten().formErrors.join(", "); + console.error("[CREATE TAG] Validation failed:", errorMsg); + return { + success: false, + error: errorMsg + }; } - // React ์๋ฒ ์ก์
์์ ๋งค ์์ฒญ๋ง๋ค ์คํ - unstable_noStore() + // 3. ์บ์ ๋ฌดํจํ ์ค์ + unstable_noStore(); try { - // ํ๋์ ํธ๋์ญ์
์์ ๋ชจ๋ ์์
์ํ + // 4. ํธ๋์ญ์
์์ return await db.transaction(async (tx) => { - // 1) ์ ํ๋ contractItem์ contractId ๊ฐ์ ธ์ค๊ธฐ + // 5. Contract Item ์ ๋ณด ์กฐํ const contractItemResult = await tx .select({ contractId: contractItems.contractId, - projectId: contracts.projectId, // projectId ์ถ๊ฐ - vendorId: contracts.vendorId // projectId ์ถ๊ฐ + projectId: contracts.projectId, + vendorId: contracts.vendorId }) .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts ํ
์ด๋ธ ์กฐ์ธ + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where(eq(contractItems.id, selectedPackageId)) - .limit(1) + .limit(1); if (contractItemResult.length === 0) { - return { error: "Contract item not found" } + console.error("[CREATE TAG] Contract item not found"); + return { + success: false, + error: "Contract item not found" + }; } - const contractId = contractItemResult[0].contractId - const projectId = contractItemResult[0].projectId - const vendorId = contractItemResult[0].vendorId + const { contractId, projectId, vendorId } = contractItemResult[0]; - const vendor = await db.query.vendors.findFirst({ + // 6. Vendor ์ ๋ณด ์กฐํ + const vendor = await tx.query.vendors.findFirst({ where: eq(vendors.id, vendorId) }); - + if (!vendor) { - return { error: "์ ํํ ๋ฒค๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค." }; + console.error("[CREATE TAG] Vendor not found"); + return { + success: false, + error: "์ ํํ ๋ฒค๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค." + }; } - // 2) ํด๋น ๊ณ์ฝ ๋ด์์ ๊ฐ์ tagNo๋ฅผ ๊ฐ์ง ํ๊ทธ๊ฐ ์๋์ง ํ์ธ + // 7. ์ค๋ณต ํ๊ทธ ํ์ธ const duplicateCheck = await tx .select({ count: sql<number>`count(*)` }) .from(tags) .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where( and( - eq(contractItems.contractId, contractId), + eq(contracts.projectId, projectId), eq(tags.tagNo, validated.data.tagNo) ) - ) + ); if (duplicateCheck[0].count > 0) { + console.error(`[CREATE TAG] Duplicate tag number: ${validated.data.tagNo}`); return { + success: false, error: `ํ๊ทธ ๋ฒํธ "${validated.data.tagNo}"๋ ์ด๋ฏธ ์ด ๊ณ์ฝ ๋ด์ ์กด์ฌํฉ๋๋ค.`, - } + }; } - // 3) ๋จผ์ ๊ธฐ์กด form ์ฐพ๊ธฐ + // 8. Form ์กฐํ let form = await tx.query.forms.findFirst({ where: and( eq(forms.formCode, formCode), @@ -463,191 +483,183 @@ export async function createTagInForm( ) }); - // 4) form์ด ์์ผ๋ฉด formMappings๋ฅผ ํตํด ์์ฑ + // 9. Form์ด ์์ผ๋ฉด ์์ฑ if (!form) { - console.log(`[CREATE TAG IN FORM] Form ${formCode} not found, attempting to create...`); + console.log(`[CREATE TAG] Form ${formCode} not found, attempting to create...`); - // ํ๊ทธ ํ์
์ ๋ฐ๋ฅธ ํผ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ + // Form Mappings ์กฐํ const allFormMappings = await getFormMappingsByTagType( validated.data.tagType, projectId, validated.data.class - ) - - - - - // ep๊ฐ "IMEP"์ธ ๊ฒ๋ง ํํฐ๋ง - const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [] + ); - // ํ์ฌ formCode์ ์ผ์นํ๋ ๋งคํ ์ฐพ๊ธฐ + // IMEP ํผ๋ง ํํฐ๋ง + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode); - if (targetFormMapping) { - console.log(`[CREATE TAG IN FORM] Found form mapping for ${formCode}, creating form...`); - - // form ์์ฑ - const insertResult = await tx - .insert(forms) - .values({ - contractItemId: selectedPackageId, - formCode: targetFormMapping.formCode, - formName: targetFormMapping.formName, - im: true, - }) - .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) - - form = { - id: insertResult[0].id, - formCode: insertResult[0].formCode, - formName: insertResult[0].formName, - contractItemId: selectedPackageId, - im: true, - createdAt: new Date(), - updatedAt: new Date() - }; - - console.log(`[CREATE TAG IN FORM] Successfully created form:`, insertResult[0]); - } else { - console.log(`[CREATE TAG IN FORM] No IMEP form mapping found for formCode: ${formCode}`); - console.log(`[CREATE TAG IN FORM] Available IMEP mappings:`, formMappings.map(m => m.formCode)); + if (!targetFormMapping) { + console.error(`[CREATE TAG] No IMEP form mapping found for formCode: ${formCode}`); return { + success: false, error: `Form ${formCode} not found and no IMEP mapping available for tag type ${validated.data.tagType}` }; } - } else { - console.log(`[CREATE TAG IN FORM] Found existing form:`, form.id); + + // Form ์์ฑ + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: targetFormMapping.formCode, + formName: targetFormMapping.formName, + im: true, + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); + + form = { + id: insertResult[0].id, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + contractItemId: selectedPackageId, + im: true, + createdAt: new Date(), + updatedAt: new Date() + }; - // ๊ธฐ์กด form์ด ์์ง๋ง im์ด false์ธ ๊ฒฝ์ฐ true๋ก ์
๋ฐ์ดํธ + console.log(`[CREATE TAG] Successfully created form:`, insertResult[0]); + } else { + // ๊ธฐ์กด form์ im ์ํ ์
๋ฐ์ดํธ if (form.im !== true) { await tx .update(forms) .set({ im: true }) - .where(eq(forms.id, form.id)) + .where(eq(forms.id, form.id)); - console.log(`[CREATE TAG IN FORM] Form ${form.id} updated with im: true`) + console.log(`[CREATE TAG] Form ${form.id} updated with im: true`); } } - if (form?.id) { - // ๐ 16์ง์ 24์๋ฆฌ ํ๊ทธ ๊ณ ์ ์๋ณ์ ์์ฑ - const generatedTagIdx = generateTagIdx(); - console.log(`[CREATE TAG IN FORM] Generated tagIdx: ${generatedTagIdx}`); + // 10. Form์ด ์๋ ๊ฒฝ์ฐ์๋ง ์งํ + if (!form?.id) { + console.error("[CREATE TAG] Failed to create or find form"); + return { + success: false, + error: "Failed to create or find form" + }; + } - // 5) ์ Tag ์์ฑ (tagIdx ์ถ๊ฐ) - const [newTag] = await insertTag(tx, { - contractItemId: selectedPackageId, - formId: form.id, - tagIdx: generatedTagIdx, // ๐ ์์ฑ๋ 16์ง์ 24์๋ฆฌ ์ถ๊ฐ - tagNo: validated.data.tagNo, - class: validated.data.class, - tagType: validated.data.tagType, - description: validated.data.description ?? null, - }) + // 11. Tag Index ์์ฑ + const generatedTagIdx = generateTagIdx(); + console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); - // 6) ๊ธฐ์กด formEntry ๊ฐ์ ธ์ค๊ธฐ - const entry = await tx.query.formEntries.findFirst({ - where: and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, selectedPackageId), - ) - }); + // 12. ์ Tag ์์ฑ + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: form.id, + tagIdx: generatedTagIdx, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + }); - if (entry && entry.id) { - // 7) ๊ธฐ์กด ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ (๋ฐฐ์ด์ธ์ง ํ์ธ) - TAG_IDX ํ์
์ถ๊ฐ - let existingData: Array<{ - TAG_IDX?: string; // ๐ TAG_IDX ํ๋ ์ถ๊ฐ - TAG_NO: string; - TAG_DESC?: string; - status?: string; - [key: string]: any; // ๋ค๋ฅธ ํ๋๋ค๋ ํฌํจ - }> = []; - - if (Array.isArray(entry.data)) { - existingData = entry.data; - } + // 13. Tag Class ์กฐํ + const tagClass = await tx.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.projectId, projectId), + eq(tagClasses.label, validated.data.class) + ) + }); - console.log(`[CREATE TAG IN FORM] Existing data count: ${existingData.length}`); + if (!tagClass) { + console.warn("[CREATE TAG] Tag class not found, using default"); + } - const tagClass = await db.query.tagClasses.findFirst({ - where: and(eq(tagClasses.projectId, projectId),eq(tagClasses.label, validated.data.class)) - }); + // 14. FormEntry ์ฒ๋ฆฌ + const entry = await tx.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, selectedPackageId), + ) + }); - // 8) ์๋ก์ด ํ๊ทธ๋ฅผ ๊ธฐ์กด ๋ฐ์ดํฐ์ ์ถ๊ฐ (TAG_IDX ํฌํจ) - const newTagData = { - TAG_IDX: generatedTagIdx, // ๐ ๊ฐ์ 16์ง์ 24์๋ฆฌ ๊ฐ ์ฌ์ฉ - TAG_NO: validated.data.tagNo, - TAG_DESC: validated.data.description ?? null, - CLS_ID: tagClass.code, - VNDRCD: vendor.vendorCode, - VNDRNM_1: vendor.vendorName, - CM3003: packageCode, - ME5074: packageCode, + // 15. ์๋ก์ด ํ๊ทธ ๋ฐ์ดํฐ ์ค๋น + const newTagData = { + TAG_IDX: generatedTagIdx, + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + CLS_ID: tagClass?.code || validated.data.class, // tagClass๊ฐ ์์ ๊ฒฝ์ฐ ๋๋น + VNDRCD: vendor.vendorCode, + VNDRNM_1: vendor.vendorName, + CM3003: packageCode, + ME5074: packageCode, + status: "New" // ์๋์ผ๋ก ์์ฑ๋ ํ๊ทธ์์ ํ์ + }; - status: "New" // ์๋์ผ๋ก ์์ฑ๋ ํ๊ทธ์์ ํ์ - }; + if (entry?.id) { + // 16. ๊ธฐ์กด FormEntry ์
๋ฐ์ดํธ + let existingData: Array<any> = []; + if (Array.isArray(entry.data)) { + existingData = entry.data; + } - const updatedData = [...existingData, newTagData]; + console.log(`[CREATE TAG] Existing data count: ${existingData.length}`); - console.log(`[CREATE TAG IN FORM] Updated data count: ${updatedData.length}`); - console.log(`[CREATE TAG IN FORM] Added tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}, status: ์๋ ์์ฑ`); + const updatedData = [...existingData, newTagData]; - // 9) formEntries ์
๋ฐ์ดํธ - await tx - .update(formEntries) - .set({ - data: updatedData, - updatedAt: new Date() // ์
๋ฐ์ดํธ ์๊ฐ๋ ๊ฐฑ์ - }) - .where(eq(formEntries.id, entry.id)); - } else { - // 10) formEntry๊ฐ ์๋ ๊ฒฝ์ฐ ์๋ก ์์ฑ (TAG_IDX ํฌํจ) - console.log(`[CREATE TAG IN FORM] No existing formEntry found, creating new one`); - - const newEntryData = [{ - TAG_IDX: generatedTagIdx, // ๐ ๊ฐ์ 16์ง์ 24์๋ฆฌ ๊ฐ ์ฌ์ฉ - TAG_NO: validated.data.tagNo, - TAG_DESC: validated.data.description ?? null, - status: "New" // ์๋์ผ๋ก ์์ฑ๋ ํ๊ทธ์์ ํ์ - }]; + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, entry.id)); - await tx.insert(formEntries).values({ - formCode: formCode, - contractItemId: selectedPackageId, - data: newEntryData, - createdAt: new Date(), - updatedAt: new Date(), - }); - } + console.log(`[CREATE TAG] Updated formEntry with new tag`); + } else { + // 17. ์ FormEntry ์์ฑ + console.log(`[CREATE TAG] Creating new formEntry`); + + await tx.insert(formEntries).values({ + formCode: formCode, + contractItemId: selectedPackageId, + data: [newTagData], + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[CREATE TAG] Created new formEntry`); + } + + // 18. ์บ์ ๋ฌดํจํ + revalidateTag(`tags-${selectedPackageId}`); + revalidateTag(`forms-${selectedPackageId}`); + revalidateTag(`form-data-${formCode}-${selectedPackageId}`); + revalidateTag("tags"); - // 12) ์ฑ๊ณต ์ ๋ฐํ + console.log(`[CREATE TAG] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`); + + // 19. ์ฑ๊ณต ์๋ต return { success: true, data: { formId: form.id, tagNo: validated.data.tagNo, - tagIdx: generatedTagIdx, // ๐ ์์ฑ๋ tagIdx๋ ๋ฐํ - formCreated: !form // form์ด ์๋ก ์์ฑ๋์๋์ง ์ฌ๋ถ + tagIdx: generatedTagIdx, + formCreated: !form } - } - - console.log(`[CREATE TAG IN FORM] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`) - } else { - return { error: "Failed to create or find form" }; - } - - // 11) ์บ์ ๋ฌดํจํ (React ์๋ฒ ์ก์
์์ ์บ์ฑ ์ฌ์ฉ ์) - revalidateTag(`tags-${selectedPackageId}`) - revalidateTag(`forms-${selectedPackageId}`) - revalidateTag(`form-data-${formCode}-${selectedPackageId}`) // ํผ ๋ฐ์ดํฐ ์บ์๋ ๋ฌดํจํ - revalidateTag("tags") - - - }) + }; + }); } catch (err: any) { - console.log("createTag in Form error:", err) - console.error("createTag in Form error:", err) - return { error: getErrorMessage(err) } + // 20. ์๋ฌ ์ฒ๋ฆฌ + console.error("[CREATE TAG] Transaction error:", err); + const errorMessage = getErrorMessage(err); + + return { + success: false, + error: errorMessage + }; } } diff --git a/lib/vendor-document-list/plant/document-stage-actions.ts b/lib/vendor-document-list/plant/document-stage-actions.ts deleted file mode 100644 index e69de29b..00000000 --- a/lib/vendor-document-list/plant/document-stage-actions.ts +++ /dev/null diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 14035562..779d31e1 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -32,7 +32,7 @@ import { } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" import { StageDocumentsView } from "@/db/schema" -import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react" +import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle, FileText, FolderOpen} from "lucide-react" import { toast } from "sonner" import { getDocumentNumberTypes, @@ -60,11 +60,11 @@ import { DrawerTrigger, } from "@/components/ui/drawer" import { useRouter } from "next/navigation" -import { cn, formatDate } from "@/lib/utils" -import ExcelJS from 'exceljs' -import { Progress } from "@/components/ui/progress" import { Alert, AlertDescription } from "@/components/ui/alert" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Controller, useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" const getStatusVariant = (status: string) => { switch (status) { @@ -88,17 +88,24 @@ const getStatusText = (status: string) => { } -// ============================================================================= -// 1. Add Document Dialog -// ============================================================================= +// Form validation schema +const documentFormSchema = z.object({ + documentClassId: z.string().min(1, "Document class is required"), + title: z.string().min(1, "Document title is required"), + shiFieldValues: z.record(z.string()).optional(), + cpyFieldValues: z.record(z.string()).optional(), + planDates: z.record(z.string()).optional(), +}) + +type DocumentFormValues = z.infer<typeof documentFormSchema> + interface AddDocumentDialogProps { open: boolean onOpenChange: (open: boolean) => void contractId: number - projectType: "ship" | "plant" + projectType?: string } - export function AddDocumentDialog({ open, onOpenChange, @@ -106,113 +113,115 @@ export function AddDocumentDialog({ projectType }: AddDocumentDialogProps) { const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [documentNumberTypes, setDocumentNumberTypes] = React.useState<any[]>([]) const [documentClasses, setDocumentClasses] = React.useState<any[]>([]) - const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) - const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) - // SHI์ CPY ํ์
์ฒดํฌ + // SHI related states const [shiType, setShiType] = React.useState<any>(null) + const [shiTypeConfigs, setShiTypeConfigs] = React.useState<any[]>([]) + const [shiComboBoxOptions, setShiComboBoxOptions] = React.useState<Record<number, any[]>>({}) + + // CPY related states const [cpyType, setCpyType] = React.useState<any>(null) - const [activeTab, setActiveTab] = React.useState<"SHI" | "CPY">("SHI") - const [dataLoaded, setDataLoaded] = React.useState(false) + const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([]) + const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({}) + + // Initialize react-hook-form + const form = useForm<DocumentFormValues>({ + resolver: zodResolver(documentFormSchema), + defaultValues: { + documentClassId: '', + title: '', + shiFieldValues: {}, + cpyFieldValues: {}, + planDates: {}, + }, + }) - console.log(dataLoaded,"dataLoaded") + // Watch form values for reactive updates + const documentClassId = useWatch({ + control: form.control, + name: 'documentClassId', + }) - const [formData, setFormData] = React.useState({ - documentNumberTypeId: "", - documentClassId: "", - title: "", - fieldValues: {} as Record<string, string>, - planDates: {} as Record<number, string> + const shiFieldValues = useWatch({ + control: form.control, + name: 'shiFieldValues', }) - // Load initial data -// Dialog๊ฐ ๋ซํ ๋ ์ํ ์ด๊ธฐํ๋ฅผ ํ์คํ ํ๊ธฐ -React.useEffect(() => { - if (!open) { - // Dialog๊ฐ ๋ซํ ๋๋ง ์ด๊ธฐํ - resetForm() - } else if (!dataLoaded) { - // Dialog๊ฐ ์ด๋ฆฌ๊ณ ๋ฐ์ดํฐ๊ฐ ๋ก๋๋์ง ์์์ ๋๋ง - loadInitialData() - } -}, [open]) + const cpyFieldValues = useWatch({ + control: form.control, + name: 'cpyFieldValues', + }) + // Load initial data when dialog opens + React.useEffect(() => { + if (open) { + loadInitialData() + } else { + // Reset form when dialog closes + form.reset() + setShiTypeConfigs([]) + setCpyTypeConfigs([]) + setShiComboBoxOptions({}) + setCpyComboBoxOptions({}) + setDocumentClassOptions([]) + } + }, [open]) + + // Load document class options when class changes + React.useEffect(() => { + if (documentClassId) { + loadDocumentClassOptions(documentClassId) + } + }, [documentClassId]) const loadInitialData = async () => { setIsLoadingInitialData(true) - let foundShiType = null; - let foundCpyType = null; - try { const [typesResult, classesResult] = await Promise.all([ getDocumentNumberTypes(contractId), getDocumentClasses(contractId) ]) - console.log(typesResult,"typesResult") - if (typesResult.success && typesResult.data) { - setDocumentNumberTypes(typesResult.data) - - // ๋ก์ปฌ ๋ณ์์ ๋จผ์ ์ ์ฅ - foundShiType = typesResult.data.find((type: any) => - type.name?.toUpperCase().trim() === "SHI" + const foundShiType = typesResult.data.find((type: any) => + type.name?.toUpperCase().trim() === 'SHI' ) - foundCpyType = typesResult.data.find((type: any) => - type.name?.toUpperCase().trim() === "CPY" + const foundCpyType = typesResult.data.find((type: any) => + type.name?.toUpperCase().trim() === 'CPY' ) setShiType(foundShiType || null) setCpyType(foundCpyType || null) - - // ๋ก์ปฌ ๋ณ์ ์ฌ์ฉ + + // Load configs for both types if (foundShiType) { - await handleTabChange("SHI", String(foundShiType.id)) - } else if (foundCpyType) { - setActiveTab("CPY") - await handleTabChange("CPY", String(foundCpyType.id)) + await loadShiTypeConfigs(foundShiType.id) + } + if (foundCpyType) { + await loadCpyTypeConfigs(foundCpyType.id) } } if (classesResult.success) { setDocumentClasses(classesResult.data) } - - setDataLoaded(true) } catch (error) { - console.error("Error loading data:", error) - toast.error("Error loading data.") + console.error('Error loading initial data:', error) + toast.error('Error loading data.') } finally { - // ๋ก์ปฌ ๋ณ์๋ฅผ ์ฒดํฌ - if (!foundShiType && !foundCpyType) { - console.error("No types found after loading") - } setIsLoadingInitialData(false) } } - // ํญ ๋ณ๊ฒฝ ์ฒ๋ฆฌ - const handleTabChange = async (tab: "SHI" | "CPY", typeId?: string) => { - setActiveTab(tab) - - const documentNumberTypeId = typeId || (tab === "SHI" ? shiType?.id : cpyType?.id) - - if (documentNumberTypeId) { - setFormData(prev => ({ - ...prev, - documentNumberTypeId: String(documentNumberTypeId), - fieldValues: {} - })) - - const configsResult = await getDocumentNumberTypeConfigs(Number(documentNumberTypeId)) + const loadShiTypeConfigs = async (typeId: number) => { + try { + const configsResult = await getDocumentNumberTypeConfigs(typeId) if (configsResult.success) { - setSelectedTypeConfigs(configsResult.data) + setShiTypeConfigs(configsResult.data) - // Pre-load combobox options + // Pre-load combobox options for SHI const comboBoxPromises = configsResult.data .filter(config => config.codeGroup?.controlType === 'combobox') .map(async (config) => { @@ -230,62 +239,82 @@ React.useEffect(() => { newComboBoxOptions[result.codeGroupId] = result.options } }) - setComboBoxOptions(newComboBoxOptions) + setShiComboBoxOptions(newComboBoxOptions) } - } else { - setSelectedTypeConfigs([]) - setComboBoxOptions({}) + } catch (error) { + console.error('Error loading SHI type configs:', error) } } - // Handle field value change - const handleFieldValueChange = (fieldKey: string, value: string) => { - setFormData({ - ...formData, - fieldValues: { - ...formData.fieldValues, - [fieldKey]: value + const loadCpyTypeConfigs = async (typeId: number) => { + try { + const configsResult = await getDocumentNumberTypeConfigs(typeId) + if (configsResult.success) { + setCpyTypeConfigs(configsResult.data) + + // Pre-load combobox options for CPY + const comboBoxPromises = configsResult.data + .filter(config => config.codeGroup?.controlType === 'combobox') + .map(async (config) => { + const optionsResult = await getComboBoxOptions(config.codeGroupId!, contractId) + return { + codeGroupId: config.codeGroupId, + options: optionsResult.success ? optionsResult.data : [] + } + }) + + const comboBoxResults = await Promise.all(comboBoxPromises) + const newComboBoxOptions: Record<number, any[]> = {} + comboBoxResults.forEach(result => { + if (result.codeGroupId) { + newComboBoxOptions[result.codeGroupId] = result.options + } + }) + setCpyComboBoxOptions(newComboBoxOptions) } - }) + } catch (error) { + console.error('Error loading CPY type configs:', error) + } } - // Handle document class change - const handleDocumentClassChange = async (documentClassId: string) => { - setFormData({ - ...formData, - documentClassId, - planDates: {} - }) - - if (documentClassId) { - const optionsResult = await getDocumentClassOptions(Number(documentClassId)) + const loadDocumentClassOptions = async (classId: string) => { + try { + const optionsResult = await getDocumentClassOptions(Number(classId)) if (optionsResult.success) { setDocumentClassOptions(optionsResult.data) + // Reset plan dates for new class + form.setValue('planDates', {}) } - } else { - setDocumentClassOptions([]) + } catch (error) { + console.error('Error loading class options:', error) } } - // Handle plan date change - const handlePlanDateChange = (optionId: number, date: string) => { - setFormData({ - ...formData, - planDates: { - ...formData.planDates, - [optionId]: date + // Generate document number preview for SHI + const generateShiPreview = () => { + if (shiTypeConfigs.length === 0) return '' + + let preview = '' + shiTypeConfigs.forEach((config, index) => { + const fieldKey = `field_${config.sdq}` + const value = shiFieldValues?.[fieldKey] || '[value]' + + if (index > 0 && config.delimiter) { + preview += config.delimiter } + preview += value }) + return preview } - // Generate document number preview - const generatePreviewDocNumber = () => { - if (selectedTypeConfigs.length === 0) return "" + // Generate document number preview for CPY + const generateCpyPreview = () => { + if (cpyTypeConfigs.length === 0) return '' - let preview = "" - selectedTypeConfigs.forEach((config, index) => { + let preview = '' + cpyTypeConfigs.forEach((config, index) => { const fieldKey = `field_${config.sdq}` - const value = formData.fieldValues[fieldKey] || "[value]" + const value = cpyFieldValues?.[fieldKey] || '[value]' if (index > 0 && config.delimiter) { preview += config.delimiter @@ -295,228 +324,155 @@ React.useEffect(() => { return preview } - // Check if form is valid for submission - const isFormValid = () => { - if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title.trim()) { - return false - } + // Check if SHI fields are complete + const isShiComplete = () => { + if (!shiType || shiTypeConfigs.length === 0) return true // Skip if not configured - const requiredConfigs = selectedTypeConfigs.filter(config => config.required) + const requiredConfigs = shiTypeConfigs.filter(config => config.required) for (const config of requiredConfigs) { const fieldKey = `field_${config.sdq}` - const value = formData.fieldValues[fieldKey] + const value = shiFieldValues?.[fieldKey] if (!value || !value.trim()) { return false } } - const docNumber = generatePreviewDocNumber() - if (!docNumber || docNumber === "" || docNumber.includes("[value]")) { - return false + const preview = generateShiPreview() + return preview && preview !== '' && !preview.includes('[value]') + } + + // Check if CPY fields are complete + const isCpyComplete = () => { + if (!cpyType || cpyTypeConfigs.length === 0) return true // Skip if not configured + + const requiredConfigs = cpyTypeConfigs.filter(config => config.required) + for (const config of requiredConfigs) { + const fieldKey = `field_${config.sdq}` + const value = cpyFieldValues?.[fieldKey] + if (!value || !value.trim()) { + return false + } } - return true + const preview = generateCpyPreview() + return preview && preview !== '' && !preview.includes('[value]') } - const handleSubmit = async () => { - if (!isFormValid()) { - toast.error("Please fill in all required fields.") + const onSubmit = async (data: DocumentFormValues) => { + // Validate that at least one document number is configured and complete + if (shiType && !isShiComplete()) { + toast.error('Please fill in all required SHI document number fields.') return } - - const generatedDocNumber = generatePreviewDocNumber() - if (!generatedDocNumber) { - toast.error("Cannot generate document number.") + + if (cpyType && !isCpyComplete()) { + toast.error('Please fill in all required CPY project document number fields.') return } - setIsSubmitting(true) + const shiDocNumber = shiType ? generateShiPreview() : '' + const cpyDocNumber = cpyType ? generateCpyPreview() : '' + try { - // CPY ํญ์์๋ ์์ฑ๋ ๋ฌธ์๋ฒํธ๋ฅผ vendorDocNumber๋ก ์ ์ฅ const submitData = { contractId, - documentNumberTypeId: Number(formData.documentNumberTypeId), - documentClassId: Number(formData.documentClassId), - title: formData.title, - docNumber: activeTab === "SHI" ? generatedDocNumber : "", // SHI๋ docNumber๋ก - vendorDocNumber: activeTab === "CPY" ? generatedDocNumber : "", // CPY๋ vendorDocNumber๋ก - fieldValues: formData.fieldValues, - planDates: formData.planDates, + documentClassId: Number(data.documentClassId), + title: data.title, + docNumber: shiDocNumber, + vendorDocNumber: cpyDocNumber, + fieldValues: { + ...data.shiFieldValues, + ...data.cpyFieldValues + }, + planDates: data.planDates || {}, } const result = await createDocument(submitData) if (result.success) { - toast.success("Document added successfully.") + toast.success('Document added successfully.') onOpenChange(false) - resetForm() + form.reset() } else { - toast.error(result.error || "Error adding document.") + toast.error(result.error || 'Error adding document.') } } catch (error) { - toast.error("Error adding document.") - } finally { - setIsSubmitting(false) + console.error('Error submitting document:', error) + toast.error('Error adding document.') } } - const resetForm = () => { - setFormData({ - documentNumberTypeId: "", - documentClassId: "", - title: "", - fieldValues: {}, - planDates: {} - }) - setSelectedTypeConfigs([]) - setComboBoxOptions({}) - setDocumentClassOptions([]) - setActiveTab("SHI") - setDataLoaded(false) - } + // Check if we have at least one type available + const hasAvailableTypes = shiType || cpyType - // ๊ณตํต ํผ ์ปดํฌ๋ํธ - const DocumentForm = () => ( - <div className="grid gap-4"> - {/* Dynamic Fields */} - {selectedTypeConfigs.length > 0 && ( - <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> - <Label className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-3 block"> - Document Number Components - </Label> - <div className="grid gap-3"> - {selectedTypeConfigs.map((config) => ( - <div key={config.id} className="grid gap-2"> - <Label className="text-sm"> - {config.codeGroup?.description || config.description} - {config.required && <span className="text-red-500 ml-1">*</span>} - {config.remark && ( - <span className="text-xs text-gray-500 dark:text-gray-400 ml-2">({config.remark})</span> - )} - </Label> + // Render field component + const renderField = ( + config: any, + fieldType: 'SHI' | 'CPY', + comboBoxOptions: Record<number, any[]> + ) => { + const fieldKey = `field_${config.sdq}` + const fieldName = fieldType === 'SHI' + ? `shiFieldValues.${fieldKey}` + : `cpyFieldValues.${fieldKey}` - {config.codeGroup?.controlType === 'combobox' ? ( - <Select - value={formData.fieldValues[`field_${config.sdq}`] || ""} - onValueChange={(value) => handleFieldValueChange(`field_${config.sdq}`, value)} - > - <SelectTrigger> - <SelectValue placeholder="Select option" /> - </SelectTrigger> - <SelectContent> - {(comboBoxOptions[config.codeGroupId!] || []).map((option) => ( - <SelectItem key={option.id} value={option.code}> - {option.code} - {option.description} - </SelectItem> - ))} - </SelectContent> - </Select> - ) : config.documentClass ? ( - <div className="p-2 bg-gray-100 dark:bg-gray-800 rounded text-sm"> - {config.documentClass.code} - {config.documentClass.description} - </div> - ) : ( - <Input - value={formData.fieldValues[`field_${config.sdq}`] || ""} - onChange={(e) => handleFieldValueChange(`field_${config.sdq}`, e.target.value)} - placeholder="Enter value" - /> - )} - </div> - ))} - </div> - - {/* Document Number Preview */} - <div className="mt-3 p-2 bg-white dark:bg-gray-900 border rounded"> - <Label className="text-xs text-gray-600 dark:text-gray-400"> - {activeTab === "SHI" ? "Document Number" : "Project Document Number"} Preview: - </Label> - <div className="font-mono text-sm font-medium text-blue-600 dark:text-blue-400"> - {generatePreviewDocNumber()} - </div> - </div> - </div> - )} - - {/* Document Class Selection */} - <div className="grid gap-2"> - <Label htmlFor="documentClassId"> - Document Class <span className="text-red-500">*</span> + return ( + <div key={config.id} className="grid gap-2"> + <Label className="text-sm"> + {config.codeGroup?.description || config.description} + {config.required && <span className="text-red-500 ml-1">*</span>} + {config.remark && ( + <span className="text-xs text-gray-500 dark:text-gray-400 ml-2"> + ({config.remark}) + </span> + )} </Label> - <Select - value={formData.documentClassId} - onValueChange={handleDocumentClassChange} - > - <SelectTrigger> - <SelectValue placeholder="Select document class" /> - </SelectTrigger> - <SelectContent> - {documentClasses.map((cls) => ( - <SelectItem key={cls.id} value={String(cls.id)}> - {cls.value} - </SelectItem> - ))} - </SelectContent> - </Select> - {formData.documentClassId && ( - <p className="text-xs text-gray-600 dark:text-gray-400"> - Options from the selected class will be automatically created as stages. - </p> - )} - </div> - {/* Document Class Options with Plan Dates */} - {documentClassOptions.length > 0 && ( - <div className="border rounded-lg p-4 bg-green-50/30 dark:bg-green-950/30"> - <Label className="text-sm font-medium text-green-800 dark:text-green-200 mb-3 block"> - Document Class Stages with Plan Dates - </Label> - <div className="grid gap-3"> - {documentClassOptions.map((option) => ( - <div key={option.id} className="grid grid-cols-2 gap-3 items-center"> - <div> - <Label className="text-sm font-medium"> - {option.optionValue} - </Label> - {option.optionCode && ( - <p className="text-xs text-gray-500 dark:text-gray-400">Code: {option.optionCode}</p> - )} - </div> - <div className="grid gap-1"> - <Label className="text-xs text-gray-600 dark:text-gray-400">Plan Date</Label> - <Input - type="date" - value={formData.planDates[option.id] || ""} - onChange={(e) => handlePlanDateChange(option.id, e.target.value)} - className="text-sm" - /> + <Controller + name={fieldName as any} + control={form.control} + rules={{ required: config.required }} + render={({ field }) => { + if (config.codeGroup?.controlType === 'combobox') { + return ( + <Select value={field.value || ''} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="Select option" /> + </SelectTrigger> + <SelectContent> + {(comboBoxOptions[config.codeGroupId!] || []).map((option) => ( + <SelectItem key={option.id} value={option.code}> + {option.code} - {option.description} + </SelectItem> + ))} + </SelectContent> + </Select> + ) + } else if (config.documentClass) { + return ( + <div className="p-2 bg-gray-100 dark:bg-gray-800 rounded text-sm"> + {config.documentClass.code} - {config.documentClass.description} </div> - </div> - ))} - </div> - </div> - )} - - {/* Document Title */} - <div className="grid gap-2"> - <Label htmlFor="title"> - Document Title <span className="text-red-500">*</span> - </Label> - <Input - id="title" - value={formData.title} - onChange={(e) => setFormData({ ...formData, title: e.target.value })} - placeholder="Enter document title" + ) + } else { + return ( + <Input + {...field} + value={field.value || ''} + placeholder="Enter value" + /> + ) + } + }} /> </div> - </div> - ) + ) + } - // ๋ก๋ฉ ์ค์ด๊ฑฐ๋ ๋ฐ์ดํฐ ์ฒดํฌ ์ค์ผ ๋ ํ์ if (isLoadingInitialData) { return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col"> + <DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col"> <div className="flex items-center justify-center py-8 flex-1"> <Loader2 className="h-8 w-8 animate-spin" /> </div> @@ -525,98 +481,239 @@ React.useEffect(() => { ) } - return ( -<Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col overflow-hidden"> - <DialogHeader className="flex-shrink-0"> - <DialogTitle>Add New Document</DialogTitle> - <DialogDescription> - Enter the basic information for the new document. - </DialogDescription> - </DialogHeader> - - {!shiType && !cpyType ? ( - <div className="flex-1 flex items-center justify-center"> - <Alert className="max-w-md"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - Required Document Number Type (SHI, CPY) is not configured. Please configure it first in the Number Types management. - </AlertDescription> - </Alert> - </div> - ) : ( - <> - <Tabs - value={activeTab} - onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")} - className="flex-1 min-h-0 flex flex-col" - > - {/* ๊ณ ์ ์์ญ */} - <TabsList className="grid w-full grid-cols-2 flex-shrink-0"> - <TabsTrigger value="SHI" disabled={!shiType}> - SHI (Document No.) - {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />} - </TabsTrigger> - <TabsTrigger value="CPY" disabled={!cpyType}> - CPY (Project Document No.) - {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />} - </TabsTrigger> - </TabsList> - - {/* ์คํฌ๋กค ์์ญ */} - <div className="flex-1 min-h-0 mt-4 overflow-y-auto pr-2"> - <TabsContent - value="SHI" - className="data-[state=inactive]:hidden" - > - {shiType ? ( - <DocumentForm /> - ) : ( - <Alert> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - SHI Document Number Type is not configured. - </AlertDescription> - </Alert> + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col overflow-hidden"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>Add New Document</DialogTitle> + <DialogDescription> + Enter the document information and generate document numbers. + </DialogDescription> + </DialogHeader> + + {!hasAvailableTypes ? ( + <div className="flex-1 flex items-center justify-center"> + <Alert className="max-w-md"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + Required Document Number Type (SHI, CPY) is not configured. + Please configure it first in the Number Types management. + </AlertDescription> + </Alert> + </div> + ) : ( + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0"> + <div className="flex-1 overflow-y-auto pr-2 space-y-4"> + + {/* SHI Document Number Card */} + {shiType && ( + <Card className="border-blue-200 dark:border-blue-800"> + <CardHeader className="pb-4"> + <CardTitle className="flex items-center gap-2 text-lg"> + <FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" /> + SHI Document Number + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + {shiTypeConfigs.length > 0 ? ( + <> + <div className="grid gap-3"> + {shiTypeConfigs.map((config) => + renderField(config, 'SHI', shiComboBoxOptions) + )} + </div> + + {/* SHI Preview */} + <div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded"> + <Label className="text-xs text-blue-700 dark:text-blue-300"> + Document Number Preview: + </Label> + <div className="font-mono text-sm font-medium text-blue-800 dark:text-blue-200 mt-1"> + {generateShiPreview()} + </div> + </div> + </> + ) : ( + <div className="text-sm text-gray-500"> + Loading SHI configuration... + </div> + )} + </CardContent> + </Card> )} - </TabsContent> - <TabsContent - value="CPY" - className="data-[state=inactive]:hidden" - > - {cpyType ? ( - <DocumentForm /> - ) : ( - <Alert> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - CPY Document Number Type is not configured. - </AlertDescription> - </Alert> + {/* CPY Project Document Number Card */} + {cpyType && ( + <Card className="border-green-200 dark:border-green-800"> + <CardHeader className="pb-4"> + <CardTitle className="flex items-center gap-2 text-lg"> + <FolderOpen className="h-5 w-5 text-green-600 dark:text-green-400" /> + CPY Project Document Number + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + {cpyTypeConfigs.length > 0 ? ( + <> + <div className="grid gap-3"> + {cpyTypeConfigs.map((config) => + renderField(config, 'CPY', cpyComboBoxOptions) + )} + </div> + + {/* CPY Preview */} + <div className="mt-3 p-3 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded"> + <Label className="text-xs text-green-700 dark:text-green-300"> + Project Document Number Preview: + </Label> + <div className="font-mono text-sm font-medium text-green-800 dark:text-green-200 mt-1"> + {generateCpyPreview()} + </div> + </div> + </> + ) : ( + <div className="text-sm text-gray-500"> + Loading CPY configuration... + </div> + )} + </CardContent> + </Card> )} - </TabsContent> - </div> - </Tabs> - <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4"> - <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> - Cancel - </Button> - <Button - onClick={handleSubmit} - disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)} - > - {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} - Add Document - </Button> - </DialogFooter> - </> - )} - </DialogContent> -</Dialog> + {/* Document Class Selection */} + <div className="space-y-2"> + <Label htmlFor="documentClassId"> + Document Class <span className="text-red-500">*</span> + </Label> + <Controller + name="documentClassId" + control={form.control} + render={({ field }) => ( + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="Select document class" /> + </SelectTrigger> + <SelectContent> + {documentClasses.map((cls) => ( + <SelectItem key={cls.id} value={String(cls.id)}> + {cls.value} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + /> + {form.formState.errors.documentClassId && ( + <p className="text-xs text-red-500"> + {form.formState.errors.documentClassId.message} + </p> + )} + {documentClassId && ( + <p className="text-xs text-gray-600 dark:text-gray-400"> + Options from the selected class will be automatically created as stages. + </p> + )} + </div> + + {/* Document Class Options with Plan Dates */} + {documentClassOptions.length > 0 && ( + <Card> + <CardHeader className="pb-4"> + <CardTitle className="text-base">Document Stages with Plan Dates</CardTitle> + </CardHeader> + <CardContent> + <div className="grid gap-3"> + {documentClassOptions.map((option) => ( + <div key={option.id} className="grid grid-cols-2 gap-3 items-center"> + <div> + <Label className="text-sm font-medium"> + {option.optionValue} + </Label> + {option.optionCode && ( + <p className="text-xs text-gray-500 dark:text-gray-400"> + Code: {option.optionCode} + </p> + )} + </div> + <div className="grid gap-1"> + <Label className="text-xs text-gray-600 dark:text-gray-400"> + Plan Date + </Label> + <Controller + name={`planDates.${option.id}`} + control={form.control} + render={({ field }) => ( + <Input + type="date" + {...field} + value={field.value || ''} + className="text-sm" + /> + )} + /> + </div> + </div> + ))} + </div> + </CardContent> + </Card> + )} + + {/* Document Title */} + <div className="space-y-2"> + <Label htmlFor="title"> + Document Title <span className="text-red-500">*</span> + </Label> + <Controller + name="title" + control={form.control} + render={({ field }) => ( + <Input + {...field} + id="title" + placeholder="Enter document title" + /> + )} + /> + {form.formState.errors.title && ( + <p className="text-xs text-red-500"> + {form.formState.errors.title.message} + </p> + )} + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={form.formState.isSubmitting} + > + Cancel + </Button> + <Button + type="submit" + disabled={ + form.formState.isSubmitting || + !hasAvailableTypes || + (shiType && !isShiComplete()) || + (cpyType && !isCpyComplete()) + } + > + {form.formState.isSubmitting ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : null} + Add Document + </Button> + </DialogFooter> + </form> + )} + </DialogContent> + </Dialog> ) } + + // ============================================================================= // Edit Document Dialog (with improved stage plan date editing) // ============================================================================= @@ -690,7 +787,7 @@ export function EditDocumentDialog({ setIsLoading(true) try { const result = await updateDocument({ - documentId: document.id, + documentId: document.documentId, title: formData.title, vendorDocNumber: formData.vendorDocNumber, stagePlanDates: formData.stagePlanDates, @@ -1019,361 +1116,6 @@ export function EditStageDialog({ </Dialog> ) } -// ============================================================================= -// 4. Excel Import Dialog -// ============================================================================= -interface ExcelImportDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - contractId: number - projectType: "ship" | "plant" -} - -interface ImportResult { - documents: any[] - stages: any[] - errors: string[] - warnings: string[] -} - -export function ExcelImportDialog({ - open, - onOpenChange, - contractId, - projectType -}: ExcelImportDialogProps) { - const [file, setFile] = React.useState<File | null>(null) - const [isProcessing, setIsProcessing] = React.useState(false) - const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) - const [importResult, setImportResult] = React.useState<ImportResult | null>(null) - const [processStep, setProcessStep] = React.useState<string>("") - const [progress, setProgress] = React.useState(0) - const router = useRouter() - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const selectedFile = e.target.files?.[0] - if (selectedFile) { - // ํ์ผ ์ ํจ์ฑ ๊ฒ์ฌ - if (!validateFileExtension(selectedFile)) { - toast.error("Excel ํ์ผ(.xlsx, .xls)๋ง ์
๋ก๋ ๊ฐ๋ฅํฉ๋๋ค.") - return - } - - if (!validateFileSize(selectedFile, 10)) { - toast.error("ํ์ผ ํฌ๊ธฐ๋ 10MB ์ดํ์ฌ์ผ ํฉ๋๋ค.") - return - } - - setFile(selectedFile) - setImportResult(null) - } - } - - const validateFileExtension = (file: File): boolean => { - const allowedExtensions = ['.xlsx', '.xls'] - const fileName = file.name.toLowerCase() - return allowedExtensions.some(ext => fileName.endsWith(ext)) - } - - const validateFileSize = (file: File, maxSizeMB: number): boolean => { - const maxSizeBytes = maxSizeMB * 1024 * 1024 - return file.size <= maxSizeBytes - } - - // ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ - const handleDownloadTemplate = async () => { - setIsDownloadingTemplate(true) - try { - const workbook = await createImportTemplate(projectType, contractId) - const buffer = await workbook.xlsx.writeBuffer() - - const blob = new Blob([buffer], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }) - - const url = window.URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `๋ฌธ์_์ํฌํธ_ํ
ํ๋ฆฟ_${projectType}_${new Date().toISOString().split('T')[0]}.xlsx` - link.click() - - window.URL.revokeObjectURL(url) - toast.success("ํ
ํ๋ฆฟ ํ์ผ์ด ๋ค์ด๋ก๋๋์์ต๋๋ค.") - } catch (error) { - toast.error("ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: " + (error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ")) - } finally { - setIsDownloadingTemplate(false) - } - } - - // ์์
ํ์ผ ์ฒ๋ฆฌ - const handleImport = async () => { - if (!file) { - toast.error("ํ์ผ์ ์ ํํด์ฃผ์ธ์.") - return - } - - setIsProcessing(true) - setProgress(0) - - try { - setProcessStep("ํ์ผ ์ฝ๋ ์ค...") - setProgress(20) - - const workbook = new ExcelJS.Workbook() - const buffer = await file.arrayBuffer() - await workbook.xlsx.load(buffer) - - setProcessStep("๋ฐ์ดํฐ ๊ฒ์ฆ ์ค...") - setProgress(40) - - // ์ํฌ์ํธ ํ์ธ - const documentsSheet = workbook.getWorksheet('Documents') || workbook.getWorksheet(1) - const stagesSheet = workbook.getWorksheet('Stage Plan Dates') || workbook.getWorksheet(2) - - if (!documentsSheet) { - throw new Error("Documents ์ํธ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.") - } - - setProcessStep("๋ฌธ์ ๋ฐ์ดํฐ ํ์ฑ ์ค...") - setProgress(60) - - // ๋ฌธ์ ๋ฐ์ดํฐ ํ์ฑ - const documentData = await parseDocumentsSheet(documentsSheet, projectType) - - setProcessStep("์คํ
์ด์ง ๋ฐ์ดํฐ ํ์ฑ ์ค...") - setProgress(80) - - // ์คํ
์ด์ง ๋ฐ์ดํฐ ํ์ฑ (์ ํ์ฌํญ) - let stageData: any[] = [] - if (stagesSheet) { - stageData = await parseStagesSheet(stagesSheet) - } - - setProcessStep("์๋ฒ์ ์
๋ก๋ ์ค...") - setProgress(90) - - // ์๋ฒ๋ก ๋ฐ์ดํฐ ์ ์ก - const result = await uploadImportData({ - contractId, - documents: documentData.validData, - stages: stageData, - projectType - }) - - if (result.success) { - setImportResult({ - documents: documentData.validData, - stages: stageData, - errors: documentData.errors, - warnings: result.warnings || [] - }) - setProgress(100) - toast.success(`${documentData.validData.length}๊ฐ ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค.`) - } else { - throw new Error(result.error || "์ํฌํธ์ ์คํจํ์ต๋๋ค.") - } - - } catch (error) { - toast.error(error instanceof Error ? error.message : "์ํฌํธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.") - setImportResult({ - documents: [], - stages: [], - errors: [error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ"], - warnings: [] - }) - } finally { - setIsProcessing(false) - setProcessStep("") - setProgress(0) - } - } - - const handleClose = () => { - setFile(null) - setImportResult(null) - setProgress(0) - setProcessStep("") - onOpenChange(false) - } - - const handleConfirmImport = () => { - // ํ์ด์ง ์๋ก๊ณ ์นจํ์ฌ ๋ฐ์ดํฐ ๊ฐฑ์ - router.refresh() - handleClose() - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col"> - <DialogHeader className="flex-shrink-0"> - <DialogTitle> - <FileSpreadsheet className="inline w-5 h-5 mr-2" /> - Excel ํ์ผ ์ํฌํธ - </DialogTitle> - <DialogDescription> - Excel ํ์ผ์ ์ฌ์ฉํ์ฌ ๋ฌธ์๋ฅผ ์ผ๊ด ๋ฑ๋กํฉ๋๋ค. - </DialogDescription> - </DialogHeader> - - <div className="flex-1 overflow-y-auto pr-2"> - <div className="grid gap-4 py-4"> - {/* ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์น์
*/} - <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> - <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. ํ
ํ๋ฆฟ ๋ค์ด๋ก๋</h4> - <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> - ์ฌ๋ฐ๋ฅธ ํ์๊ณผ ๋๋กญ๋ค์ด์ด ์ ์ฉ๋ ํ
ํ๋ฆฟ์ ๋ค์ด๋ก๋ํ์ธ์. - </p> - <Button - variant="outline" - size="sm" - onClick={handleDownloadTemplate} - disabled={isDownloadingTemplate} - > - {isDownloadingTemplate ? ( - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - ) : ( - <Download className="h-4 w-4 mr-2" /> - )} - ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ - </Button> - </div> - - {/* ํ์ผ ์
๋ก๋ ์น์
*/} - <div className="border rounded-lg p-4"> - <h4 className="font-medium mb-2">2. ํ์ผ ์
๋ก๋</h4> - <div className="grid gap-2"> - <Label htmlFor="excel-file">Excel ํ์ผ ์ ํ</Label> - <Input - id="excel-file" - type="file" - accept=".xlsx,.xls" - onChange={handleFileChange} - disabled={isProcessing} - className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" - /> - {file && ( - <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> - ์ ํ๋ ํ์ผ: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) - </p> - )} - </div> - </div> - - {/* ์งํ ์ํ */} - {isProcessing && ( - <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> - <div className="flex items-center gap-2 mb-2"> - <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> - <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">์ฒ๋ฆฌ ์ค...</span> - </div> - <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> - <Progress value={progress} className="h-2" /> - </div> - )} - - {/* ์ํฌํธ ๊ฒฐ๊ณผ */} - {importResult && ( - <div className="space-y-3"> - {importResult.documents.length > 0 && ( - <Alert> - <CheckCircle className="h-4 w-4" /> - <AlertDescription> - <strong>{importResult.documents.length}๊ฐ</strong> ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค. - {importResult.stages.length > 0 && ( - <> ({importResult.stages.length}๊ฐ ์คํ
์ด์ง ๊ณํ๋ ์ง ํฌํจ)</> - )} - </AlertDescription> - </Alert> - )} - - {importResult.warnings.length > 0 && ( - <Alert> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - <strong>๊ฒฝ๊ณ :</strong> - <ul className="mt-1 list-disc list-inside"> - {importResult.warnings.map((warning, index) => ( - <li key={index} className="text-sm">{warning}</li> - ))} - </ul> - </AlertDescription> - </Alert> - )} - - {importResult.errors.length > 0 && ( - <Alert variant="destructive"> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - <strong>์ค๋ฅ:</strong> - <ul className="mt-1 list-disc list-inside"> - {importResult.errors.map((error, index) => ( - <li key={index} className="text-sm">{error}</li> - ))} - </ul> - </AlertDescription> - </Alert> - )} - </div> - )} - - {/* ํ์ผ ํ์ ๊ฐ์ด๋ */} - <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> - <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">ํ์ผ ํ์ ๊ฐ์ด๋</h4> - <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> - <p><strong>Documents ์ํธ:</strong></p> - <ul className="ml-4 list-disc"> - <li>Document Number* (๋ฌธ์๋ฒํธ)</li> - <li>Document Name* (๋ฌธ์๋ช
)</li> - <li>Document Class* (๋ฌธ์ํด๋์ค - ๋๋กญ๋ค์ด ์ ํ)</li> - {projectType === "plant" && ( - <li>Project Doc No. (๋ฒค๋๋ฌธ์๋ฒํธ)</li> - )} - </ul> - <p className="mt-2"><strong>Stage Plan Dates ์ํธ (์ ํ์ฌํญ):</strong></p> - <ul className="ml-4 list-disc"> - <li>Document Number* (๋ฌธ์๋ฒํธ)</li> - <li>Stage Name* (์คํ
์ด์ง๋ช
- ๋๋กญ๋ค์ด ์ ํ, ํด๋น ๋ฌธ์ํด๋์ค์ ๋ง๋ ์คํ
์ด์ง๋ง ์ ํ)</li> - <li>Plan Date (๊ณํ๋ ์ง: YYYY-MM-DD)</li> - </ul> - <p className="mt-2 text-green-600 dark:text-green-400"><strong>์ค๋งํธ ๊ธฐ๋ฅ:</strong></p> - <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> - <li>Document Class๋ ๋๋กญ๋ค์ด์ผ๋ก ์ ํํ ๊ฐ๋ง ์ ํ ๊ฐ๋ฅ</li> - <li>Stage Name๋ ๋๋กญ๋ค์ด์ผ๋ก ์คํ ๋ฐฉ์ง</li> - <li>"์ฌ์ฉ ๊ฐ์ด๋" ์ํธ์์ ๊ฐ ํด๋์ค๋ณ ์ฌ์ฉ ๊ฐ๋ฅํ ์คํ
์ด์ง ํ์ธ ๊ฐ๋ฅ</li> - </ul> - <p className="mt-2 text-red-600 dark:text-red-400">* ํ์ ํญ๋ชฉ</p> - </div> - </div> - </div> - </div> - - <DialogFooter className="flex-shrink-0"> - <Button variant="outline" onClick={handleClose}> - {importResult ? "๋ซ๊ธฐ" : "์ทจ์"} - </Button> - {!importResult ? ( - <Button - onClick={handleImport} - disabled={!file || isProcessing} - > - {isProcessing ? ( - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - ) : ( - <Upload className="h-4 w-4 mr-2" /> - )} - {isProcessing ? "์ฒ๋ฆฌ ์ค..." : "์ํฌํธ ์์"} - </Button> - ) : importResult.documents.length > 0 ? ( - <Button onClick={handleConfirmImport}> - ์๋ฃ ๋ฐ ์๋ก๊ณ ์นจ - </Button> - ) : null} - </DialogFooter> - </DialogContent> - </Dialog> - ) -} // ============================================================================= // 5. Delete Documents Confirmation Dialog @@ -1399,6 +1141,7 @@ export function DeleteDocumentsDialog({ function onDelete() { startDeleteTransition(async () => { + const { error } = await deleteDocuments({ ids: documents.map((document) => document.documentId), }) @@ -1500,223 +1243,3 @@ export function DeleteDocumentsDialog({ ) } -// ============================================================================= -// Helper Functions for Excel Import -// ============================================================================= - -// ExcelJS ์ปฌ๋ผ ์ธ๋ฑ์ค๋ฅผ ๋ฌธ์๋ก ๋ณํ (A, B, C, ... Z, AA, AB, ...) -function getExcelColumnName(index: number): string { - let result = "" - while (index > 0) { - index-- - result = String.fromCharCode(65 + (index % 26)) + result - index = Math.floor(index / 26) - } - return result -} - -// ํค๋ ํ ์คํ์ผ๋ง ํจ์ -function styleHeaderRow(headerRow: ExcelJS.Row, bgColor: string = 'FF4472C4') { - headerRow.eachCell((cell) => { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: bgColor } - } - cell.font = { - color: { argb: 'FFFFFFFF' }, - bold: true - } - cell.alignment = { - horizontal: 'center', - vertical: 'middle' - } - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - if (String(cell.value).includes('*')) { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE74C3C' } - } - } - }) -} - -// ํ
ํ๋ฆฟ ์์ฑ ํจ์ -async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { - const res = await getDocumentClassOptionsByContract(contractId); - if (!res.success) throw new Error(res.error || "๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ"); - - const documentClasses = res.data.classes; // [{id, code, description}] - const options = res.data.options; // [{documentClassId, optionValue, ...}] - - // ํด๋์ค๋ณ ์ต์
๋งต - const optionsByClassId = new Map<number, string[]>(); - for (const c of documentClasses) optionsByClassId.set(c.id, []); - for (const o of options) { - optionsByClassId.get(o.documentClassId)?.push(o.optionValue); - } - - // ๋ชจ๋ ์คํ
์ด์ง ๋ช
(์ ๋ํฌ) - const allStageNames = Array.from(new Set(options.map(o => o.optionValue))); - - const workbook = new ExcelJS.Workbook(); - - // ================= ReferenceData (hidden) ================= - const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }); - - // A์ด: DocumentClasses - referenceSheet.getCell("A1").value = "DocumentClasses"; - documentClasses.forEach((docClass, i) => { - referenceSheet.getCell(`A${i + 2}`).value = `${docClass.description}`; - }); - - // B์ด๋ถํฐ: ๊ฐ ํด๋์ค์ Stage ์ต์
- let currentCol = 2; // B - for (const docClass of documentClasses) { - const colLetter = getExcelColumnName(currentCol); - referenceSheet.getCell(`${colLetter}1`).value = docClass.description; - - const list = optionsByClassId.get(docClass.id) ?? []; - list.forEach((v, i) => { - referenceSheet.getCell(`${colLetter}${i + 2}`).value = v; - }); - - currentCol++; - } - - // ๋ง์ง๋ง ์ด: AllStageNames - const allStagesCol = getExcelColumnName(currentCol); - referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames"; - allStageNames.forEach((v, i) => { - referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v; - }); - - // ================= Documents ================= - const documentsSheet = workbook.addWorksheet("Documents"); - const documentHeaders = [ - "Document Number*", - "Document Name*", - "Document Class*", - ...(projectType === "plant" ? ["Project Doc No."] : []), - "Notes", - ]; - const documentHeaderRow = documentsSheet.addRow(documentHeaders); - styleHeaderRow(documentHeaderRow); - - const sampleDocumentData = - projectType === "ship" - ? [ - "SH-2024-001", - "๊ธฐ๋ณธ ์ค๊ณ ๋๋ฉด", - documentClasses[0] - ? `${documentClasses[0].description}` - : "", - "์ฐธ๊ณ ์ฌํญ", - ] - : [ - "PL-2024-001", - "๊ณต์ ์ค๊ณ ๋๋ฉด", - documentClasses[0] - ? `${documentClasses[0].description}` - : "", - "V-001", - "์ฐธ๊ณ ์ฌํญ", - ]; - - documentsSheet.addRow(sampleDocumentData); - - // Document Class ๋๋กญ๋ค์ด - const docClassColIndex = 3; // C - const docClassCol = getExcelColumnName(docClassColIndex); - documentsSheet.dataValidations.add(`${docClassCol}2:${docClassCol}1000`, { - type: "list", - allowBlank: false, - formulae: [`ReferenceData!$A$2:$A$${documentClasses.length + 1}`], - }); - - documentsSheet.columns = [ - { width: 15 }, - { width: 25 }, - { width: 28 }, - ...(projectType === "plant" ? [{ width: 18 }] : []), - { width: 24 }, - ]; - - // ================= Stage Plan Dates ================= - const stagesSheet = workbook.addWorksheet("Stage Plan Dates"); - const stageHeaderRow = stagesSheet.addRow(["Document Number*", "Stage Name*", "Plan Date"]); - styleHeaderRow(stageHeaderRow, "FF27AE60"); - - const firstClass = documentClasses[0]; - const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : []; - - const sampleStageData = [ - [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[0] ?? "", "2024-02-15"], - [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[1] ?? "", "2024-03-01"], - ]; - - sampleStageData.forEach(row => { - const r = stagesSheet.addRow(row); - r.getCell(3).numFmt = "yyyy-mm-dd"; - }); - - // Stage Name ๋๋กญ๋ค์ด (์ ์ฒด) - stagesSheet.dataValidations.add("B2:B1000", { - type: "list", - allowBlank: false, - formulae: [`ReferenceData!$${allStagesCol}$2:$${allStagesCol}$${allStageNames.length + 1}`], - promptTitle: "Stage Name ์ ํ", - prompt: "Document์ Document Class์ ํด๋นํ๋ Stage Name์ ์ ํํ์ธ์.", - }); - - stagesSheet.columns = [{ width: 15 }, { width: 30 }, { width: 12 }]; - - // ================= ์ฌ์ฉ ๊ฐ์ด๋ ================= - const guideSheet = workbook.addWorksheet("์ฌ์ฉ ๊ฐ์ด๋"); - const guideContent: (string[])[] = [ - ["๋ฌธ์ ์ํฌํธ ๊ฐ์ด๋"], - [""], - ["1. Documents ์ํธ"], - [" - Document Number*: ๊ณ ์ ํ ๋ฌธ์ ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์"], - [" - Document Name*: ๋ฌธ์๋ช
์ ์
๋ ฅํ์ธ์"], - [" - Document Class*: ๋๋กญ๋ค์ด์์ ๋ฌธ์ ํด๋์ค๋ฅผ ์ ํํ์ธ์"], - [" - Project Doc No.: ๋ฒค๋ ๋ฌธ์ ๋ฒํธ"], - [" - Notes: ์ฐธ๊ณ ์ฌํญ"], - [""], - ["2. Stage Plan Dates ์ํธ (์ ํ์ฌํญ)"], - [" - Document Number*: Documents ์ํธ์ Document Number์ ์ผ์นํด์ผ ํฉ๋๋ค"], - [" - Stage Name*: ๋๋กญ๋ค์ด์์ ํด๋น ๋ฌธ์ ํด๋์ค์ ๋ง๋ ์คํ
์ด์ง๋ช
์ ์ ํํ์ธ์"], - [" - Plan Date: ๊ณํ ๋ ์ง (YYYY-MM-DD ํ์)"], - [""], - ["3. ์ฃผ์์ฌํญ"], - [" - * ํ์๋ ํ์ ํญ๋ชฉ์
๋๋ค"], - [" - Document Number๋ ๊ณ ์ ํด์ผ ํฉ๋๋ค"], - [" - Stage Name์ ํด๋น Document์ Document Class์ ์ํ ๊ฒ๋ง ์ ํจํฉ๋๋ค"], - [" - ๋ ์ง๋ YYYY-MM-DD ํ์์ผ๋ก ์
๋ ฅํ์ธ์"], - [""], - ["4. Document Class๋ณ ์ฌ์ฉ ๊ฐ๋ฅํ Stage Names"], - [""], - ]; - - for (const c of documentClasses) { - guideContent.push([`${c.code} - ${c.description}:`]); - (optionsByClassId.get(c.id) ?? []).forEach(v => guideContent.push([` โข ${v}`])); - guideContent.push([""]); - } - - guideContent.forEach((row, i) => { - const r = guideSheet.addRow(row); - if (i === 0) r.getCell(1).font = { bold: true, size: 14 }; - else if (row[0] && row[0].includes(":") && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true }; - }); - guideSheet.getColumn(1).width = 60; - - return workbook; -}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx index ccb9e15c..f676e1fc 100644 --- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -12,13 +12,13 @@ import { Button } from "@/components/ui/button" import { DeleteDocumentsDialog, AddDocumentDialog, - ExcelImportDialog } from "./document-stage-dialogs" import { sendDocumentsToSHI } from "./document-stages-service" import { useDocumentPolling } from "@/hooks/use-document-polling" import { cn } from "@/lib/utils" import { MultiUploadDialog } from "./upload/components/multi-upload-dialog" import { useRouter } from "next/navigation" +import { ExcelImportDialog } from "./excel-import-stage" // ์๋ฒ ์ก์
import (ํ์ํ ๊ฒฝ์ฐ) // import { importDocumentsExcel } from "./document-stages-service" diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index 0b85c3f8..d71ecc0f 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -347,166 +347,166 @@ export function getDocumentStagesColumns({ }, }, - { - accessorKey: "buyerSystemStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="SHI Status" /> - ), - cell: ({ row }) => { - const doc = row.original - const getBuyerStatusBadge = () => { - if (!doc.buyerSystemStatus) { - return <Badge variant="outline">Not Recieved</Badge> - } + // { + // accessorKey: "buyerSystemStatus", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="SHI Status" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // const getBuyerStatusBadge = () => { + // if (!doc.buyerSystemStatus) { + // return <Badge variant="outline">Not Recieved</Badge> + // } - switch (doc.buyerSystemStatus) { - case '์น์ธ(DC)': - return <Badge variant="success">Approved</Badge> - case '๊ฒํ ์ค': - return <Badge variant="default">๊ฒํ ์ค</Badge> - case '๋ฐ๋ ค': - return <Badge variant="destructive">๋ฐ๋ ค</Badge> - default: - return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge> - } - } + // switch (doc.buyerSystemStatus) { + // case '์น์ธ(DC)': + // return <Badge variant="success">Approved</Badge> + // case '๊ฒํ ์ค': + // return <Badge variant="default">๊ฒํ ์ค</Badge> + // case '๋ฐ๋ ค': + // return <Badge variant="destructive">๋ฐ๋ ค</Badge> + // default: + // return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge> + // } + // } - return ( - <div className="flex flex-col gap-1"> - {getBuyerStatusBadge()} - {doc.buyerSystemComment && ( - <Tooltip> - <TooltipTrigger> - <MessageSquare className="h-3 w-3 text-muted-foreground" /> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs">{doc.buyerSystemComment}</p> - </TooltipContent> - </Tooltip> - )} - </div> - ) - }, - size: 120, - }, - { - accessorKey: "currentStageName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Current Stage" /> - ), - cell: ({ row }) => { - const doc = row.original - // if (!doc.currentStageName) { - // return ( - // <Button - // size="sm" - // variant="outline" - // onClick={(e) => { - // e.stopPropagation() - // setRowAction({ row, type: "add_stage" }) - // }} - // className="h-6 text-xs" - // > - // <Plus className="w-3 h-3 mr-1" /> - // Add stage - // </Button> - // ) - // } - - return ( - <div className="flex items-center gap-2"> - <span className="text-sm font-medium truncate" title={doc.currentStageName}> - {doc.currentStageName} - </span> - <Badge - variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} - className="text-xs px-1.5 py-0" - > - {getStatusText(doc.currentStageStatus || '')} - </Badge> - {doc.currentStageAssigneeName && ( - <span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> - <User className="w-3 h-3" /> - {doc.currentStageAssigneeName} - </span> - )} - </div> - ) - }, - size: 180, - enableResizing: true, - meta: { - excelHeader: "Current Stage" - }, - }, - - // ๊ณํ ์ผ์ (ํ ์ค) - { - accessorKey: "currentStagePlanDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Plan Date" /> - ), - cell: ({ row }) => { - const doc = row.original - if (!doc.currentStagePlanDate) return <span className="text-gray-400 dark:text-gray-500">-</span> - - return ( - <div className="flex items-center gap-2"> - <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span> - <DueDateInfo - daysUntilDue={doc.daysUntilDue} - isOverdue={doc.isOverdue || false} - /> - </div> - ) - }, - size: 150, - enableResizing: true, - meta: { - excelHeader: "Plan Date" - }, - }, - - // ์ฐ์ ์์ + ์งํ๋ฅ (์ฝคํฉํธ) - { - accessorKey: "progressPercentage", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Priority/Progress" /> - ), - cell: ({ row }) => { - const doc = row.original - const progress = doc.progressPercentage || 0 - const completed = doc.completedStages || 0 - const total = doc.totalStages || 0 - - return ( - <div className="flex items-center gap-2"> - {doc.currentStagePriority && ( - <Badge - variant={getPriorityColor(doc.currentStagePriority)} - className="text-xs px-1.5 py-0" - > - {getPriorityText(doc.currentStagePriority)} - </Badge> - )} - <div className="flex items-center gap-1"> - <Progress value={progress} className="w-12 h-1.5" /> - <span className="text-xs text-gray-600 dark:text-gray-400 min-w-[2rem]"> - {progress}% - </span> - </div> - <span className="text-xs text-gray-500 dark:text-gray-400"> - ({completed}/{total}) - </span> - </div> - ) - }, - size: 140, - enableResizing: true, - meta: { - excelHeader: "Progress" - }, - }, + // return ( + // <div className="flex flex-col gap-1"> + // {getBuyerStatusBadge()} + // {doc.buyerSystemComment && ( + // <Tooltip> + // <TooltipTrigger> + // <MessageSquare className="h-3 w-3 text-muted-foreground" /> + // </TooltipTrigger> + // <TooltipContent> + // <p className="max-w-xs">{doc.buyerSystemComment}</p> + // </TooltipContent> + // </Tooltip> + // )} + // </div> + // ) + // }, + // size: 120, + // }, + // { + // accessorKey: "currentStageName", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Current Stage" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // // if (!doc.currentStageName) { + // // return ( + // // <Button + // // size="sm" + // // variant="outline" + // // onClick={(e) => { + // // e.stopPropagation() + // // setRowAction({ row, type: "add_stage" }) + // // }} + // // className="h-6 text-xs" + // // > + // // <Plus className="w-3 h-3 mr-1" /> + // // Add stage + // // </Button> + // // ) + // // } + + // return ( + // <div className="flex items-center gap-2"> + // <span className="text-sm font-medium truncate" title={doc.currentStageName}> + // {doc.currentStageName} + // </span> + // <Badge + // variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} + // className="text-xs px-1.5 py-0" + // > + // {getStatusText(doc.currentStageStatus || '')} + // </Badge> + // {doc.currentStageAssigneeName && ( + // <span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> + // <User className="w-3 h-3" /> + // {doc.currentStageAssigneeName} + // </span> + // )} + // </div> + // ) + // }, + // size: 180, + // enableResizing: true, + // meta: { + // excelHeader: "Current Stage" + // }, + // }, + + // // ๊ณํ ์ผ์ (ํ ์ค) + // { + // accessorKey: "currentStagePlanDate", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Plan Date" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // if (!doc.currentStagePlanDate) return <span className="text-gray-400 dark:text-gray-500">-</span> + + // return ( + // <div className="flex items-center gap-2"> + // <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span> + // <DueDateInfo + // daysUntilDue={doc.daysUntilDue} + // isOverdue={doc.isOverdue || false} + // /> + // </div> + // ) + // }, + // size: 150, + // enableResizing: true, + // meta: { + // excelHeader: "Plan Date" + // }, + // }, + + // // ์ฐ์ ์์ + ์งํ๋ฅ (์ฝคํฉํธ) + // { + // accessorKey: "progressPercentage", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Priority/Progress" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // const progress = doc.progressPercentage || 0 + // const completed = doc.completedStages || 0 + // const total = doc.totalStages || 0 + + // return ( + // <div className="flex items-center gap-2"> + // {doc.currentStagePriority && ( + // <Badge + // variant={getPriorityColor(doc.currentStagePriority)} + // className="text-xs px-1.5 py-0" + // > + // {getPriorityText(doc.currentStagePriority)} + // </Badge> + // )} + // <div className="flex items-center gap-1"> + // <Progress value={progress} className="w-12 h-1.5" /> + // <span className="text-xs text-gray-600 dark:text-gray-400 min-w-[2rem]"> + // {progress}% + // </span> + // </div> + // <span className="text-xs text-gray-500 dark:text-gray-400"> + // ({completed}/{total}) + // </span> + // </div> + // ) + // }, + // size: 140, + // enableResizing: true, + // meta: { + // excelHeader: "Progress" + // }, + // }, // ์
๋ฐ์ดํธ ์ผ์ { diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 2c65b4e6..77a03aae 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -4,7 +4,7 @@ import { revalidatePath, revalidateTag } from "next/cache" import { redirect } from "next/navigation" import db from "@/db/db" -import {stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" +import { stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, stageIssueStages, stageDocuments, stageDocumentsView } from "@/db/schema" import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, @@ -33,6 +33,7 @@ import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { ShiBuyerSystemAPI } from "./shi-buyer-system-api" +import ExcelJS from 'exceljs' interface UpdateDocumentData { documentId: number @@ -47,13 +48,13 @@ export async function updateDocument(data: UpdateDocumentData) { try { // 1. ๋ฌธ์ ๊ธฐ๋ณธ ์ ๋ณด ์
๋ฐ์ดํธ const [updatedDocument] = await db - .update(documents) + .update(stageDocuments) .set({ title: data.title, vendorDocNumber: data.vendorDocNumber || null, updatedAt: new Date(), }) - .where(eq(documents.id, data.documentId)) + .where(eq(stageDocuments.id, data.documentId)) .returning() if (!updatedDocument) { @@ -63,12 +64,12 @@ export async function updateDocument(data: UpdateDocumentData) { // 2. ์คํ
์ด์ง๋ค์ plan date ์
๋ฐ์ดํธ const stageUpdatePromises = Object.entries(data.stagePlanDates).map(([stageId, planDate]) => { return db - .update(issueStages) + .update(stageIssueStages) .set({ planDate: planDate || null, updatedAt: new Date(), }) - .where(eq(issueStages.id, Number(stageId))) + .where(eq(stageIssueStages.id, Number(stageId))) }) await Promise.all(stageUpdatePromises) @@ -93,8 +94,8 @@ export async function deleteDocument(input: { id: number }) { const validatedData = deleteDocumentSchema.parse(input) // ๋ฌธ์ ์กด์ฌ ํ์ธ - const existingDoc = await db.query.documents.findFirst({ - where: eq(documents.id, validatedData.id) + const existingDoc = await db.query.stageDocuments.findFirst({ + where: eq(stageDocuments.id, validatedData.id) }) if (!existingDoc) { @@ -102,8 +103,8 @@ export async function deleteDocument(input: { id: number }) { } // ์ฐ๊ด๋ ์คํ
์ด์ง ํ์ธ - const relatedStages = await db.query.issueStages.findMany({ - where: eq(issueStages.documentId, validatedData.id) + const relatedStages = await db.query.stageIssueStages.findMany({ + where: eq(stageIssueStages.documentId, validatedData.id) }) if (relatedStages.length > 0) { @@ -112,16 +113,12 @@ export async function deleteDocument(input: { id: number }) { // ์ํํธ ์ญ์ (์ํ ๋ณ๊ฒฝ) await db - .update(documents) + .update(stageDocuments) .set({ status: "DELETED", updatedAt: new Date(), }) - .where(eq(documents.id, validatedData.id)) - - // ์บ์ ๋ฌดํจํ - revalidateTag(`documents-${existingDoc.contractId}`) - revalidatePath(`/contracts/${existingDoc.contractId}/documents`) + .where(eq(stageDocuments.id, validatedData.id)) return { success: true, @@ -143,16 +140,17 @@ interface DeleteDocumentsData { export async function deleteDocuments(data: DeleteDocumentsData) { try { + if (data.ids.length === 0) { return { success: false, error: "์ญ์ ํ ๋ฌธ์๊ฐ ์ ํ๋์ง ์์์ต๋๋ค." } } /* 1. ์์ฒญํ ๋ฌธ์๊ฐ ์กด์ฌํ๋์ง ํ์ธ ------------------------------------ */ const existingDocs = await db - .select({ id: documents.id, docNumber: documents.docNumber }) - .from(documents) + .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber }) + .from(stageDocuments) .where(and( - inArray(documents.id, data.ids), + inArray(stageDocuments.id, data.ids), )) if (existingDocs.length === 0) { @@ -168,9 +166,9 @@ export async function deleteDocuments(data: DeleteDocumentsData) { /* 2. ์ฐ๊ด ์คํ
์ด์ง ๊ฑด์ ํ์
(๋ก๊ทธยท๋ฉ์์ง์ฉ) --------------------------- */ const relatedStages = await db - .select({ documentId: issueStages.documentId }) - .from(issueStages) - .where(inArray(issueStages.documentId, data.ids)) + .select({ documentId: stageIssueStages.documentId }) + .from(stageIssueStages) + .where(inArray(stageIssueStages.documentId, data.ids)) const stagesToDelete = relatedStages.length @@ -178,17 +176,17 @@ export async function deleteDocuments(data: DeleteDocumentsData) { // โ> FK์ ON DELETE CASCADE ๊ฐ ์๋ค๋ฉด ์๋ต ๊ฐ๋ฅ. if (stagesToDelete > 0) { await db - .delete(issueStages) - .where(inArray(issueStages.documentId, data.ids)) + .delete(stageIssueStages) + .where(inArray(stageIssueStages.documentId, data.ids)) } /* 4. ๋ฌธ์ ํ๋ ์ญ์ --------------------------------------------------- */ const deletedDocs = await db - .delete(documents) + .delete(stageDocuments) .where(and( - inArray(documents.id, data.ids), + inArray(stageDocuments.id, data.ids), )) - .returning({ id: documents.id, docNumber: documents.docNumber }) + .returning({ id: stageDocuments.id, docNumber: stageDocuments.docNumber }) /* 5. ์บ์ ๋ฌดํจํ ------------------------------------------------------ */ @@ -225,8 +223,8 @@ export async function createStage(input: CreateStageInput) { const validatedData = createStageSchema.parse(input) // ๋ฌธ์ ์กด์ฌ ํ์ธ - const document = await db.query.documents.findFirst({ - where: eq(documents.id, validatedData.documentId) + const document = await db.query.stageDocuments.findFirst({ + where: eq(stageDocuments.id, validatedData.documentId) }) if (!document) { @@ -234,10 +232,10 @@ export async function createStage(input: CreateStageInput) { } // ์คํ
์ด์ง๋ช
์ค๋ณต ๊ฒ์ฌ - const existingStage = await db.query.issueStages.findFirst({ + const existingStage = await db.query.stageIssueStages.findFirst({ where: and( - eq(issueStages.documentId, validatedData.documentId), - eq(issueStages.stageName, validatedData.stageName) + eq(stageIssueStages.documentId, validatedData.documentId), + eq(stageIssueStages.stageName, validatedData.stageName) ) }) @@ -249,15 +247,15 @@ export async function createStage(input: CreateStageInput) { let stageOrder = validatedData.stageOrder if (stageOrder === 0 || stageOrder === undefined) { const maxOrderResult = await db - .select({ maxOrder: max(issueStages.stageOrder) }) - .from(issueStages) - .where(eq(issueStages.documentId, validatedData.documentId)) + .select({ maxOrder: max(stageIssueStages.stageOrder) }) + .from(stageIssueStages) + .where(eq(stageIssueStages.documentId, validatedData.documentId)) stageOrder = (maxOrderResult[0]?.maxOrder ?? -1) + 1 } // ์คํ
์ด์ง ์์ฑ - const [newStage] = await db.insert(issueStages).values({ + const [newStage] = await db.insert(stageIssueStages).values({ documentId: validatedData.documentId, stageName: validatedData.stageName, planDate: validatedData.planDate || null, @@ -273,10 +271,6 @@ export async function createStage(input: CreateStageInput) { updatedAt: new Date(), }).returning() - // ์บ์ ๋ฌดํจํ - revalidateTag(`documents-${document.contractId}`) - revalidateTag(`document-${validatedData.documentId}`) - revalidatePath(`/contracts/${document.contractId}/documents`) return { success: true, @@ -301,8 +295,8 @@ export async function updateStage(input: UpdateStageInput) { const validatedData = updateStageSchema.parse(input) // ์คํ
์ด์ง ์กด์ฌ ํ์ธ - const existingStage = await db.query.issueStages.findFirst({ - where: eq(issueStages.id, validatedData.id), + const existingStage = await db.query.stageIssueStages.findFirst({ + where: eq(stageIssueStages.id, validatedData.id), with: { document: true } @@ -314,10 +308,10 @@ export async function updateStage(input: UpdateStageInput) { // ์คํ
์ด์ง๋ช
์ค๋ณต ๊ฒ์ฌ (์คํ
์ด์ง๋ช
๋ณ๊ฒฝ ์) if (validatedData.stageName && validatedData.stageName !== existingStage.stageName) { - const duplicateStage = await db.query.issueStages.findFirst({ + const duplicateStage = await db.query.stageIssueStages.findFirst({ where: and( - eq(issueStages.documentId, existingStage.documentId), - eq(issueStages.stageName, validatedData.stageName) + eq(stageIssueStages.documentId, existingStage.documentId), + eq(stageIssueStages.stageName, validatedData.stageName) ) }) @@ -328,18 +322,14 @@ export async function updateStage(input: UpdateStageInput) { // ์คํ
์ด์ง ์
๋ฐ์ดํธ const [updatedStage] = await db - .update(issueStages) + .update(stageIssueStages) .set({ ...validatedData, updatedAt: new Date(), }) - .where(eq(issueStages.id, validatedData.id)) + .where(eq(stageIssueStages.id, validatedData.id)) .returning() - // ์บ์ ๋ฌดํจํ - revalidateTag(`documents-${existingStage.document.contractId}`) - revalidateTag(`document-${existingStage.documentId}`) - revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) return { success: true, @@ -364,8 +354,8 @@ export async function deleteStage(input: { id: number }) { const validatedData = deleteStageSchema.parse(input) // ์คํ
์ด์ง ์กด์ฌ ํ์ธ - const existingStage = await db.query.issueStages.findFirst({ - where: eq(issueStages.id, validatedData.id), + const existingStage = await db.query.stageIssueStages.findFirst({ + where: eq(stageIssueStages.id, validatedData.id), with: { document: true } @@ -385,28 +375,23 @@ export async function deleteStage(input: { id: number }) { // } // ์คํ
์ด์ง ์ญ์ - await db.delete(issueStages).where(eq(issueStages.id, validatedData.id)) + await db.delete(stageIssueStages).where(eq(stageIssueStages.id, validatedData.id)) // ์คํ
์ด์ง ์์ ์ฌ์ ๋ ฌ - const remainingStages = await db.query.issueStages.findMany({ - where: eq(issueStages.documentId, existingStage.documentId), - orderBy: [issueStages.stageOrder] + const remainingStages = await db.query.stageIssueStages.findMany({ + where: eq(stageIssueStages.documentId, existingStage.documentId), + orderBy: [stageIssueStages.stageOrder] }) for (let i = 0; i < remainingStages.length; i++) { if (remainingStages[i].stageOrder !== i) { await db - .update(issueStages) + .update(stageIssueStages) .set({ stageOrder: i, updatedAt: new Date() }) - .where(eq(issueStages.id, remainingStages[i].id)) + .where(eq(stageIssueStages.id, remainingStages[i].id)) } } - // ์บ์ ๋ฌดํจํ - revalidateTag(`documents-${existingStage.document.contractId}`) - revalidateTag(`document-${existingStage.documentId}`) - revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) - return { success: true, message: "์คํ
์ด์ง๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ญ์ ๋์์ต๋๋ค" @@ -432,8 +417,8 @@ export async function reorderStages(input: any) { validateStageOrder(validatedData.stages) // ๋ฌธ์ ์กด์ฌ ํ์ธ - const document = await db.query.documents.findFirst({ - where: eq(documents.id, validatedData.documentId) + const document = await db.query.stageDocuments.findFirst({ + where: eq(stageDocuments.id, validatedData.documentId) }) if (!document) { @@ -442,10 +427,10 @@ export async function reorderStages(input: any) { // ์คํ
์ด์ง๋ค์ด ํด๋น ๋ฌธ์์ ์ํ๋์ง ํ์ธ const stageIds = validatedData.stages.map(s => s.id) - const existingStages = await db.query.issueStages.findMany({ + const existingStages = await db.query.stageIssueStages.findMany({ where: and( - eq(issueStages.documentId, validatedData.documentId), - inArray(issueStages.id, stageIds) + eq(stageIssueStages.documentId, validatedData.documentId), + inArray(stageIssueStages.id, stageIds) ) }) @@ -457,19 +442,15 @@ export async function reorderStages(input: any) { await db.transaction(async (tx) => { for (const stage of validatedData.stages) { await tx - .update(issueStages) + .update(stageIssueStages) .set({ stageOrder: stage.stageOrder, updatedAt: new Date() }) - .where(eq(issueStages.id, stage.id)) + .where(eq(stageIssueStages.id, stage.id)) } }) - // ์บ์ ๋ฌดํจํ - revalidateTag(`documents-${document.contractId}`) - revalidateTag(`document-${validatedData.documentId}`) - revalidatePath(`/contracts/${document.contractId}/documents`) return { success: true, @@ -497,7 +478,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult const validatedData = bulkCreateDocumentsSchema.parse(input) const result: ExcelImportResult = { - totalRows: validatedData.documents.length, + totalRows: validatedData.stageDocuments.length, successCount: 0, failureCount: 0, errors: [], @@ -506,16 +487,15 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult // ํธ๋์ญ์
์ผ๋ก ์ผ๊ด ์ฒ๋ฆฌ await db.transaction(async (tx) => { - for (let i = 0; i < validatedData.documents.length; i++) { - const docData = validatedData.documents[i] + for (let i = 0; i < validatedData.stageDocuments.length; i++) { + const docData = validatedData.stageDocuments[i] try { // ๋ฌธ์๋ฒํธ ์ค๋ณต ๊ฒ์ฌ - const existingDoc = await tx.query.documents.findFirst({ + const existingDoc = await tx.query.stageDocuments.findFirst({ where: and( - eq(documents.contractId, validatedData.contractId), - eq(documents.docNumber, docData.docNumber), - eq(documents.status, "ACTIVE") + eq(stageDocuments.docNumber, docData.docNumber), + eq(stageDocuments.status, "ACTIVE") ) }) @@ -530,7 +510,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult } // ๋ฌธ์ ์์ฑ - const [newDoc] = await tx.insert(documents).values({ + const [newDoc] = await tx.insert(stageDocuments).values({ contractId: validatedData.contractId, docNumber: docData.docNumber, title: docData.title, @@ -566,9 +546,6 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult } }) - // ์บ์ ๋ฌดํจํ - revalidateTag(`documents-${validatedData.contractId}`) - revalidatePath(`/contracts/${validatedData.contractId}/documents`) return result @@ -586,8 +563,8 @@ export async function bulkUpdateStageStatus(input: any) { const validatedData = bulkUpdateStatusSchema.parse(input) // ์คํ
์ด์ง๋ค ์กด์ฌ ํ์ธ - const existingStages = await db.query.issueStages.findMany({ - where: inArray(issueStages.id, validatedData.stageIds), + const existingStages = await db.query.stageIssueStages.findMany({ + where: inArray(stageIssueStages.id, validatedData.stageIds), with: { document: true } }) @@ -597,20 +574,15 @@ export async function bulkUpdateStageStatus(input: any) { // ์ผ๊ด ์
๋ฐ์ดํธ await db - .update(issueStages) + .update(stageIssueStages) .set({ stageStatus: validatedData.status, actualDate: validatedData.actualDate || null, updatedAt: new Date() }) - .where(inArray(issueStages.id, validatedData.stageIds)) + .where(inArray(stageIssueStages.id, validatedData.stageIds)) // ๊ด๋ จ๋ ๊ณ์ฝ๋ค์ ์บ์ ๋ฌดํจํ - const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] - for (const contractId of contractIds) { - revalidateTag(`documents-${contractId}`) - revalidatePath(`/contracts/${contractId}/documents`) - } return { success: true, @@ -634,8 +606,8 @@ export async function bulkAssignStages(input: any) { const validatedData = bulkAssignSchema.parse(input) // ์คํ
์ด์ง๋ค ์กด์ฌ ํ์ธ - const existingStages = await db.query.issueStages.findMany({ - where: inArray(issueStages.id, validatedData.stageIds), + const existingStages = await db.query.stageIssueStages.findMany({ + where: inArray(stageIssueStages.id, validatedData.stageIds), with: { document: true } }) @@ -645,20 +617,14 @@ export async function bulkAssignStages(input: any) { // ์ผ๊ด ๋ด๋น์ ์ง์ await db - .update(issueStages) + .update(stageIssueStages) .set({ assigneeId: validatedData.assigneeId || null, assigneeName: validatedData.assigneeName || null, updatedAt: new Date() }) - .where(inArray(issueStages.id, validatedData.stageIds)) + .where(inArray(stageIssueStages.id, validatedData.stageIds)) - // ๊ด๋ จ๋ ๊ณ์ฝ๋ค์ ์บ์ ๋ฌดํจํ - const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] - for (const contractId of contractIds) { - revalidateTag(`documents-${contractId}`) - revalidatePath(`/contracts/${contractId}/documents`) - } return { success: true, @@ -689,12 +655,12 @@ export async function getDocumentNumberTypes(contractId: number) { } } - console.log(project,"project") + console.log(project, "project") const types = await db .select() .from(documentNumberTypes) - .where(and (eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId,project.projectId))) + .where(and(eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId, project.projectId))) .orderBy(asc(documentNumberTypes.name)) return { success: true, data: types } @@ -735,7 +701,7 @@ export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) .where( and( eq(documentNumberTypeConfigs.documentNumberTypeId, documentNumberTypeId), - eq(documentNumberTypeConfigs.isActive, true), + eq(documentNumberTypeConfigs.isActive, true), // eq(documentNumberTypeConfigs.projectId, project.projectId) ) ) @@ -772,9 +738,9 @@ export async function getComboBoxOptions(codeGroupId: number) { } // ๋ฌธ์ ํด๋์ค ๋ชฉ๋ก ์กฐํ -export async function getDocumentClasses(contractId:number) { +export async function getDocumentClasses(contractId: number) { try { - const projectId = await db.query.contracts.findFirst({ + const projectId = await db.query.contracts.findFirst({ where: eq(contracts.id, contractId), }); @@ -788,10 +754,10 @@ export async function getDocumentClasses(contractId:number) { .select() .from(documentClasses) .where( - and( - eq(documentClasses.isActive, true), - eq(documentClasses.projectId, projectId.projectId) - ) + and( + eq(documentClasses.isActive, true), + eq(documentClasses.projectId, projectId.projectId) + ) ) .orderBy(asc(documentClasses.description)) @@ -871,7 +837,7 @@ export async function getDocumentClassOptionsByContract(contractId: number) { eq(documentClassOptions.isActive, true) ) ); - // ํ์ํ๋ฉด .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue)) + // ํ์ํ๋ฉด .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue)) return { success: true, data: { classes, options } }; } catch (error) { @@ -924,7 +890,7 @@ export async function createDocument(data: CreateDocumentData) { }, }) - console.log(contract,"contract") + console.log(contract, "contract") if (!contract) { return { success: false, error: "์ ํจํ์ง ์์ ๊ณ์ฝ(ID)์
๋๋ค." } @@ -944,7 +910,7 @@ export async function createDocument(data: CreateDocumentData) { /* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 3. ๋ฌธ์ ๋ ์ฝ๋ ์ฝ์
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ const insertData = { // ํ์ - projectId, + projectId, vendorId, // โ
์๋ก ์ถ๊ฐ contractId: data.contractId, docNumber: data.docNumber, @@ -954,7 +920,7 @@ export async function createDocument(data: CreateDocumentData) { updatedAt: new Date(), // ์ ํ - vendorDocNumber: data.vendorDocNumber === null || data.vendorDocNumber ==='' ? null: data.vendorDocNumber , + vendorDocNumber: data.vendorDocNumber === null || data.vendorDocNumber === '' ? null : data.vendorDocNumber, } @@ -984,7 +950,7 @@ export async function createDocument(data: CreateDocumentData) { ) - console.log(data.documentClassId,"documentClassId") + console.log(data.documentClassId, "documentClassId") console.log(stageOptionsResult.data) if (stageOptionsResult.success && stageOptionsResult.data.length > 0) { @@ -1045,7 +1011,7 @@ export async function getDocumentStagesOnly( // ์ธ์
์์ ๋๋ฉ์ธ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ const session = await getServerSession(authOptions) const isEvcpDomain = session?.user?.domain === "evcp" - + // ๋๋ฉ์ธ๋ณ WHERE ์กฐ๊ฑด ์ค์ let finalWhere if (isEvcpDomain) { @@ -1065,14 +1031,14 @@ export async function getDocumentStagesOnly( - // ์ ๋ ฌ ์ฒ๋ฆฌ + // ์ ๋ ฌ ์ฒ๋ฆฌ const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(stageDocumentsView[item.id]) : asc(stageDocumentsView[item.id]) ) - : [desc(stageDocumentsView.createdAt)] + : [desc(stageDocumentsView.createdAt)] // ํธ๋์ญ์
์คํ @@ -1195,10 +1161,10 @@ export async function sendDocumentsToSHI(contractId: number) { try { const api = new ShiBuyerSystemAPI() const result = await api.sendToSHI(contractId) - + // ์บ์ ๋ฌดํจํ revalidatePath(`/partners/document-list-only/${contractId}`) - + return result } catch (error) { console.error("SHI ์ ์ก ์คํจ:", error) @@ -1215,10 +1181,10 @@ export async function pullDocumentStatusFromSHI( try { const api = new ShiBuyerSystemAPI() const result = await api.pullDocumentStatus(contractId) - + // ์บ์ ๋ฌดํจํ revalidatePath(`/partners/document-list-only/${contractId}`) - + return result } catch (error) { console.error("๋ฌธ์ ์ํ ํ๋ง ์คํจ:", error) @@ -1254,10 +1220,10 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation if (!session?.user?.companyId) { throw new Error("Unauthorized") } - + const vendorId = session.user.companyId const results: ValidationResult[] = [] - + for (const file of files) { // stageSubmissionView์์ ๋งค์นญ๋๋ ๋ ์ฝ๋ ์ฐพ๊ธฐ const match = await db @@ -1277,7 +1243,7 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation ) ) .limit(1) - + if (match.length > 0) { results.push({ projectId: file.projectId, @@ -1298,6 +1264,238 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation }) } } - + return results -}
\ No newline at end of file +} + + + +// ============================================================================= +// Type Definitions (์๋ฒ์ ํด๋ผ์ด์ธํธ ๊ณต์ ) +// ============================================================================= +export interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string +} + +export interface ParsedStage { + docNumber: string + stageName: string + planDate?: string +} + +interface UploadData { + contractId: number + documents: ParsedDocument[] + stages: ParsedStage[] + projectType: "ship" | "plant" +} + +// ============================================================================= +// Upload Import Data (์๋ฒ ์ก์
) +// ============================================================================= +export async function uploadImportData(data: UploadData) { + const { contractId, documents, stages, projectType } = data + const warnings: string[] = [] + const createdDocumentIds: number[] = [] + const documentIdMap = new Map<string, number>() // docNumber -> documentId + + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!contract ) { + throw new Error("Contract not found") + } + + + // 2. Document Class ๋งคํ ๊ฐ์ ธ์ค๊ธฐ (ํธ๋์ญ์
๋ฐ์์) + const documentClassesData = await db + .select({ + id: documentClasses.id, + value: documentClasses.value, + description: documentClasses.description, + }) + .from(documentClasses) + .where(and(eq(documentClasses.projectId, contract.projectId), eq(documentClasses.isActive, true))) + + const classMap = new Map( + documentClassesData.map(dc => [dc.value, dc.id]) + ) + + console.log(classMap) + + // 3. ๊ฐ ๋ฌธ์๋ฅผ ๊ฐ๋ณ์ ์ผ๋ก ์ฒ๋ฆฌ (๊ฐ๋ณ ํธ๋์ญ์
) + for (const doc of documents) { + console.log(doc) + const documentClassId = classMap.get(doc.documentClass) + + if (!documentClassId) { + warnings.push(`Document Class "${doc.documentClass}"๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค (๋ฌธ์: ${doc.docNumber})`) + continue + } + + try { + // ๊ฐ๋ณ ํธ๋์ญ์
์ผ๋ก ๊ฐ ๋ฌธ์ ์ฒ๋ฆฌ + const result = await db.transaction(async (tx) => { + // ๋จผ์ ๋ฌธ์๊ฐ ์ด๋ฏธ ์กด์ฌํ๋์ง ํ์ธ + const [existingDoc] = await tx + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.projectId, contract.projectId), + eq(stageDocuments.docNumber, doc.docNumber), + eq(stageDocuments.status, "ACTIVE") + ) + ) + .limit(1) + + if (existingDoc) { + throw new Error(`๋ฌธ์๋ฒํธ "${doc.docNumber}"๊ฐ ์ด๋ฏธ ์กด์ฌํฉ๋๋ค`) + } + + // 3-1. ๋ฌธ์ ์์ฑ + const [newDoc] = await tx + .insert(stageDocuments) + .values({ + docNumber: doc.docNumber, + title: doc.title, + vendorDocNumber: doc.vendorDocNumber || null, + projectId:contract.projectId, + vendorId:contract.vendorId, + contractId, + status: "ACTIVE", + syncStatus: "pending", + syncVersion: 0, + }) + .returning({ id: stageDocuments.id }) + + if (!newDoc) { + throw new Error(`๋ฌธ์ ์์ฑ ์คํจ: ${doc.docNumber}`) + } + + // 3-2. Document Class Options์์ ์คํ
์ด์ง ์๋ ์์ฑ + const classOptions = await db + .select() + .from(documentClassOptions) + .where( + and( + eq(documentClassOptions.documentClassId, documentClassId), + eq(documentClassOptions.isActive, true) + ) + ) + .orderBy(asc(documentClassOptions.sdq)) + + + // 3-3. ๊ฐ ์ต์
์ ๋ํด ์คํ
์ด์ง ์์ฑ + const stageInserts = [] + + console.log(documentClassId, "documentClassId") + console.log(classOptions, "classOptions") + + for (const option of classOptions) { + // stages ๋ฐฐ์ด์์ ํด๋น ์คํ
์ด์ง์ plan date ์ฐพ๊ธฐ + const stageData = stages.find( + s => s.docNumber === doc.docNumber && s.stageName === option.description + ) + + stageInserts.push({ + documentId: newDoc.id, + stageName: option.description, + stageOrder: option.sdq, + stageStatus: "PLANNED" as const, + priority: "MEDIUM" as const, + planDate: stageData?.planDate || null, + reminderDays: 3, + }) + } + + // ๋ชจ๋ ์คํ
์ด์ง๋ฅผ ํ๋ฒ์ ์ฝ์
+ if (stageInserts.length > 0) { + await tx.insert(stageIssueStages).values(stageInserts) + } + + return newDoc.id + }) + + createdDocumentIds.push(result) + documentIdMap.set(doc.docNumber, result) + + } catch (error) { + console.error(`Error creating document ${doc.docNumber}:`, error) + warnings.push(`๋ฌธ์ ์์ฑ ์ค ์ค๋ฅ: ${doc.docNumber} - ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + // 4. ๊ธฐ์กด ๋ฌธ์์ Plan Date ์
๋ฐ์ดํธ (์ ๊ท ์์ฑ๋ ๋ฌธ์๋ ์ ์ธ) + const processedDocNumbers = new Set(documents.map(d => d.docNumber)) + + for (const stage of stages) { + if (!stage.planDate) continue + + // ์ด๋ฏธ ์ฒ๋ฆฌ๋ ์ ๊ท ๋ฌธ์๋ ์ ์ธ + if (processedDocNumbers.has(stage.docNumber)) continue + + try { + // ๊ธฐ์กด ๋ฌธ์ ์ฐพ๊ธฐ + const [existingDoc] = await db + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.projectId, contract.projectId), + eq(stageDocuments.docNumber, stage.docNumber), + eq(stageDocuments.status, "ACTIVE") + ) + ) + .limit(1) + + if (!existingDoc) { + warnings.push(`์คํ
์ด์ง ์
๋ฐ์ดํธ ์คํจ: ๋ฌธ์ "${stage.docNumber}"๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค`) + continue + } + + // ์คํ
์ด์ง plan date ์
๋ฐ์ดํธ + await db + .update(stageIssueStages) + .set({ + planDate: stage.planDate, + updatedAt: new Date() + }) + .where( + and( + eq(stageIssueStages.documentId, existingDoc.id), + eq(stageIssueStages.stageName, stage.stageName) + ) + ) + } catch (error) { + console.error(`Error updating stage for document ${stage.docNumber}:`, error) + warnings.push(`์คํ
์ด์ง "${stage.stageName}" ์
๋ฐ์ดํธ ์คํจ (๋ฌธ์: ${stage.docNumber})`) + } + } + + return { + success: true, + data: { + success: true, + createdCount: createdDocumentIds.length, + documentIds: createdDocumentIds + }, + warnings, + message: `${createdDocumentIds.length}๊ฐ ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์์ฑ๋์์ต๋๋ค` + } + + } catch (error) { + console.error("Upload import data error:", error) + return { + success: false, + error: error instanceof Error ? error.message : "๋ฐ์ดํฐ ์
๋ก๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค", + warnings + } + } +} diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 50d54a92..6cc112e3 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -14,11 +14,11 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { - AlertTriangle, - Clock, - TrendingUp, - Target, - Users, + FileText, + Send, + Search, + CheckCircle2, + XCircle, Plus, FileSpreadsheet } from "lucide-react" @@ -32,7 +32,6 @@ import { DocumentStagesExpandedContent } from "./document-stages-expanded-conten import { AddDocumentDialog, DeleteDocumentsDialog } from "./document-stage-dialogs" import { EditDocumentDialog } from "./document-stage-dialogs" import { EditStageDialog } from "./document-stage-dialogs" -import { ExcelImportDialog } from "./document-stage-dialogs" import { DocumentsTableToolbarActions } from "./document-stage-toolbar" import { useSession } from "next-auth/react" @@ -51,7 +50,6 @@ export function DocumentStagesTable({ const { data: session } = useSession() - // URL์์ ์ธ์ด ํ๋ผ๋ฏธํฐ ๊ฐ์ ธ์ค๊ธฐ const params = useParams() const lng = (params?.lng as string) || 'ko' @@ -63,7 +61,7 @@ export function DocumentStagesTable({ // ์ํ ๊ด๋ฆฌ const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageDocumentsView> | null>(null) const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) - const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + const [quickFilter, setQuickFilter] = React.useState<'all' | 'submitted' | 'under_review' | 'approved' | 'rejected'>('all') // ๋ค์ด์ผ๋ก๊ทธ ์ํ๋ค const [addDocumentOpen, setAddDocumentOpen] = React.useState(false) @@ -112,52 +110,41 @@ export function DocumentStagesTable({ [expandedRows, projectType, currentDomain] ) - // ํต๊ณ ๊ณ์ฐ + // ๋ฌธ์ ์ํ๋ณ ํต๊ณ ๊ณ์ฐ const stats = React.useMemo(() => { - console.log('DocumentStagesTable - data:', data) - console.log('DocumentStagesTable - data length:', data?.length) - const totalDocs = data?.length || 0 - const overdue = data?.filter(doc => doc.isOverdue)?.length || 0 - const dueSoon = data?.filter(doc => - doc.daysUntilDue !== null && - doc.daysUntilDue >= 0 && - doc.daysUntilDue <= 3 + const submitted = data?.filter(doc => doc.status === 'SUBMITTED')?.length || 0 + const underReview = data?.filter(doc => doc.status === 'UNDER_REVIEW')?.length || 0 + const approved = data?.filter(doc => doc.status === 'APPROVED')?.length || 0 + const rejected = data?.filter(doc => doc.status === 'REJECTED')?.length || 0 + const notSubmitted = data?.filter(doc => + !doc.status || !['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED'].includes(doc.status) )?.length || 0 - const inProgress = data?.filter(doc => doc.currentStageStatus === 'IN_PROGRESS')?.length || 0 - const highPriority = data?.filter(doc => doc.currentStagePriority === 'HIGH')?.length || 0 - const avgProgress = totalDocs > 0 - ? Math.round((data?.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) || 0) / totalDocs) - : 0 - const result = { + return { total: totalDocs, - overdue, - dueSoon, - inProgress, - highPriority, - avgProgress + submitted, + underReview, + approved, + rejected, + notSubmitted, + approvalRate: totalDocs > 0 + ? Math.round((approved / totalDocs) * 100) + : 0 } - - console.log('DocumentStagesTable - stats:', result) - return result }, [data]) // ๋น ๋ฅธ ํํฐ๋ง const filteredData = React.useMemo(() => { switch (quickFilter) { - case 'overdue': - return data.filter(doc => doc.isOverdue) - case 'due_soon': - return data.filter(doc => - doc.daysUntilDue !== null && - doc.daysUntilDue >= 0 && - doc.daysUntilDue <= 3 - ) - case 'in_progress': - return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') - case 'high_priority': - return data.filter(doc => doc.currentStagePriority === 'HIGH') + case 'submitted': + return data.filter(doc => doc.status === 'SUBMITTED') + case 'under_review': + return data.filter(doc => doc.status === 'UNDER_REVIEW') + case 'approved': + return data.filter(doc => doc.status === 'APPROVED') + case 'rejected': + return data.filter(doc => doc.status === 'REJECTED') default: return data } @@ -172,24 +159,6 @@ export function DocumentStagesTable({ setExcelImportOpen(true) } - const handleBulkAction = async (action: string, selectedRows: any[]) => { - try { - if (action === 'bulk_complete') { - const stageIds = selectedRows - .map(row => row.original.currentStageId) - .filter(Boolean) - - if (stageIds.length > 0) { - toast.success(t('documentList.messages.stageCompletionSuccess', { count: stageIds.length })) - } - } else if (action === 'bulk_assign') { - toast.info(t('documentList.messages.bulkAssignPending')) - } - } catch (error) { - toast.error(t('documentList.messages.bulkActionError')) - } - } - const closeAllDialogs = () => { setAddDocumentOpen(false) setEditDocumentOpen(false) @@ -201,8 +170,7 @@ export function DocumentStagesTable({ } // ํํฐ ํ๋ ์ ์ - const filterFields: DataTableFilterField<StageDocumentsView>[] = [ - ] + const filterFields: DataTableFilterField<StageDocumentsView>[] = [] const advancedFilterFields: DataTableAdvancedFilterField<StageDocumentsView>[] = [ { @@ -216,37 +184,18 @@ export function DocumentStagesTable({ type: "text", }, { - id: "currentStageStatus", - label: "์คํ
์ด์ง ์ํ", + id: "status", + label: "๋ฌธ์ ์ํ", type: "select", options: [ - { label: "๊ณํ๋จ", value: "PLANNED" }, - { label: "์งํ์ค", value: "IN_PROGRESS" }, { label: "์ ์ถ๋จ", value: "SUBMITTED" }, - { label: "์๋ฃ๋จ", value: "COMPLETED" }, - ], - }, - { - id: "currentStagePriority", - label: "์ฐ์ ์์", - type: "select", - options: [ - { label: "๋์", value: "HIGH" }, - { label: "๋ณดํต", value: "MEDIUM" }, - { label: "๋ฎ์", value: "LOW" }, - ], - }, - { - id: "isOverdue", - label: "์ง์ฐ ์ฌ๋ถ", - type: "select", - options: [ - { label: "์ง์ฐ๋จ", value: "true" }, - { label: "์ ์", value: "false" }, + { label: "๊ฒํ ์ค", value: "UNDER_REVIEW" }, + { label: "์น์ธ๋จ", value: "APPROVED" }, + { label: "๋ฐ๋ ค๋จ", value: "REJECTED" }, ], }, { - id: "currentStageAssigneeName", + id: "pic", label: "๋ด๋น์", type: "text", }, @@ -276,95 +225,111 @@ export function DocumentStagesTable({ return ( <div className="space-y-6"> - {/* ํต๊ณ ๋์๋ณด๋ */} + {/* ๋ฌธ์ ์ํ ๋์๋ณด๋ */} <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + {/* ์ ์ฒด ๋ฌธ์ */} <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.totalDocuments')}</CardTitle> - <TrendingUp className="h-4 w-4 text-muted-foreground" /> + <CardTitle className="text-sm font-medium">Total Documents</CardTitle> + <FileText className="h-4 w-4 text-muted-foreground" /> </CardHeader> <CardContent> <div className="text-2xl font-bold">{stats.total}</div> <p className="text-xs text-muted-foreground"> - {t('documentList.dashboard.totalDocumentCount', { total: stats.total })} + ์ ์ฒด ๋ฑ๋ก ๋ฌธ์ </p> </CardContent> </Card> - <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + {/* ์ ์ถ๋จ */} + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('submitted')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.overdueDocuments')}</CardTitle> - <AlertTriangle className="h-4 w-4 text-red-500" /> + <CardTitle className="text-sm font-medium">Submitted</CardTitle> + <Send className="h-4 w-4 text-blue-500" /> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.overdue}</div> - <p className="text-xs text-muted-foreground">{t('documentList.dashboard.checkImmediately')}</p> + <div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.submitted}</div> + <p className="text-xs text-muted-foreground">์ ์ถ ๋๊ธฐ์ค</p> </CardContent> </Card> - <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + {/* ๊ฒํ ์ค */} + {/* <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('under_review')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.dueSoonDocuments')}</CardTitle> - <Clock className="h-4 w-4 text-orange-500" /> + <CardTitle className="text-sm font-medium">Under Review</CardTitle> + <Search className="h-4 w-4 text-orange-500" /> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.dueSoon}</div> - <p className="text-xs text-muted-foreground">{t('documentList.dashboard.dueInDays')}</p> + <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.underReview}</div> + <p className="text-xs text-muted-foreground">๊ฒํ ์งํ์ค</p> + </CardContent> + </Card> */} + + {/* ์น์ธ๋จ */} + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('approved')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">Approved</CardTitle> + <CheckCircle2 className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.approved}</div> + <p className="text-xs text-muted-foreground">์น์ธ ์๋ฃ ({stats.approvalRate}%)</p> </CardContent> </Card> - <Card> + <Card className="cursor-pointer hover:shadow-md transition-shadow border-red-200 dark:border-red-800" + onClick={() => setQuickFilter('rejected')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.averageProgress')}</CardTitle> - <Target className="h-4 w-4 text-green-500" /> + <CardTitle className="text-sm font-medium">Rejected</CardTitle> + <XCircle className="h-4 w-4 text-red-500" /> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.avgProgress}%</div> - <p className="text-xs text-muted-foreground">{t('documentList.dashboard.overallProgress')}</p> + <div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejected}</div> + <p className="text-xs text-muted-foreground">์ฌ์์
ํ์</p> </CardContent> </Card> </div> - {/* ๋น ๋ฅธ ํํฐ */} + {/* ๋น ๋ฅธ ํํฐ ๋ฑ์ง */} <div className="flex gap-2 overflow-x-auto pb-2"> <Badge variant={quickFilter === 'all' ? 'default' : 'outline'} className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" onClick={() => setQuickFilter('all')} > - {t('documentList.quickFilters.all')} ({stats.total}) + ์ ์ฒด ({stats.total}) </Badge> <Badge - variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} - className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" - onClick={() => setQuickFilter('overdue')} + variant={quickFilter === 'submitted' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 whitespace-nowrap" + onClick={() => setQuickFilter('submitted')} > - <AlertTriangle className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.overdue')} ({stats.overdue}) + <Send className="w-3 h-3 mr-1" /> + ์ ์ถ๋จ ({stats.submitted}) </Badge> <Badge - variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + variant={quickFilter === 'under_review' ? 'default' : 'outline'} className="cursor-pointer hover:bg-orange-500 hover:text-white dark:hover:bg-orange-600 whitespace-nowrap" - onClick={() => setQuickFilter('due_soon')} + onClick={() => setQuickFilter('under_review')} > - <Clock className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.dueSoon')} ({stats.dueSoon}) + <Search className="w-3 h-3 mr-1" /> + ๊ฒํ ์ค ({stats.underReview}) </Badge> <Badge - variant={quickFilter === 'in_progress' ? 'default' : 'outline'} - className="cursor-pointer hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 whitespace-nowrap" - onClick={() => setQuickFilter('in_progress')} + variant={quickFilter === 'approved' ? 'success' : 'outline'} + className="cursor-pointer hover:bg-green-500 hover:text-white dark:hover:bg-green-600 whitespace-nowrap" + onClick={() => setQuickFilter('approved')} > - <Users className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.inProgress')} ({stats.inProgress}) + <CheckCircle2 className="w-3 h-3 mr-1" /> + ์น์ธ๋จ ({stats.approved}) </Badge> <Badge - variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + variant={quickFilter === 'rejected' ? 'destructive' : 'outline'} className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" - onClick={() => setQuickFilter('high_priority')} + onClick={() => setQuickFilter('rejected')} > - <Target className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.highPriority')} ({stats.highPriority}) + <XCircle className="w-3 h-3 mr-1" /> + ๋ฐ๋ ค๋จ ({stats.rejected}) </Badge> </div> @@ -375,6 +340,7 @@ export function DocumentStagesTable({ table={table} expandable={true} expandedRows={expandedRows} + simpleExpansion={true} setExpandedRows={setExpandedRows} renderExpandedContent={(document) => ( <DocumentStagesExpandedContent @@ -440,25 +406,13 @@ export function DocumentStagesTable({ stageId={selectedStageId} /> - <ExcelImportDialog - open={excelImportOpen} - onOpenChange={(open) => { - if (!open) closeAllDialogs() - else setExcelImportOpen(open) - }} - contractId={contractId} - projectType={projectType} - /> - <DeleteDocumentsDialog open={rowAction?.type === "delete"} onOpenChange={() => setRowAction(null)} showTrigger={false} - documents={rowAction?.row.original ? [rowAction?.row.original] : []} // ์ ์ฒด ๋ฌธ์ ๋ฐฐ์ด + documents={rowAction?.row.original ? [rowAction?.row.original] : []} onSuccess={() => rowAction?.row.toggleSelected(false)} /> - - </div> ) }
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts deleted file mode 100644 index c1409205..00000000 --- a/lib/vendor-document-list/plant/excel-import-export.ts +++ /dev/null @@ -1,788 +0,0 @@ -// excel-import-export.ts -"use client" - -import ExcelJS from 'exceljs' -import { - excelDocumentRowSchema, - excelStageRowSchema, - type ExcelDocumentRow, - type ExcelStageRow, - type ExcelImportResult, - type CreateDocumentInput -} from './document-stage-validations' -import { StageDocumentsView } from '@/db/schema' - -// ============================================================================= -// 1. ์์
ํ
ํ๋ฆฟ ์์ฑ ๋ฐ ๋ค์ด๋ก๋ -// ============================================================================= - -// ๋ฌธ์ ํ
ํ๋ฆฟ ์์ฑ -export async function createDocumentTemplate(projectType: "ship" | "plant") { - const workbook = new ExcelJS.Workbook() - const worksheet = workbook.addWorksheet("๋ฌธ์๋ชฉ๋ก", { - properties: { defaultColWidth: 15 } - }) - - const baseHeaders = [ - "๋ฌธ์๋ฒํธ*", - "๋ฌธ์๋ช
*", - "๋ฌธ์์ข
๋ฅ*", - "PIC", - "๋ฐํ์ผ", - "์ค๋ช
" - ] - - const plantHeaders = [ - "๋ฒค๋๋ฌธ์๋ฒํธ", - "๋ฒค๋๋ช
", - "๋ฒค๋์ฝ๋" - ] - - const b4Headers = [ - "C๊ตฌ๋ถ", - "D๊ตฌ๋ถ", - "Degree๊ตฌ๋ถ", - "๋ถ์๊ตฌ๋ถ", - "S๊ตฌ๋ถ", - "J๊ตฌ๋ถ" - ] - - const headers = [ - ...baseHeaders, - ...(projectType === "plant" ? plantHeaders : []), - ...b4Headers - ] - - // ํค๋ ํ ์ถ๊ฐ ๋ฐ ์คํ์ผ๋ง - const headerRow = worksheet.addRow(headers) - headerRow.eachCell((cell, colNumber) => { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FF4472C4' } - } - cell.font = { - color: { argb: 'FFFFFFFF' }, - bold: true, - size: 11 - } - cell.alignment = { - horizontal: 'center', - vertical: 'middle' - } - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // ํ์ ํ๋ ํ์ - if (cell.value && String(cell.value).includes('*')) { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE74C3C' } - } - } - }) - - // ์ํ ๋ฐ์ดํฐ ์ถ๊ฐ - const sampleData = projectType === "ship" ? [ - "SH-2024-001", - "๊ธฐ๋ณธ ์ค๊ณ ๋๋ฉด", - "B3", - "๊น์ฒ ์", - new Date("2024-01-15"), - "์ ๋ฐ ๊ธฐ๋ณธ ์ค๊ณ ๊ด๋ จ ๋ฌธ์", - "", "", "", "", "", "" // B4 ํ๋๋ค - ] : [ - "PL-2024-001", - "๊ณต์ ์ค๊ณ ๋๋ฉด", - "B4", - "์ด์ํฌ", - new Date("2024-01-15"), - "ํ๋ํธ ๊ณต์ ์ค๊ณ ๊ด๋ จ ๋ฌธ์", - "V-001", // ๋ฒค๋๋ฌธ์๋ฒํธ - "์ผ์ฑ์์ง๋์ด๋ง", // ๋ฒค๋๋ช
- "SENG", // ๋ฒค๋์ฝ๋ - "C1", "D1", "DEG1", "DEPT1", "S1", "J1" // B4 ํ๋๋ค - ] - - const sampleRow = worksheet.addRow(sampleData) - sampleRow.eachCell((cell, colNumber) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // ๋ ์ง ํ์ ์ค์ - if (cell.value instanceof Date) { - cell.numFmt = 'yyyy-mm-dd' - } - }) - - // ์ปฌ๋ผ ๋๋น ์๋ ์กฐ์ - worksheet.columns.forEach((column, index) => { - if (index < 6) { - column.width = headers[index].length + 5 - } else { - column.width = 12 - } - }) - - // ๋ฌธ์์ข
๋ฅ ๋๋กญ๋ค์ด ์ค์ - const docTypeCol = headers.indexOf("๋ฌธ์์ข
๋ฅ*") + 1 - worksheet.dataValidations.add(`${String.fromCharCode(64 + docTypeCol)}2:${String.fromCharCode(64 + docTypeCol)}1000`, { - type: 'list', - allowBlank: false, - formulae: ['"B3,B4,B5"'] - }) - - // Plant ํ๋ก์ ํธ์ ๊ฒฝ์ฐ ์ฐ์ ์์ ๋๋กญ๋ค์ด ์ถ๊ฐ - if (projectType === "plant") { - // ์ฌ๊ธฐ์ ์ถ๊ฐ์ ์ธ ๋๋กญ๋ค์ด๋ค์ ์ค์ ํ ์ ์์ต๋๋ค - } - - return workbook -} - -// ์คํ
์ด์ง ํ
ํ๋ฆฟ ์์ฑ -export async function createStageTemplate() { - const workbook = new ExcelJS.Workbook() - const worksheet = workbook.addWorksheet("์คํ
์ด์ง๋ชฉ๋ก", { - properties: { defaultColWidth: 15 } - }) - - const headers = [ - "๋ฌธ์๋ฒํธ*", - "์คํ
์ด์ง๋ช
*", - "๊ณํ์ผ", - "์ฐ์ ์์", - "๋ด๋น์", - "์ค๋ช
", - "์คํ
์ด์ง์์" - ] - - // ํค๋ ํ ์ถ๊ฐ ๋ฐ ์คํ์ผ๋ง - const headerRow = worksheet.addRow(headers) - headerRow.eachCell((cell, colNumber) => { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FF27AE60' } - } - cell.font = { - color: { argb: 'FFFFFFFF' }, - bold: true, - size: 11 - } - cell.alignment = { - horizontal: 'center', - vertical: 'middle' - } - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // ํ์ ํ๋ ํ์ - if (cell.value && String(cell.value).includes('*')) { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE74C3C' } - } - } - }) - - // ์ํ ๋ฐ์ดํฐ ์ถ๊ฐ - const sampleData = [ - [ - "SH-2024-001", - "์ด๊ธฐ ์ค๊ณ ๊ฒํ ", - new Date("2024-02-15"), - "HIGH", - "๊น์ฒ ์", - "์ด๊ธฐ ์ค๊ณ์ ๊ฒํ ๋ฐ ์น์ธ", - 0 - ], - [ - "SH-2024-001", - "์์ธ ์ค๊ณ", - new Date("2024-03-15"), - "MEDIUM", - "์ด์ํฌ", - "์์ธ ์ค๊ณ ์์
์ํ", - 1 - ] - ] - - sampleData.forEach(rowData => { - const row = worksheet.addRow(rowData) - row.eachCell((cell, colNumber) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // ๋ ์ง ํ์ ์ค์ - if (cell.value instanceof Date) { - cell.numFmt = 'yyyy-mm-dd' - } - }) - }) - - // ์ปฌ๋ผ ๋๋น ์ค์ - worksheet.columns = [ - { width: 15 }, // ๋ฌธ์๋ฒํธ - { width: 20 }, // ์คํ
์ด์ง๋ช
- { width: 12 }, // ๊ณํ์ผ - { width: 10 }, // ์ฐ์ ์์ - { width: 15 }, // ๋ด๋น์ - { width: 30 }, // ์ค๋ช
- { width: 12 }, // ์คํ
์ด์ง์์ - ] - - // ์ฐ์ ์์ ๋๋กญ๋ค์ด ์ค์ - worksheet.dataValidations.add('D2:D1000', { - type: 'list', - allowBlank: true, - formulae: ['"HIGH,MEDIUM,LOW"'] - }) - - return workbook -} - -// ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ํจ์ -export async function downloadTemplate(type: "documents" | "stages", projectType: "ship" | "plant") { - const workbook = await (type === "documents" - ? createDocumentTemplate(projectType) - : createStageTemplate()) - - const filename = type === "documents" - ? `๋ฌธ์_์ํฌํธ_ํ
ํ๋ฆฟ_${projectType}.xlsx` - : `์คํ
์ด์ง_์ํฌํธ_ํ
ํ๋ฆฟ.xlsx` - - // ๋ธ๋ผ์ฐ์ ์์ ๋ค์ด๋ก๋ - const buffer = await workbook.xlsx.writeBuffer() - const blob = new Blob([buffer], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }) - - const url = window.URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename - link.click() - - // ๋ฉ๋ชจ๋ฆฌ ์ ๋ฆฌ - window.URL.revokeObjectURL(url) -} - -// ============================================================================= -// 2. ์์
ํ์ผ ์ฝ๊ธฐ ๋ฐ ํ์ฑ -// ============================================================================= - -// ์์
ํ์ผ์ ์ฝ์ด์ JSON์ผ๋ก ๋ณํ -export async function readExcelFile(file: File): Promise<any[]> { - return new Promise((resolve, reject) => { - const reader = new FileReader() - - reader.onload = async (e) => { - try { - const buffer = e.target?.result as ArrayBuffer - const workbook = new ExcelJS.Workbook() - await workbook.xlsx.load(buffer) - - const worksheet = workbook.getWorksheet(1) // ์ฒซ ๋ฒ์งธ ์ํฌ์ํธ - if (!worksheet) { - throw new Error('์ํฌ์ํธ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค') - } - - const jsonData: any[] = [] - - worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { - const rowData: any[] = [] - row.eachCell({ includeEmpty: true }, (cell, colNumber) => { - let value = cell.value - - // ๋ ์ง ์ฒ๋ฆฌ - if (cell.type === ExcelJS.ValueType.Date) { - value = cell.value as Date - } - // ์์ ๊ฒฐ๊ณผ๊ฐ ์ฒ๋ฆฌ - else if (cell.type === ExcelJS.ValueType.Formula && cell.result) { - value = cell.result - } - // ํ์ดํผ๋งํฌ ์ฒ๋ฆฌ - else if (cell.type === ExcelJS.ValueType.Hyperlink) { - value = cell.value?.text || cell.value - } - - rowData[colNumber - 1] = value || "" - }) - - jsonData.push(rowData) - }) - - resolve(jsonData) - } catch (error) { - reject(new Error('์์
ํ์ผ์ ์ฝ๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: ' + error)) - } - } - - reader.onerror = () => { - reject(new Error('ํ์ผ์ ์ฝ์ ์ ์์ต๋๋ค')) - } - - reader.readAsArrayBuffer(file) - }) -} - -// ๋ฌธ์ ๋ฐ์ดํฐ ์ ํจ์ฑ ๊ฒ์ฌ ๋ฐ ๋ณํ -export function validateDocumentRows( - rawData: any[], - contractId: number, - projectType: "ship" | "plant" -): { validData: CreateDocumentInput[], errors: any[] } { - if (rawData.length < 2) { - throw new Error('๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ์ต์ ํค๋์ 1๊ฐ ํ์ด ํ์ํฉ๋๋ค.') - } - - const headers = rawData[0] as string[] - const rows = rawData.slice(1) - - const validData: CreateDocumentInput[] = [] - const errors: any[] = [] - - // ํ์ ํค๋ ๊ฒ์ฌ - const requiredHeaders = ["๋ฌธ์๋ฒํธ", "๋ฌธ์๋ช
", "๋ฌธ์์ข
๋ฅ"] - const missingHeaders = requiredHeaders.filter(h => - !headers.some(header => header.includes(h.replace("*", ""))) - ) - - if (missingHeaders.length > 0) { - throw new Error(`ํ์ ํค๋๊ฐ ๋๋ฝ๋์์ต๋๋ค: ${missingHeaders.join(", ")}`) - } - - // ํค๋ ์ธ๋ฑ์ค ๋งคํ - const headerMap: Record<string, number> = {} - headers.forEach((header, index) => { - const cleanHeader = header.replace("*", "").trim() - headerMap[cleanHeader] = index - }) - - // ๊ฐ ํ ์ฒ๋ฆฌ - rows.forEach((row: any[], rowIndex) => { - try { - // ๋น ํ ์คํต - if (row.every(cell => !cell || String(cell).trim() === "")) { - return - } - - const rowData: any = { - contractId, - docNumber: String(row[headerMap["๋ฌธ์๋ฒํธ"]] || "").trim(), - title: String(row[headerMap["๋ฌธ์๋ช
"]] || "").trim(), - drawingKind: String(row[headerMap["๋ฌธ์์ข
๋ฅ"]] || "").trim(), - pic: String(row[headerMap["PIC"]] || "").trim() || undefined, - issuedDate: row[headerMap["๋ฐํ์ผ"]] ? - formatExcelDate(row[headerMap["๋ฐํ์ผ"]]) : undefined, - } - - // Plant ํ๋ก์ ํธ ์ ์ฉ ํ๋ - if (projectType === "plant") { - rowData.vendorDocNumber = String(row[headerMap["๋ฒค๋๋ฌธ์๋ฒํธ"]] || "").trim() || undefined - } - - // B4 ์ ์ฉ ํ๋๋ค - const b4Fields = ["C๊ตฌ๋ถ", "D๊ตฌ๋ถ", "Degree๊ตฌ๋ถ", "๋ถ์๊ตฌ๋ถ", "S๊ตฌ๋ถ", "J๊ตฌ๋ถ"] - const b4FieldMap = { - "C๊ตฌ๋ถ": "cGbn", - "D๊ตฌ๋ถ": "dGbn", - "Degree๊ตฌ๋ถ": "degreeGbn", - "๋ถ์๊ตฌ๋ถ": "deptGbn", - "S๊ตฌ๋ถ": "sGbn", - "J๊ตฌ๋ถ": "jGbn" - } - - b4Fields.forEach(field => { - if (headerMap[field] !== undefined) { - const value = String(row[headerMap[field]] || "").trim() - if (value) { - rowData[b4FieldMap[field as keyof typeof b4FieldMap]] = value - } - } - }) - - // ์ ํจ์ฑ ๊ฒ์ฌ - const validatedData = excelDocumentRowSchema.parse({ - "๋ฌธ์๋ฒํธ": rowData.docNumber, - "๋ฌธ์๋ช
": rowData.title, - "๋ฌธ์์ข
๋ฅ": rowData.drawingKind, - "๋ฒค๋๋ฌธ์๋ฒํธ": rowData.vendorDocNumber, - "PIC": rowData.pic, - "๋ฐํ์ผ": rowData.issuedDate, - "C๊ตฌ๋ถ": rowData.cGbn, - "D๊ตฌ๋ถ": rowData.dGbn, - "Degree๊ตฌ๋ถ": rowData.degreeGbn, - "๋ถ์๊ตฌ๋ถ": rowData.deptGbn, - "S๊ตฌ๋ถ": rowData.sGbn, - "J๊ตฌ๋ถ": rowData.jGbn, - }) - - // CreateDocumentInput ํํ๋ก ๋ณํ - const documentInput: CreateDocumentInput = { - contractId, - docNumber: validatedData["๋ฌธ์๋ฒํธ"], - title: validatedData["๋ฌธ์๋ช
"], - drawingKind: validatedData["๋ฌธ์์ข
๋ฅ"], - vendorDocNumber: validatedData["๋ฒค๋๋ฌธ์๋ฒํธ"], - pic: validatedData["PIC"], - issuedDate: validatedData["๋ฐํ์ผ"], - cGbn: validatedData["C๊ตฌ๋ถ"], - dGbn: validatedData["D๊ตฌ๋ถ"], - degreeGbn: validatedData["Degree๊ตฌ๋ถ"], - deptGbn: validatedData["๋ถ์๊ตฌ๋ถ"], - sGbn: validatedData["S๊ตฌ๋ถ"], - jGbn: validatedData["J๊ตฌ๋ถ"], - } - - validData.push(documentInput) - - } catch (error) { - errors.push({ - row: rowIndex + 2, // ์์
ํ ๋ฒํธ (ํค๋ ํฌํจ) - message: error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ", - data: row - }) - } - }) - - return { validData, errors } -} - -// ์์
๋ ์ง ํ์ ๋ณํ -function formatExcelDate(value: any): string | undefined { - if (!value) return undefined - - // ExcelJS์์ Date ๊ฐ์ฒด๋ก ์ฒ๋ฆฌ๋ ๊ฒฝ์ฐ - if (value instanceof Date) { - return value.toISOString().split('T')[0] - } - - // ์ด๋ฏธ ๋ฌธ์์ด ๋ ์ง ํ์์ธ ๊ฒฝ์ฐ - if (typeof value === 'string') { - const dateMatch = value.match(/^\d{4}-\d{2}-\d{2}$/) - if (dateMatch) return value - - // ๋ค๋ฅธ ํ์ ์๋ - const date = new Date(value) - if (!isNaN(date.getTime())) { - return date.toISOString().split('T')[0] - } - } - - // ์์
์๋ฆฌ์ผ ๋ ์ง์ธ ๊ฒฝ์ฐ - if (typeof value === 'number') { - // ExcelJS๋ ์ด๋ฏธ Date ๊ฐ์ฒด๋ก ๋ณํํด์ฃผ๋ฏ๋ก ์ด ๊ฒฝ์ฐ๋ ๋๋ฌผ์ง๋ง - // 1900๋
1์ 1์ผ๋ถํฐ์ ์ผ์๋ก ๊ณ์ฐ - const excelEpoch = new Date(1900, 0, 1) - const date = new Date(excelEpoch.getTime() + (value - 2) * 24 * 60 * 60 * 1000) - if (!isNaN(date.getTime())) { - return date.toISOString().split('T')[0] - } - } - - return undefined -} - -// ============================================================================= -// 3. ๋ฐ์ดํฐ ์ต์คํฌํธ -// ============================================================================= - -// ๋ฌธ์ ๋ฐ์ดํฐ๋ฅผ ์์
๋ก ์ต์คํฌํธ -export function exportDocumentsToExcel( - documents: StageDocumentsView[], - projectType: "ship" | "plant" -) { - const headers = [ - "๋ฌธ์๋ฒํธ", - "๋ฌธ์๋ช
", - "๋ฌธ์์ข
๋ฅ", - "PIC", - "๋ฐํ์ผ", - "ํ์ฌ์คํ
์ด์ง", - "์คํ
์ด์ง์ํ", - "๊ณํ์ผ", - "๋ด๋น์", - "์ฐ์ ์์", - "์งํ๋ฅ (%)", - "์๋ฃ์คํ
์ด์ง", - "์ ์ฒด์คํ
์ด์ง", - "์ง์ฐ์ฌ๋ถ", - "๋จ์์ผ์", - "์์ฑ์ผ", - "์์ ์ผ" - ] - - // Plant ํ๋ก์ ํธ ์ ์ฉ ํค๋ ์ถ๊ฐ - if (projectType === "plant") { - headers.splice(3, 0, "๋ฒค๋๋ฌธ์๋ฒํธ", "๋ฒค๋๋ช
", "๋ฒค๋์ฝ๋") - } - - const data = documents.map(doc => { - const baseData = [ - doc.docNumber, - doc.title, - doc.drawingKind || "", - doc.pic || "", - doc.issuedDate || "", - doc.currentStageName || "", - getStatusText(doc.currentStageStatus || ""), - doc.currentStagePlanDate || "", - doc.currentStageAssigneeName || "", - getPriorityText(doc.currentStagePriority || ""), - doc.progressPercentage || 0, - doc.completedStages || 0, - doc.totalStages || 0, - doc.isOverdue ? "์" : "์๋์ค", - doc.daysUntilDue || "", - doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : "", - doc.updatedAt ? new Date(doc.updatedAt).toLocaleDateString() : "" - ] - - // Plant ํ๋ก์ ํธ ๋ฐ์ดํฐ ์ถ๊ฐ - if (projectType === "plant") { - baseData.splice(3, 0, - doc.vendorDocNumber || "", - doc.vendorName || "", - doc.vendorCode || "" - ) - } - - return baseData - }) - - const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) - - // ์ปฌ๋ผ ๋๋น ์ค์ - const colWidths = [ - { wch: 15 }, // ๋ฌธ์๋ฒํธ - { wch: 30 }, // ๋ฌธ์๋ช
- { wch: 10 }, // ๋ฌธ์์ข
๋ฅ - ...(projectType === "plant" ? [ - { wch: 15 }, // ๋ฒค๋๋ฌธ์๋ฒํธ - { wch: 20 }, // ๋ฒค๋๋ช
- { wch: 10 }, // ๋ฒค๋์ฝ๋ - ] : []), - { wch: 10 }, // PIC - { wch: 12 }, // ๋ฐํ์ผ - { wch: 15 }, // ํ์ฌ์คํ
์ด์ง - { wch: 10 }, // ์คํ
์ด์ง์ํ - { wch: 12 }, // ๊ณํ์ผ - { wch: 10 }, // ๋ด๋น์ - { wch: 8 }, // ์ฐ์ ์์ - { wch: 8 }, // ์งํ๋ฅ - { wch: 8 }, // ์๋ฃ์คํ
์ด์ง - { wch: 8 }, // ์ ์ฒด์คํ
์ด์ง - { wch: 8 }, // ์ง์ฐ์ฌ๋ถ - { wch: 8 }, // ๋จ์์ผ์ - { wch: 12 }, // ์์ฑ์ผ - { wch: 12 }, // ์์ ์ผ - ] - - worksheet['!cols'] = colWidths - - const workbook = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(workbook, worksheet, "๋ฌธ์๋ชฉ๋ก") - - const filename = `๋ฌธ์๋ชฉ๋ก_${new Date().toISOString().split('T')[0]}.xlsx` - XLSX.writeFile(workbook, filename) -} - -// ์คํ
์ด์ง ์์ธ ๋ฐ์ดํฐ๋ฅผ ์์
๋ก ์ต์คํฌํธ -export function exportStageDetailsToExcel(documents: StageDocumentsView[]) { - const headers = [ - "๋ฌธ์๋ฒํธ", - "๋ฌธ์๋ช
", - "์คํ
์ด์ง๋ช
", - "์คํ
์ด์ง์ํ", - "์คํ
์ด์ง์์", - "๊ณํ์ผ", - "๋ด๋น์", - "์ฐ์ ์์", - "์ค๋ช
", - "๋
ธํธ", - "์๋ฆผ์ผ์" - ] - - const data: any[] = [] - - documents.forEach(doc => { - if (doc.allStages && doc.allStages.length > 0) { - doc.allStages.forEach(stage => { - data.push([ - doc.docNumber, - doc.title, - stage.stageName, - getStatusText(stage.stageStatus), - stage.stageOrder, - stage.planDate || "", - stage.assigneeName || "", - getPriorityText(stage.priority), - stage.description || "", - stage.notes || "", - stage.reminderDays || "" - ]) - }) - } else { - // ์คํ
์ด์ง๊ฐ ์๋ ๋ฌธ์๋ ํฌํจ - data.push([ - doc.docNumber, - doc.title, - "", "", "", "", "", "", "", "", "" - ]) - } - }) - - const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) - - // ์ปฌ๋ผ ๋๋น ์ค์ - worksheet['!cols'] = [ - { wch: 15 }, // ๋ฌธ์๋ฒํธ - { wch: 30 }, // ๋ฌธ์๋ช
- { wch: 20 }, // ์คํ
์ด์ง๋ช
- { wch: 12 }, // ์คํ
์ด์ง์ํ - { wch: 8 }, // ์คํ
์ด์ง์์ - { wch: 12 }, // ๊ณํ์ผ - { wch: 10 }, // ๋ด๋น์ - { wch: 8 }, // ์ฐ์ ์์ - { wch: 25 }, // ์ค๋ช
- { wch: 25 }, // ๋
ธํธ - { wch: 8 }, // ์๋ฆผ์ผ์ - ] - - const workbook = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(workbook, worksheet, "์คํ
์ด์ง์์ธ") - - const filename = `์คํ
์ด์ง์์ธ_${new Date().toISOString().split('T')[0]}.xlsx` - XLSX.writeFile(workbook, filename) -} - -// ============================================================================= -// 4. ์ ํธ๋ฆฌํฐ ํจ์๋ค -// ============================================================================= - -function getStatusText(status: string): string { - switch (status) { - case 'PLANNED': return '๊ณํ๋จ' - case 'IN_PROGRESS': return '์งํ์ค' - case 'SUBMITTED': return '์ ์ถ๋จ' - case 'UNDER_REVIEW': return '๊ฒํ ์ค' - case 'APPROVED': return '์น์ธ๋จ' - case 'REJECTED': return '๋ฐ๋ ค๋จ' - case 'COMPLETED': return '์๋ฃ๋จ' - default: return status - } -} - -function getPriorityText(priority: string): string { - switch (priority) { - case 'HIGH': return '๋์' - case 'MEDIUM': return '๋ณดํต' - case 'LOW': return '๋ฎ์' - default: return priority - } -} - -// ํ์ผ ํฌ๊ธฐ ๊ฒ์ฆ -export function validateFileSize(file: File, maxSizeMB: number = 10): boolean { - const maxSizeBytes = maxSizeMB * 1024 * 1024 - return file.size <= maxSizeBytes -} - -// ํ์ผ ํ์ฅ์ ๊ฒ์ฆ -export function validateFileExtension(file: File): boolean { - const allowedExtensions = ['.xlsx', '.xls'] - const fileName = file.name.toLowerCase() - return allowedExtensions.some(ext => fileName.endsWith(ext)) -} - -// ExcelJS ์ํฌ๋ถ์ ์ ํจ์ฑ ๊ฒ์ฌ -export async function validateExcelWorkbook(file: File): Promise<{ - isValid: boolean - error?: string - worksheetCount?: number - firstWorksheetName?: string -}> { - try { - const buffer = await file.arrayBuffer() - const workbook = new ExcelJS.Workbook() - await workbook.xlsx.load(buffer) - - const worksheets = workbook.worksheets - if (worksheets.length === 0) { - return { - isValid: false, - error: '์ํฌ์ํธ๊ฐ ์๋ ํ์ผ์
๋๋ค' - } - } - - const firstWorksheet = worksheets[0] - if (firstWorksheet.rowCount < 2) { - return { - isValid: false, - error: '๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. ์ต์ ํค๋์ 1๊ฐ ํ์ด ํ์ํฉ๋๋ค' - } - } - - return { - isValid: true, - worksheetCount: worksheets.length, - firstWorksheetName: firstWorksheet.name - } - } catch (error) { - return { - isValid: false, - error: `ํ์ผ์ ์ฝ์ ์ ์์ต๋๋ค: ${error instanceof Error ? error.message : '์ ์ ์๋ ์ค๋ฅ'}` - } - } -} - -// ์
๊ฐ์ ์์ ํ๊ฒ ๋ฌธ์์ด๋ก ๋ณํ -export function getCellValueAsString(cell: ExcelJS.Cell): string { - if (!cell.value) return "" - - if (cell.value instanceof Date) { - return cell.value.toISOString().split('T')[0] - } - - if (typeof cell.value === 'object' && 'text' in cell.value) { - return cell.value.text || "" - } - - if (typeof cell.value === 'object' && 'result' in cell.value) { - return String(cell.value.result || "") - } - - return String(cell.value) -} - -// ์์
์ปฌ๋ผ ์ธ๋ฑ์ค๋ฅผ ๋ฌธ์๋ก ๋ณํ (A, B, C, ... Z, AA, AB, ...) -export function getExcelColumnName(index: number): string { - let result = "" - while (index > 0) { - index-- - result = String.fromCharCode(65 + (index % 26)) + result - index = Math.floor(index / 26) - } - return result -}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx b/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx new file mode 100644 index 00000000..8dc85c51 --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx @@ -0,0 +1,899 @@ +"use client" + +import React from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, 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 { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import ExcelJS from "exceljs" +import { + getDocumentClassOptionsByContract, + uploadImportData, +} from "./document-stages-service" + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + +interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string + stages?: { stageName: string; planDate: string }[] +} + +// ============================================================================= +// Main Component +// ============================================================================= +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + if (!validateFileExtension(selectedFile)) { + toast.error("Excel ํ์ผ(.xlsx, .xls)๋ง ์
๋ก๋ ๊ฐ๋ฅํฉ๋๋ค.") + return + } + if (!validateFileSize(selectedFile, 10)) { + toast.error("ํ์ผ ํฌ๊ธฐ๋ 10MB ์ดํ์ฌ์ผ ํฉ๋๋ค.") + return + } + setFile(selectedFile) + setImportResult(null) + } + } + + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `๋ฌธ์_์ํฌํธ_ํ
ํ๋ฆฟ_${projectType}_${new Date().toISOString().split("T")[0]}.xlsx` + link.click() + window.URL.revokeObjectURL(url) + toast.success("ํ
ํ๋ฆฟ ํ์ผ์ด ๋ค์ด๋ก๋๋์์ต๋๋ค.") + } catch (error) { + toast.error("ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: " + (error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ")) + } finally { + setIsDownloadingTemplate(false) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("ํ์ผ์ ์ ํํด์ฃผ์ธ์.") + return + } + setIsProcessing(true) + setProgress(0) + try { + setProcessStep("ํ์ผ ์ฝ๋ ์ค...") + setProgress(20) + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("๋ฐ์ดํฐ ๊ฒ์ฆ ์ค...") + setProgress(40) + const worksheet = workbook.getWorksheet("Documents") || workbook.getWorksheet(1) + if (!worksheet) throw new Error("Documents ์ํธ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.") + + setProcessStep("๋ฌธ์ ๋ฐ ์คํ
์ด์ง ๋ฐ์ดํฐ ํ์ฑ ์ค...") + setProgress(60) + const parseResult = await parseDocumentsWithStages(worksheet, projectType, contractId) + + setProcessStep("์๋ฒ์ ์
๋ก๋ ์ค...") + setProgress(90) + const allStages: any[] = [] + parseResult.validData.forEach((doc) => { + if (doc.stages) { + doc.stages.forEach((stage) => { + allStages.push({ + docNumber: doc.docNumber, + stageName: stage.stageName, + planDate: stage.planDate, + }) + }) + } + }) + + const result = await uploadImportData({ + contractId, + documents: parseResult.validData, + stages: allStages, + projectType, + }) + + if (result.success) { + setImportResult({ + documents: parseResult.validData, + stages: allStages, + errors: parseResult.errors, + warnings: result.warnings || [], + }) + setProgress(100) + toast.success(`${parseResult.validData.length}๊ฐ ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค.`) + } else { + throw new Error(result.error || "์ํฌํธ์ ์คํจํ์ต๋๋ค.") + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "์ํฌํธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ"], + warnings: [], + }) + } finally { + setIsProcessing(false) + setProcessStep("") + setProgress(0) + } + } + + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + router.refresh() + handleClose() + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Excel ํ์ผ ์ํฌํธ + </DialogTitle> + <DialogDescription> + Excel ํ์ผ์ ์ฌ์ฉํ์ฌ ๋ฌธ์์ ์คํ
์ด์ง ๊ณํ์ ์ผ๊ด ๋ฑ๋กํฉ๋๋ค. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์น์
*/} + <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> + <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. ํ
ํ๋ฆฟ ๋ค์ด๋ก๋</h4> + <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> + ์ฌ๋ฐ๋ฅธ ํ์๊ณผ ์ค๋งํธ ๊ฒ์ฆ์ด ์ ์ฉ๋ ํ
ํ๋ฆฟ์ ๋ค์ด๋ก๋ํ์ธ์. + </p> + <Button variant="outline" size="sm" onClick={handleDownloadTemplate} disabled={isDownloadingTemplate}> + {isDownloadingTemplate ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />} + ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ + </Button> + </div> + + {/* ํ์ผ ์
๋ก๋ ์น์
*/} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. ํ์ผ ์
๋ก๋</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel ํ์ผ ์ ํ</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> + ์ ํ๋ ํ์ผ: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* ์งํ ์ํ */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">์ฒ๋ฆฌ ์ค...</span> + </div> + <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> + )} + + {/* ์ํฌํธ ๊ฒฐ๊ณผ */} + {importResult && <ImportResultDisplay importResult={importResult} />} + + {/* ํ์ผ ํ์ ๊ฐ์ด๋ */} + <FileFormatGuide projectType={projectType} /> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "๋ซ๊ธฐ" : "์ทจ์"} + </Button> + {!importResult ? ( + <Button onClick={handleImport} disabled={!file || isProcessing}> + {isProcessing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />} + {isProcessing ? "์ฒ๋ฆฌ ์ค..." : "์ํฌํธ ์์"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}>์๋ฃ ๋ฐ ์๋ก๊ณ ์นจ</Button> + ) : null} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// Sub Components +// ============================================================================= +function ImportResultDisplay({ importResult }: { importResult: ImportResult }) { + return ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}๊ฐ</strong> ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค. + {importResult.stages.length > 0 && <> ({importResult.stages.length}๊ฐ ์คํ
์ด์ง ๊ณํ๋ ์ง ํฌํจ)</>} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>๊ฒฝ๊ณ :</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm"> + {warning} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>์ค๋ฅ:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm"> + {error} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + ) +} + +function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) { + return ( + <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">ํ์ผ ํ์ ๊ฐ์ด๋</h4> + <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> + <p> + <strong>ํตํฉ Documents ์ํธ:</strong> + </p> + <ul className="ml-4 list-disc"> + <li>Document Number* (๋ฌธ์๋ฒํธ)</li> + <li>Document Name* (๋ฌธ์๋ช
)</li> + <li>Document Class* (๋ฌธ์ํด๋์ค - ๋๋กญ๋ค์ด ์ ํ)</li> + <li>Project Doc No.* (ํ๋ก์ ํธ ๋ฌธ์๋ฒํธ)</li> + <li>๊ฐ Stage Name ์ปฌ๋ผ (๊ณํ๋ ์ง ์
๋ ฅ: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600 dark:text-green-400"> + <strong>์ค๋งํธ ๊ฒ์ฆ ๊ธฐ๋ฅ:</strong> + </p> + <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> + <li>Document Class ๋๋กญ๋ค์ด์ผ๋ก ์ ํํ ๊ฐ ์ ํ</li> + <li>์ ํํ Class์ ๋ง์ง ์๋ Stage๋ ์๋์ผ๋ก ํ์ ์ฒ๋ฆฌ</li> + <li>์๋ชป๋ Stage์ ๋ ์ง ์
๋ ฅ์ ๋นจ๊ฐ์์ผ๋ก ๊ฒฝ๊ณ </li> + <li>๋ ์ง ํ์ ์๋ ๊ฒ์ฆ</li> + </ul> + <p className="mt-2 text-yellow-600 dark:text-yellow-400"> + <strong>์์ ๊ฐ์ด๋:</strong> + </p> + <ul className="ml-4 list-disc text-yellow-600 dark:text-yellow-400"> + <li>๐ฆ ํ๋์ ํค๋: ํ์ ์
๋ ฅ ํญ๋ชฉ</li> + <li>๐ฉ ์ด๋ก์ ํค๋: ํด๋น Class์ ์ ํจํ Stage</li> + <li>โฌ ํ์ ์
: ํด๋น Class์์ ์ฌ์ฉ ๋ถ๊ฐ๋ฅํ Stage</li> + <li>๐ฅ ๋นจ๊ฐ์ ์
: ์๋ชป๋ ์
๋ ฅ (๊ฒ์ฆ ์คํจ)</li> + </ul> + <p className="mt-2 text-red-600 dark:text-red-400">* ํ์ ํญ๋ชฉ</p> + </div> + </div> + ) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +function validateFileExtension(file: File): boolean { + const allowedExtensions = [".xlsx", ".xls"] + const fileName = file.name.toLowerCase() + return allowedExtensions.some((ext) => fileName.endsWith(ext)) +} + +function validateFileSize(file: File, maxSizeMB: number): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +function styleHeaderRow( + headerRow: ExcelJS.Row, + bgColor: string = "FF4472C4", + startCol?: number, + endCol?: number +) { + const start = startCol || 1 + const end = endCol || headerRow.cellCount + + for (let i = start; i <= end; i++) { + const cell = headerRow.getCell(i) + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + } + cell.font = { + color: { argb: "FFFFFFFF" }, + bold: true, + } + cell.alignment = { + horizontal: "center", + vertical: "middle", + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + } + headerRow.height = 20 +} + +// ============================================================================= +// Template Creation - ํตํฉ ์ํธ + ์กฐ๊ฑด๋ถ์์/๊ฒ์ฆ +// ============================================================================= +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) throw new Error(res.error || "๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ") + + const documentClasses = res.data.classes as Array<{ id: number; code: string; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // ํด๋์ค๋ณ ์ต์
๋งต + const optionsByClassId = new Map<number, string[]>() + for (const c of documentClasses) optionsByClassId.set(c.id, []) + for (const o of options) optionsByClassId.get(o.documentClassId)!.push(o.optionValue) + + // ์ ๋ํฌ Stage + const allStageNames = Array.from(new Set(options.map((o) => o.optionValue))) + + const workbook = new ExcelJS.Workbook() + // ํ์ผ ์ด ๋ ๊ฐ์ ์ ์ฒด ๊ณ์ฐ + workbook.calcProperties.fullCalcOnLoad = true + + // ================= Documents ์ํธ ================= + const worksheet = workbook.addWorksheet("Documents") + + const headers = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Project Doc No.*"] : []), + ...allStageNames, + ] + const headerRow = worksheet.addRow(headers) + + // ํ์ ํค๋ (ํ๋) + const requiredCols = projectType === "plant" ? 4 : 3 + styleHeaderRow(headerRow, "FF4472C4", 1, requiredCols) + // Stage ํค๋ (์ด๋ก) + styleHeaderRow(headerRow, "FF27AE60", requiredCols + 1, headers.length) + + // ์ํ ๋ฐ์ดํฐ + const firstClass = documentClasses[0] + const firstClassStages = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : [] + const sampleRow = [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + "์ํ ๋ฌธ์๋ช
", + firstClass ? firstClass.description : "", + ...(projectType === "plant" ? ["V-001"] : []), + ...allStageNames.map((s) => (firstClassStages.includes(s) ? "2024-03-01" : "")), + ] + worksheet.addRow(sampleRow) + + const docNumberColIndex = 1; // A: Document Number* + const docNameColIndex = 2; // B: Document Name* + const docNumberColLetter = getExcelColumnName(docNumberColIndex); + const docNameColLetter = getExcelColumnName(docNameColIndex); + + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Document Number๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + // 1) ๋น๊ฐ ๊ธ์ง (๊ธธ์ด > 0) + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Document Number๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + + // ๋๋กญ๋ค์ด: Document Class + const docClassColIndex = 3 // "Document Class*"๋ ํญ์ 3์ด + const docClassColLetter = getExcelColumnName(docClassColIndex) + worksheet.dataValidations.add(`${docClassColLetter}2:${docClassColLetter}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`], + showErrorMessage: true, + errorTitle: "์๋ชป๋ ์
๋ ฅ", + error: "๋๋กญ๋ค์ด ๋ชฉ๋ก์์ Document Class๋ฅผ ์ ํํ์ธ์.", + }) + + // 2) ์ค๋ณต ๊ธ์ง (COUNTIF๋ก ํ์ฌ ๊ฐ์ด ๋ฒ์์์ 1ํ๋ง ๋ฑ์ฅํด์ผ ํจ) + // - Validation์ ํ ์
์ 1๊ฐ๋ง ๊ฐ๋ฅํ๋ฏ๋ก, ์ค๋ณต ๊ฒ์ฆ์ "Custom" ํ๋๋ก ํตํฉํ๋ ๋ฐฉ๋ฒ๋ ์์. + // - ์ฌ๊ธฐ์๋ '์ค๋ณต ๊ธ์ง'๋ฅผ ์ถ๊ฐ์ ์ผ๋ก **Guidance์ฉ**์ผ๋ก Conditional Formatting(๋นจ๊ฐ์)์ผ๋ก ๊ฐ์ํํฉ๋๋ค. + worksheet.addConditionalFormatting({ + ref: `${docNumberColLetter}2:${docNumberColLetter}1000`, + rules: [ + // ๋น๊ฐ ๋นจ๊ฐ + { + type: "expression", + formulae: [`LEN(${docNumberColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // ์ค๋ณต ๋นจ๊ฐ: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${docNumberColLetter}$2:$${docNumberColLetter}$1000,${docNumberColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + + + // ===== Document Name* (B์ด): ๋น๊ฐ ๊ธ์ง + ๋น์นธ ๋นจ๊ฐ ===== +worksheet.dataValidations.add(`${docNameColLetter}2:${docNameColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Document Name์ ํ์ ํญ๋ชฉ์
๋๋ค.", +}); + +worksheet.addConditionalFormatting({ + ref: `${docNameColLetter}2:${docNameColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docNameColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Document Class* (C์ด): ๋๋กญ๋ค์ด + allowBlank:false๋ก ์ฐจ๋จ์ ๋์ด ์์ โ ๋น์นธ ๋นจ๊ฐ๋ง ์ถ๊ฐ ===== +worksheet.addConditionalFormatting({ + ref: `${docClassColLetter}2:${docClassColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docClassColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Project Doc No.* (Plant ์ ์ฉ): (์ด๋ฏธ ์์ฑํ์ ์ฝ๋ ์ ์ง) ===== +if (projectType === "plant") { + const vendorDocColIndex = 4; // D + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Project Doc No.๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // ์ค๋ณต ๋นจ๊ฐ: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${vendorDocColLetter}$2:$${vendorDocColLetter}$1000,${vendorDocColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + +} + + if (projectType === "plant") { + const vendorDocColIndex = 4; // Document Number, Name, Class ๋ค์์ด Project Doc No.* + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + // ๊ณต๋ฐฑ ๋ถ๊ฐ: ๊ธ์์ > 0 + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Project Doc No.๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + // UX: ๋น์ด์์ผ๋ฉด ๋นจ๊ฐ ๋ฐฐ๊ฒฝ์ผ๋ก ํ์ (์กฐ๊ฑด๋ถ ์์) + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, // ์ฐํ ๋นจ๊ฐ + }, + }, + ], + }); + } + + // ๋ ์ง ์
ํ์ + ๊ฒ์ฆ/์กฐ๊ฑด๋ถ์์ + const stageStartCol = requiredCols + 1 + const stageEndCol = stageStartCol + allStageNames.length - 1 + + // ================= ๋งคํธ๋ฆญ์ค ์ํธ (Class-Stage Matrix) ================= + const matrixSheet = workbook.addWorksheet("Class-Stage Matrix") + const matrixHeaders = ["Document Class", ...allStageNames] + const matrixHeaderRow = matrixSheet.addRow(matrixHeaders) + styleHeaderRow(matrixHeaderRow, "FF34495E") + for (const docClass of documentClasses) { + const validStages = new Set(optionsByClassId.get(docClass.id) ?? []) + const row = [docClass.description, ...allStageNames.map((stage) => (validStages.has(stage) ? "โ" : ""))] + const dataRow = matrixSheet.addRow(row) + allStageNames.forEach((stage, idx) => { + const cell = dataRow.getCell(idx + 2) + if (validStages.has(stage)) { + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFD4EDDA" } } + cell.font = { color: { argb: "FF28A745" } } + } + }) + } + matrixSheet.columns = [{ width: 30 }, ...allStageNames.map(() => ({ width: 15 }))] + + // ๋งคํธ๋ฆญ์ค ๋ฒ์ ๊ณ์ฐ (B ~ ๋ง์ง๋ง Stage ์ด) + const matrixStageFirstColLetter = "B" + const matrixStageLastColLetter = getExcelColumnName(1 + allStageNames.length) // 1:A, 2:B ... (A๋ Class, B๋ถํฐ Stage) + const matrixClassCol = "$A:$A" + const matrixHeaderRowRange = "$1:$1" + const matrixBodyRange = `$${matrixStageFirstColLetter}:$${matrixStageLastColLetter}` + + // ================= ๊ฐ์ด๋ ์ํธ ================= + const guideSheet = workbook.addWorksheet("์ฌ์ฉ ๊ฐ์ด๋") + const guideContent: string[][] = [ + ["๐ ํตํฉ ๋ฌธ์ ์ํฌํธ ๊ฐ์ด๋"], + [""], + ["1. ํ๋์ ์ํธ์์ ๋ชจ๋ ์ ๋ณด ๊ด๋ฆฌ"], + [" โข Document Number*: ๊ณ ์ ํ ๋ฌธ์ ๋ฒํธ"], + [" โข Document Name*: ๋ฌธ์๋ช
"], + [" โข Document Class*: ๋๋กญ๋ค์ด์์ ์ ํ"], + ...(projectType === "plant" ? [[" โข Project Doc No.: ๋ฒค๋ ๋ฌธ์ ๋ฒํธ"]] : []), + [" โข Stage ์ปฌ๋ผ๋ค: ๊ฐ ์คํ
์ด์ง์ ๊ณํ ๋ ์ง (YYYY-MM-DD)"], + [""], + ["2. ์ค๋งํธ ๊ฒ์ฆ ๊ธฐ๋ฅ"], + [" โข Document Class๋ฅผ ์ ํํ๋ฉด ํด๋นํ์ง ์๋ Stage๋ ์๋์ผ๋ก ๋นํ์ฑํ(ํ์)"], + [" โข ๋น์ ํจ Stage์ ๋ ์ง ์
๋ ฅ ์ ์
๋ ฅ ์์ฒด๊ฐ ๋งํ๊ณ ๊ฒฝ๊ณ ํ์"], + [" โข ๋ ์ง ํ์ ์๋ ๊ฒ์ฆ"], + [""], + ["3. Class-Stage Matrix ์ํธ ํ์ฉ"], + [" โข ๊ฐ Document Class๋ณ๋ก ์ฌ์ฉ ๊ฐ๋ฅํ Stage ํ์ธ"], + [" โข โ ํ์๊ฐ ์๋ Stage๋ง ํด๋น Class์์ ์ฌ์ฉ ๊ฐ๋ฅ"], + [""], + ["4. ์์ฑ ์์"], + [" โ Document Number, Name ์
๋ ฅ"], + [" โก Document Class ๋๋กญ๋ค์ด์์ ์ ํ"], + [" โข Class-Stage Matrix ํ์ธํ์ฌ ์ ํจํ Stage ํ์
"], + [" โฃ ํด๋น Stage ์ปฌ๋ผ์๋ง ๋ ์ง ์
๋ ฅ"], + [""], + ["5. ์ฃผ์์ฌํญ"], + [" โข * ํ์๋ ํ์ ํญ๋ชฉ"], + [" โข Document Number๋ ์ค๋ณต ๋ถ๊ฐ"], + [" โข ํด๋น Class์ ๋ง์ง ์๋ Stage์ ๋ ์ง ์
๋ ฅ ์ ๋ฌด์/์ฐจ๋จ"], + [" โข ๋ ์ง๋ YYYY-MM-DD ํ์ ์ค์"], + ] + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row) + if (i === 0) r.getCell(1).font = { bold: true, size: 14 } + else if (row[0] && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true } + }) + guideSheet.getColumn(1).width = 70 + + // ================= ReferenceData (์จ๊น) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }) + referenceSheet.getCell("A1").value = "DocumentClasses" + documentClasses.forEach((dc, idx) => { + referenceSheet.getCell(`A${idx + 2}`).value = dc.description + }) + + // ================= Stage ์ด๋ณ ์์/๊ฒ์ฆ ================= + // ๋ฌธ์ ์ํธ ์ปฌ๋ผ ๋๋น + worksheet.columns = [ + { width: 18 }, // Doc Number + { width: 30 }, // Doc Name + { width: 30 }, // Doc Class + ...(projectType === "plant" ? [{ width: 18 }] : []), + ...allStageNames.map(() => ({ width: 12 })), + ] + + // ๊ฐ Stage ์ด ์ฒ๋ฆฌ + for (let stageIdx = 0; stageIdx < allStageNames.length; stageIdx++) { + const colIndex = stageStartCol + stageIdx + const colLetter = getExcelColumnName(colIndex) + + // ๋ ์ง ํ์ ํ์ + for (let row = 2; row <= 1000; row++) { + worksheet.getCell(`${colLetter}${row}`).numFmt = "yyyy-mm-dd" + } + + // ---- ์ปค์คํ
๋ฐ์ดํฐ ๊ฒ์ฆ (๋น์นธ OR (ํด๋น Class์ ์ ํจํ Stage AND ์ซ์(=๋ ์ง))) ---- + // INDEX('Class-Stage Matrix'!$B:$ZZ, MATCH($C2,'Class-Stage Matrix'!$A:$A,0), MATCH(H$1,'Class-Stage Matrix'!$1:$1,0)) + const validationFormula = + `=OR(` + + `LEN(${colLetter}2)=0,` + + `AND(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)<>\"\",` + + `ISNUMBER(${colLetter}2)` + + `)` + + `)` + worksheet.dataValidations.add(`${colLetter}2:${colLetter}1000`, { + type: "custom", + allowBlank: true, + formulae: [validationFormula], + showErrorMessage: true, + errorTitle: "ํ์ฉ๋์ง ์์ ์
๋ ฅ", + error: "์ด Stage๋ ์ ํํ Document Class์์ ์ฌ์ฉํ ์ ์๊ฑฐ๋ ๋ ์ง ํ์์ด ์๋๋๋ค.", + }) + + // ---- ์กฐ๊ฑด๋ถ ์์ (์ ํจํ์ง ์์ Stage โ ํ์ ๋ฐฐ๊ฒฝ) ---- + // TRUE์ด๋ฉด ์์ ์ ์ฉ: INDEX(...)="" -> ์ ํจํ์ง ์์ + const cfFormula = + `IFERROR(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)=\"\",` + + `TRUE` + // ๋งค์น ์คํจ ๋ฑ ์ค๋ฅ ์์๋ ํ์ ์ฒ๋ฆฌ + `)` + worksheet.addConditionalFormatting({ + ref: `${colLetter}2:${colLetter}1000`, + rules: [ + { + type: "expression", + formulae: [cfFormula], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFEFEFEF" } }, // ์ฐํ์ + }, + }, + ], + }) + } + + return workbook +} + +// ============================================================================= +// Parse Documents with Stages - ํตํฉ ํ์ฑ +// ============================================================================= +async function parseDocumentsWithStages( + worksheet: ExcelJS.Worksheet, + projectType: "ship" | "plant", + contractId: number +): Promise<{ validData: ParsedDocument[]; errors: string[] }> { + const documents: ParsedDocument[] = [] + const errors: string[] = [] + const seenDocNumbers = new Set<string>() + + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) { + errors.push("Document Class ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค") + return { validData: [], errors } + } + const documentClasses = res.data.classes as Array<{ id: number; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // ํด๋์ค๋ณ ์ ํจํ ์คํ
์ด์ง ๋งต + const validStagesByClass = new Map<string, Set<string>>() + for (const c of documentClasses) { + const stages = options.filter((o) => o.documentClassId === c.id).map((o) => o.optionValue) + validStagesByClass.set(c.description, new Set(stages)) + } + + // ํค๋ ํ์ฑ + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + const docNumberIdx = headers.findIndex((h) => h.includes("Document Number")) + const docNameIdx = headers.findIndex((h) => h.includes("Document Name")) + const docClassIdx = headers.findIndex((h) => h.includes("Document Class")) + const vendorDocNoIdx = projectType === "plant" ? headers.findIndex((h) => h.includes("Project Doc No")) : -1 + + if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) { + errors.push("ํ์ ํค๋๊ฐ ๋๋ฝ๋์์ต๋๋ค") + return { validData: [], errors } + } + + const stageStartIdx = projectType === "plant" ? 4 : 3 // headers slice ๊ธฐ์ค(0-index) + const stageHeaders = headers.slice(stageStartIdx) + + // ๋ฐ์ดํฐ ํ ํ์ฑ + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const docName = String(row.getCell(docNameIdx + 1).value || "").trim() + const docClass = String(row.getCell(docClassIdx + 1).value || "").trim() + const vendorDocNo = vendorDocNoIdx >= 0 ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim() : undefined + + if (!docNumber && !docName) return + if (!docNumber) { + errors.push(`ํ ${rowNumber}: Document Number๊ฐ ์์ต๋๋ค`) + return + } + if (!docName) { + errors.push(`ํ ${rowNumber}: Document Name์ด ์์ต๋๋ค`) + return + } + if (!docClass) { + errors.push(`ํ ${rowNumber}: Document Class๊ฐ ์์ต๋๋ค`) + return + } + if (projectType === "plant" && !vendorDocNo) { + errors.push(`ํ ${rowNumber}: Project Doc No.๊ฐ ์์ต๋๋ค`) + return + } + if (seenDocNumbers.has(docNumber)) { + errors.push(`ํ ${rowNumber}: ์ค๋ณต๋ Document Number: ${docNumber}`) + return + } + seenDocNumbers.add(docNumber) + + const validStages = validStagesByClass.get(docClass) + if (!validStages) { + errors.push(`ํ ${rowNumber}: ์ ํจํ์ง ์์ Document Class: ${docClass}`) + return + } + + + + const stages: { stageName: string; planDate: string }[] = [] + stageHeaders.forEach((stageName, idx) => { + if (validStages.has(stageName)) { + const cell = row.getCell(stageStartIdx + idx + 1) + let planDate = "" + if (cell.value) { + if (cell.value instanceof Date) { + planDate = cell.value.toISOString().split("T")[0] + } else { + const dateStr = String(cell.value).trim() + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) planDate = dateStr + } + if (planDate) stages.push({ stageName, planDate }) + } + } + }) + + documents.push({ + docNumber, + title: docName, + documentClass: docClass, + vendorDocNumber: vendorDocNo, + stages, + }) + }) + + return { validData: documents, errors } +} diff --git a/lib/vendor-document-list/plant/excel-import-stage copy.tsx b/lib/vendor-document-list/plant/excel-import-stage copy.tsx new file mode 100644 index 00000000..068383af --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-stage copy.tsx @@ -0,0 +1,908 @@ + + +"use client" + +import React from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, 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 { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import ExcelJS from 'exceljs' +import { + getDocumentClassOptionsByContract, + // These functions need to be implemented in document-stages-service + uploadImportData, +} from "./document-stages-service" + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + +// ============================================================================= +// Main Component +// ============================================================================= +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + // ํ์ผ ์ ํจ์ฑ ๊ฒ์ฌ + if (!validateFileExtension(selectedFile)) { + toast.error("Excel ํ์ผ(.xlsx, .xls)๋ง ์
๋ก๋ ๊ฐ๋ฅํฉ๋๋ค.") + return + } + + if (!validateFileSize(selectedFile, 10)) { + toast.error("ํ์ผ ํฌ๊ธฐ๋ 10MB ์ดํ์ฌ์ผ ํฉ๋๋ค.") + return + } + + setFile(selectedFile) + setImportResult(null) + } + } + + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `๋ฌธ์_์ํฌํธ_ํ
ํ๋ฆฟ_${projectType}_${new Date().toISOString().split('T')[0]}.xlsx` + link.click() + + window.URL.revokeObjectURL(url) + toast.success("ํ
ํ๋ฆฟ ํ์ผ์ด ๋ค์ด๋ก๋๋์์ต๋๋ค.") + } catch (error) { + toast.error("ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: " + (error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ")) + } finally { + setIsDownloadingTemplate(false) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("ํ์ผ์ ์ ํํด์ฃผ์ธ์.") + return + } + + setIsProcessing(true) + setProgress(0) + + try { + setProcessStep("ํ์ผ ์ฝ๋ ์ค...") + setProgress(20) + + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("๋ฐ์ดํฐ ๊ฒ์ฆ ์ค...") + setProgress(40) + + // ์ํฌ์ํธ ํ์ธ + const documentsSheet = workbook.getWorksheet('Documents') || workbook.getWorksheet(1) + const stagesSheet = workbook.getWorksheet('Stage Plan Dates') || workbook.getWorksheet(2) + + if (!documentsSheet) { + throw new Error("Documents ์ํธ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.") + } + + setProcessStep("๋ฌธ์ ๋ฐ์ดํฐ ํ์ฑ ์ค...") + setProgress(60) + + // ๋ฌธ์ ๋ฐ์ดํฐ ํ์ฑ + const documentData = await parseDocumentsSheet(documentsSheet, projectType) + + setProcessStep("์คํ
์ด์ง ๋ฐ์ดํฐ ํ์ฑ ์ค...") + setProgress(80) + + // ์คํ
์ด์ง ๋ฐ์ดํฐ ํ์ฑ (์ ํ์ฌํญ) + let stageData: any[] = [] + if (stagesSheet) { + stageData = await parseStagesSheet(stagesSheet) + } + + setProcessStep("์๋ฒ์ ์
๋ก๋ ์ค...") + setProgress(90) + + // ์๋ฒ๋ก ๋ฐ์ดํฐ ์ ์ก + const result = await uploadImportData({ + contractId, + documents: documentData.validData, + stages: stageData, + projectType + }) + + if (result.success) { + setImportResult({ + documents: documentData.validData, + stages: stageData, + errors: documentData.errors, + warnings: result.warnings || [] + }) + setProgress(100) + toast.success(`${documentData.validData.length}๊ฐ ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค.`) + } else { + throw new Error(result.error || "์ํฌํธ์ ์คํจํ์ต๋๋ค.") + } + + } catch (error) { + toast.error(error instanceof Error ? error.message : "์ํฌํธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ"], + warnings: [] + }) + } finally { + setIsProcessing(false) + setProcessStep("") + setProgress(0) + } + } + + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + router.refresh() + handleClose() + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Excel ํ์ผ ์ํฌํธ + </DialogTitle> + <DialogDescription> + Excel ํ์ผ์ ์ฌ์ฉํ์ฌ ๋ฌธ์๋ฅผ ์ผ๊ด ๋ฑ๋กํฉ๋๋ค. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์น์
*/} + <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> + <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. ํ
ํ๋ฆฟ ๋ค์ด๋ก๋</h4> + <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> + ์ฌ๋ฐ๋ฅธ ํ์๊ณผ ๋๋กญ๋ค์ด์ด ์ ์ฉ๋ ํ
ํ๋ฆฟ์ ๋ค์ด๋ก๋ํ์ธ์. + </p> + <Button + variant="outline" + size="sm" + onClick={handleDownloadTemplate} + disabled={isDownloadingTemplate} + > + {isDownloadingTemplate ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Download className="h-4 w-4 mr-2" /> + )} + ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ + </Button> + </div> + + {/* ํ์ผ ์
๋ก๋ ์น์
*/} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. ํ์ผ ์
๋ก๋</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel ํ์ผ ์ ํ</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> + ์ ํ๋ ํ์ผ: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* ์งํ ์ํ */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">์ฒ๋ฆฌ ์ค...</span> + </div> + <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> + )} + + {/* ์ํฌํธ ๊ฒฐ๊ณผ */} + {importResult && ( + <ImportResultDisplay importResult={importResult} /> + )} + + {/* ํ์ผ ํ์ ๊ฐ์ด๋ */} + <FileFormatGuide projectType={projectType} /> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "๋ซ๊ธฐ" : "์ทจ์"} + </Button> + {!importResult ? ( + <Button + onClick={handleImport} + disabled={!file || isProcessing} + > + {isProcessing ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Upload className="h-4 w-4 mr-2" /> + )} + {isProcessing ? "์ฒ๋ฆฌ ์ค..." : "์ํฌํธ ์์"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}> + ์๋ฃ ๋ฐ ์๋ก๊ณ ์นจ + </Button> + ) : null} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// Sub Components +// ============================================================================= +function ImportResultDisplay({ importResult }: { importResult: ImportResult }) { + return ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}๊ฐ</strong> ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค. + {importResult.stages.length > 0 && ( + <> ({importResult.stages.length}๊ฐ ์คํ
์ด์ง ๊ณํ๋ ์ง ํฌํจ)</> + )} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>๊ฒฝ๊ณ :</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm">{warning}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>์ค๋ฅ:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm">{error}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + ) +} + +function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) { + return ( + <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">ํ์ผ ํ์ ๊ฐ์ด๋</h4> + <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> + <p><strong>Documents ์ํธ:</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (๋ฌธ์๋ฒํธ)</li> + <li>Document Name* (๋ฌธ์๋ช
)</li> + <li>Document Class* (๋ฌธ์ํด๋์ค - ๋๋กญ๋ค์ด ์ ํ)</li> + {projectType === "plant" && ( + <li>Project Doc No. (๋ฒค๋๋ฌธ์๋ฒํธ)</li> + )} + </ul> + <p className="mt-2"><strong>Stage Plan Dates ์ํธ (์ ํ์ฌํญ):</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (๋ฌธ์๋ฒํธ)</li> + <li>Stage Name* (์คํ
์ด์ง๋ช
- ๋๋กญ๋ค์ด ์ ํ)</li> + <li>Plan Date (๊ณํ๋ ์ง: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600 dark:text-green-400"><strong>์ค๋งํธ ๊ธฐ๋ฅ:</strong></p> + <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> + <li>Document Class๋ ๋๋กญ๋ค์ด์ผ๋ก ์ ํํ ๊ฐ๋ง ์ ํ ๊ฐ๋ฅ</li> + <li>Stage Name๋ ๋๋กญ๋ค์ด์ผ๋ก ์คํ ๋ฐฉ์ง</li> + <li>"์ฌ์ฉ ๊ฐ์ด๋" ์ํธ์์ ๊ฐ ํด๋์ค๋ณ ์ฌ์ฉ ๊ฐ๋ฅํ ์คํ
์ด์ง ํ์ธ ๊ฐ๋ฅ</li> + </ul> + <p className="mt-2 text-red-600 dark:text-red-400">* ํ์ ํญ๋ชฉ</p> + </div> + </div> + ) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +function validateFileExtension(file: File): boolean { + const allowedExtensions = ['.xlsx', '.xls'] + const fileName = file.name.toLowerCase() + return allowedExtensions.some(ext => fileName.endsWith(ext)) +} + +function validateFileSize(file: File, maxSizeMB: number): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +// ExcelJS ์ปฌ๋ผ ์ธ๋ฑ์ค๋ฅผ ๋ฌธ์๋ก ๋ณํ (A, B, C, ... Z, AA, AB, ...) +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +// ํค๋ ํ ์คํ์ผ๋ง ํจ์ +function styleHeaderRow(headerRow: ExcelJS.Row, bgColor: string = 'FF4472C4') { + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: bgColor } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + if (String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) +} + +// ํ
ํ๋ฆฟ ์์ฑ ํจ์ - Stage Plan Dates ๋ถ๋ถ ์์ +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) throw new Error(res.error || "๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ") + + const documentClasses = res.data.classes // [{id, code, description}] + const options = res.data.options // [{documentClassId, optionValue, ...}] + + // ํด๋์ค๋ณ ์ต์
๋งต + const optionsByClassId = new Map<number, string[]>() + for (const c of documentClasses) optionsByClassId.set(c.id, []) + for (const o of options) { + optionsByClassId.get(o.documentClassId)?.push(o.optionValue) + } + + // ๋ชจ๋ ์คํ
์ด์ง ๋ช
(์ ๋ํฌ) + const allStageNames = Array.from(new Set(options.map(o => o.optionValue))) + + const workbook = new ExcelJS.Workbook() + + // ================= Documents (์ฒซ ๋ฒ์งธ ์ํธ) ================= + const documentsSheet = workbook.addWorksheet("Documents") + const documentHeaders = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Project Doc No."] : []), + "Notes", + ] + const documentHeaderRow = documentsSheet.addRow(documentHeaders) + styleHeaderRow(documentHeaderRow) + + const sampleDocumentData = + projectType === "ship" + ? [ + "SH-2024-001", + "๊ธฐ๋ณธ ์ค๊ณ ๋๋ฉด", + documentClasses[0] ? `${documentClasses[0].description}` : "", + "์ฐธ๊ณ ์ฌํญ", + ] + : [ + "PL-2024-001", + "๊ณต์ ์ค๊ณ ๋๋ฉด", + documentClasses[0] ? `${documentClasses[0].description}` : "", + "V-001", + "์ฐธ๊ณ ์ฌํญ", + ] + + documentsSheet.addRow(sampleDocumentData) + + // Document Class ๋๋กญ๋ค์ด + const docClassColIndex = 3 // C + const docClassCol = getExcelColumnName(docClassColIndex) + documentsSheet.dataValidations.add(`${docClassCol}2:${docClassCol}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`], + }) + + documentsSheet.columns = [ + { width: 15 }, + { width: 25 }, + { width: 28 }, + ...(projectType === "plant" ? [{ width: 18 }] : []), + { width: 24 }, + ] + + // ================= Stage Plan Dates (๋ ๋ฒ์งธ ์ํธ) - ์์ ๋จ ================= + const stagesSheet = workbook.addWorksheet("Stage Plan Dates") + + // Document Class Helper ์ปฌ๋ผ๊ณผ Valid Stage Helper ์ปฌ๋ผ ์ถ๊ฐ + const stageHeaderRow = stagesSheet.addRow([ + "Document Number*", + "Document Class", // Helper ์ปฌ๋ผ - ์๋์ผ๋ก ์ฑ์์ง + "Stage Name*", + "Plan Date", + "Valid Stages" // Helper ์ปฌ๋ผ - ์ ํจํ ์คํ
์ด์ง ๋ชฉ๋ก + ]) + styleHeaderRow(stageHeaderRow, "FF27AE60") + + const firstClass = documentClasses[0] + const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : [] + + // ์ํ ๋ฐ์ดํฐ + const sampleStageData = [ + [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + firstClass ? firstClass.description : "", + firstClassOpts[0] ?? "", + "2024-02-15", + firstClassOpts.join(", ") // ์ ํจํ ์คํ
์ด์ง ๋ชฉ๋ก + ], + [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + firstClass ? firstClass.description : "", + firstClassOpts[1] ?? "", + "2024-03-01", + firstClassOpts.join(", ") // ์ ํจํ ์คํ
์ด์ง ๋ชฉ๋ก + ], + ] + + sampleStageData.forEach(row => { + const r = stagesSheet.addRow(row) + r.getCell(4).numFmt = "yyyy-mm-dd" + }) + + // B์ด(Document Class)์ VLOOKUP ์์ ์ถ๊ฐ + for (let i = 3; i <= 1000; i++) { + const cell = stagesSheet.getCell(`B${i}`) + cell.value = { + formula: `IFERROR(VLOOKUP(A${i},Documents!A:C,3,FALSE),"")`, + result: "" + } + } + + + // E์ด(Valid Stages)์ ์์ ์ถ๊ฐ - Document Class์ ํด๋นํ๋ ์คํ
์ด์ง ๋ชฉ๋ก ํ์ + // MATCH์ OFFSET์ ์ฌ์ฉํ ๋์ ์ฐธ์กฐ + + + // Helper ์ปฌ๋ผ ์จ๊ธฐ๊ธฐ ์ต์
(B, E์ด) + stagesSheet.getColumn(2).hidden = false // Document Class๋ ๋ณด์ด๋๋ก (ํ์ธ์ฉ) + stagesSheet.getColumn(5).hidden = false // Valid Stages๋ ๋ณด์ด๋๋ก (๊ฐ์ด๋์ฉ) + + // Helper ์ปฌ๋ผ ์คํ์ผ๋ง + stagesSheet.getColumn(2).eachCell((cell, rowNumber) => { + if (rowNumber > 1) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF0F0F0' } // ์ฐํ ํ์ ๋ฐฐ๊ฒฝ + } + cell.protection = { locked: true } // ํธ์ง ๋ฐฉ์ง + } + }) + + stagesSheet.getColumn(5).eachCell((cell, rowNumber) => { + if (rowNumber > 1) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFF0E0' } // ์ฐํ ์ฃผํฉ์ ๋ฐฐ๊ฒฝ + } + cell.font = { size: 9, italic: true } + } + }) + + // Stage Name ๋๋กญ๋ค์ด - ์ ์ฒด ์คํ
์ด์ง ๋ชฉ๋ก ์ฌ์ฉ (ExcelJS ์ ์ฝ์ผ๋ก ์ธํด) + // ํ์ง๋ง ์กฐ๊ฑด๋ถ ์์์ผ๋ก ์๋ชป๋ ์ ํ ๊ฐ์กฐ + const allStagesCol = getExcelColumnName(documentClasses.length + 2) + stagesSheet.dataValidations.add("C3:C1000", { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!${allStagesCol}$2:${allStagesCol}${allStageNames.length + 1}`], + promptTitle: "Stage Name ์ ํ", + prompt: "Valid Stages ์ปฌ๋ผ์ ์ฐธ๊ณ ํ์ฌ ์ฌ๋ฐ๋ฅธ Stage๋ฅผ ์ ํํ์ธ์", + showErrorMessage: true, + errorTitle: "Stage ์ ํ ํ์ธ", + error: "Valid Stages ์ปฌ๋ผ์ ์๋ Stage๋ง ์ ํจํฉ๋๋ค" + }) + + // ์กฐ๊ฑด๋ถ ์์ ์ถ๊ฐ - ์๋ชป๋ Stage ์ ํ์ ๋นจ๊ฐ์ ํ์ + for (let i = 3; i <= 100; i++) { + try { + // COUNTIF๋ฅผ ์ฌ์ฉํ์ฌ ์ ํํ Stage๊ฐ Valid Stages์ ํฌํจ๋๋์ง ํ์ธ + const rule = { + type: 'expression', + formulae: [`ISERROR(SEARCH(C${i},E${i}))`], + style: { + fill: { + type: 'pattern', + pattern: 'solid', + bgColor: { argb: 'FFFF0000' } // ๋นจ๊ฐ์ ๋ฐฐ๊ฒฝ + }, + font: { + color: { argb: 'FFFFFFFF' } // ํฐ์ ๊ธ์ + } + } + } + stagesSheet.addConditionalFormatting({ + ref: `C${i}`, + rules: [rule] + }) + } catch (e) { + console.warn(`Row ${i}: ์กฐ๊ฑด๋ถ ์์ ์ถ๊ฐ ์คํจ`) + } + } + + stagesSheet.columns = [ + { width: 15 }, // Document Number + { width: 20 }, // Document Class (Helper) + { width: 30 }, // Stage Name + { width: 12 }, // Plan Date + { width: 50 } // Valid Stages (Helper) + ] + + // ================= ์ฌ์ฉ ๊ฐ์ด๋ (์ธ ๋ฒ์งธ ์ํธ) - ์์ ๋จ ================= + const guideSheet = workbook.addWorksheet("์ฌ์ฉ ๊ฐ์ด๋") + const guideContent: (string[])[] = [ + ["๋ฌธ์ ์ํฌํธ ๊ฐ์ด๋"], + [""], + ["1. Documents ์ํธ"], + [" - Document Number*: ๊ณ ์ ํ ๋ฌธ์ ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์"], + [" - Document Name*: ๋ฌธ์๋ช
์ ์
๋ ฅํ์ธ์"], + [" - Document Class*: ๋๋กญ๋ค์ด์์ ๋ฌธ์ ํด๋์ค๋ฅผ ์ ํํ์ธ์"], + [" - Project Doc No.: ๋ฒค๋ ๋ฌธ์ ๋ฒํธ (Plant ํ๋ก์ ํธ๋ง)"], + [" - Notes: ์ฐธ๊ณ ์ฌํญ"], + [""], + ["2. Stage Plan Dates ์ํธ (์ ํ์ฌํญ)"], + [" - Document Number*: Documents ์ํธ์ Document Number์ ์ผ์นํด์ผ ํฉ๋๋ค"], + [" - Document Class: Document Number ์
๋ ฅ์ ์๋์ผ๋ก ํ์๋ฉ๋๋ค (ํ์ ๋ฐฐ๊ฒฝ)"], + [" - Stage Name*: Valid Stages ์ปฌ๋ผ์ ์ฐธ๊ณ ํ์ฌ ํด๋น ํด๋์ค์ ์คํ
์ด์ง๋ฅผ ์ ํํ์ธ์"], + [" - Plan Date: ๊ณํ ๋ ์ง (YYYY-MM-DD ํ์)"], + [" - Valid Stages: ํด๋น Document Class์์ ์ ํ ๊ฐ๋ฅํ ์คํ
์ด์ง ๋ชฉ๋ก (์ฃผํฉ์ ๋ฐฐ๊ฒฝ)"], + [""], + ["3. Stage Name ์ ํ ๋ฐฉ๋ฒ"], + [" โ Document Number๋ฅผ ๋จผ์ ์
๋ ฅํฉ๋๋ค"], + [" โก Document Class๊ฐ ์๋์ผ๋ก ํ์๋ฉ๋๋ค"], + [" โข Valid Stages ์ปฌ๋ผ์์ ์ฌ์ฉ ๊ฐ๋ฅํ ์คํ
์ด์ง๋ฅผ ํ์ธํฉ๋๋ค"], + [" โฃ Stage Name ๋๋กญ๋ค์ด์์ Valid Stages์ ์๋ ํญ๋ชฉ๋ง ์ ํํฉ๋๋ค"], + [" โค ์๋ชป๋ ์คํ
์ด์ง ์ ํ์ ์
์ด ๋นจ๊ฐ์์ผ๋ก ํ์๋ฉ๋๋ค"], + [""], + ["4. ์ฃผ์์ฌํญ"], + [" - * ํ์๋ ํ์ ํญ๋ชฉ์
๋๋ค"], + [" - Document Number๋ ๊ณ ์ ํด์ผ ํฉ๋๋ค"], + [" - Stage Name์ Valid Stages์ ํ์๋ ๊ฒ๋ง ์ ํจํฉ๋๋ค"], + [" - ๋นจ๊ฐ์์ผ๋ก ํ์๋ Stage Name์ ์๋ชป๋ ์ ํ์
๋๋ค"], + [" - ๋ ์ง๋ YYYY-MM-DD ํ์์ผ๋ก ์
๋ ฅํ์ธ์"], + [""], + ["5. Document Class๋ณ ์ฌ์ฉ ๊ฐ๋ฅํ Stage Names"], + [""], + ] + + for (const c of documentClasses) { + guideContent.push([`${c.code} - ${c.description}:`]) + ;(optionsByClassId.get(c.id) ?? []).forEach(v => guideContent.push([` โข ${v}`])) + guideContent.push([""]) + } + + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row) + if (i === 0) r.getCell(1).font = { bold: true, size: 14 } + else if (row[0] && row[0].includes(":") && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true } + }) + guideSheet.getColumn(1).width = 60 + + // ================= ReferenceData (๋ง์ง๋ง ์ํธ, hidden) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }) + + let joinColStart = /* A=1 ๊ธฐ์ค, ๋น ์์ ํ ์์น ์ ํ */ documentClasses.length + 3; +const keyCol = getExcelColumnName(joinColStart); // ์: X +const joinedCol = getExcelColumnName(joinColStart+1); // ์: Y +referenceSheet.getCell(`${keyCol}1`).value = "DocClass"; +referenceSheet.getCell(`${joinedCol}1`).value = "JoinedStages"; + + // A์ด: DocumentClasses + referenceSheet.getCell("A1").value = "DocumentClasses" + documentClasses.forEach((dc, idx) => { + referenceSheet.getCell(`${keyCol}${idx+2}`).value = dc.description; + const stages = (optionsByClassId.get(dc.id) ?? []).join(", "); + referenceSheet.getCell(`${joinedCol}${idx+2}`).value = stages; + }); + + for (let i = 3; i <= 1000; i++) { + stagesSheet.getCell(`E${i}`).value = { + formula: `IFERROR("์ ํจํ ์คํ
์ด์ง: "&VLOOKUP(B${i},ReferenceData!$${keyCol}$2:$${joinedCol}$${documentClasses.length+1},2,FALSE),"Document Number๋ฅผ ๋จผ์ ์
๋ ฅํ์ธ์")`, + result: "" + }; + } + + + // B์ด๋ถํฐ: ๊ฐ ํด๋์ค์ Stage ์ต์
+ let currentCol = 2 // B + for (const docClass of documentClasses) { + const colLetter = getExcelColumnName(currentCol) + referenceSheet.getCell(`${colLetter}1`).value = docClass.description + + const list = optionsByClassId.get(docClass.id) ?? [] + list.forEach((v, i) => { + referenceSheet.getCell(`${colLetter}${i + 2}`).value = v + }) + + currentCol++ + } + + // ๋ง์ง๋ง ์ด: AllStageNames + referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames" + allStageNames.forEach((v, i) => { + referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v + }) + + return workbook +} + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string + } + + interface ParsedStage { + docNumber: string + stageName: string + planDate?: string + } + + interface ParseResult { + validData: ParsedDocument[] + errors: string[] + } + + +// ============================================================================= +// 1. Parse Documents Sheet +// ============================================================================= +export async function parseDocumentsSheet( + worksheet: ExcelJS.Worksheet, + projectType: "ship" | "plant" + ): Promise<ParseResult> { + const documents: ParsedDocument[] = [] + const errors: string[] = [] + const seenDocNumbers = new Set<string>() + + // ํค๋ ํ ํ์ธ + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + // ํค๋ ์ธ๋ฑ์ค ์ฐพ๊ธฐ + const docNumberIdx = headers.findIndex(h => h.includes("Document Number")) + const docNameIdx = headers.findIndex(h => h.includes("Document Name")) + const docClassIdx = headers.findIndex(h => h.includes("Document Class")) + const vendorDocNoIdx = projectType === "plant" + ? headers.findIndex(h => h.includes("Project Doc No")) + : -1 + const notesIdx = headers.findIndex(h => h.includes("Notes")) + + if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) { + errors.push("ํ์ ํค๋๊ฐ ๋๋ฝ๋์์ต๋๋ค: Document Number, Document Name, Document Class") + return { validData: [], errors } + } + + // ๋ฐ์ดํฐ ํ ํ์ฑ + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return // ํค๋ ํ ์คํต + + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const docName = String(row.getCell(docNameIdx + 1).value || "").trim() + const docClass = String(row.getCell(docClassIdx + 1).value || "").trim() + const vendorDocNo = vendorDocNoIdx >= 0 + ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim() + : undefined + const notes = notesIdx >= 0 + ? String(row.getCell(notesIdx + 1).value || "").trim() + : undefined + + // ๋น ํ ์คํต + if (!docNumber && !docName) return + + // ์ ํจ์ฑ ๊ฒ์ฌ + if (!docNumber) { + errors.push(`ํ ${rowNumber}: Document Number๊ฐ ์์ต๋๋ค`) + return + } + if (!docName) { + errors.push(`ํ ${rowNumber}: Document Name์ด ์์ต๋๋ค`) + return + } + if (!docClass) { + errors.push(`ํ ${rowNumber}: Document Class๊ฐ ์์ต๋๋ค`) + return + } + + // ์ค๋ณต ์ฒดํฌ + if (seenDocNumbers.has(docNumber)) { + errors.push(`ํ ${rowNumber}: ์ค๋ณต๋ Document Number: ${docNumber}`) + return + } + seenDocNumbers.add(docNumber) + + documents.push({ + docNumber, + title: docName, + documentClass: docClass, + vendorDocNumber: vendorDocNo || undefined, + notes: notes || undefined + }) + }) + + return { validData: documents, errors } + } + + + +// parseStagesSheet ํจ์๋ ์์ ์ด ํ์ํฉ๋๋ค +export async function parseStagesSheet(worksheet: ExcelJS.Worksheet): Promise<ParsedStage[]> { + const stages: ParsedStage[] = [] + + // ํค๋ ํ ํ์ธ + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + // ํค๋ ์ธ๋ฑ์ค ์ฐพ๊ธฐ (Helper ์ปฌ๋ผ๋ค ๊ณ ๋ ค) + const docNumberIdx = headers.findIndex(h => h.includes("Document Number")) + // Stage Name ์ฐพ๊ธฐ - "Stage Name*" ๋๋ "Stage Name"์ ์ฐพ์ + const stageNameIdx = headers.findIndex(h => h.includes("Stage Name") && !h.includes("Valid")) + const planDateIdx = headers.findIndex(h => h.includes("Plan Date")) + + if (docNumberIdx === -1 || stageNameIdx === -1) { + console.error("Stage Plan Dates ์ํธ์ ํ์ ํค๋๊ฐ ์์ต๋๋ค") + return [] + } + + // ๋ฐ์ดํฐ ํ ํ์ฑ + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return // ํค๋ ํ ์คํต + + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const stageName = String(row.getCell(stageNameIdx + 1).value || "").trim() + let planDate: string | undefined + + // Plan Date ํ์ฑ + if (planDateIdx >= 0) { + const planDateCell = row.getCell(planDateIdx + 1) + + if (planDateCell.value) { + // Date ๊ฐ์ฒด์ธ ๊ฒฝ์ฐ + if (planDateCell.value instanceof Date) { + planDate = planDateCell.value.toISOString().split('T')[0] + } + // ๋ฌธ์์ด์ธ ๊ฒฝ์ฐ + else { + const dateStr = String(planDateCell.value).trim() + // YYYY-MM-DD ํ์ ๊ฒ์ฆ + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + planDate = dateStr + } + } + } + } + + // ๋น ํ ์คํต + if (!docNumber && !stageName) return + + // ์ ํจ์ฑ ๊ฒ์ฌ + if (!docNumber || !stageName) { + console.warn(`ํ ${rowNumber}: Document Number ๋๋ Stage Name์ด ๋๋ฝ๋จ`) + return + } + + stages.push({ + docNumber, + stageName, + planDate + }) + }) + + return stages +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-stage.tsx b/lib/vendor-document-list/plant/excel-import-stage.tsx new file mode 100644 index 00000000..8dc85c51 --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-stage.tsx @@ -0,0 +1,899 @@ +"use client" + +import React from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, 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 { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import ExcelJS from "exceljs" +import { + getDocumentClassOptionsByContract, + uploadImportData, +} from "./document-stages-service" + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + +interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string + stages?: { stageName: string; planDate: string }[] +} + +// ============================================================================= +// Main Component +// ============================================================================= +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + if (!validateFileExtension(selectedFile)) { + toast.error("Excel ํ์ผ(.xlsx, .xls)๋ง ์
๋ก๋ ๊ฐ๋ฅํฉ๋๋ค.") + return + } + if (!validateFileSize(selectedFile, 10)) { + toast.error("ํ์ผ ํฌ๊ธฐ๋ 10MB ์ดํ์ฌ์ผ ํฉ๋๋ค.") + return + } + setFile(selectedFile) + setImportResult(null) + } + } + + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `๋ฌธ์_์ํฌํธ_ํ
ํ๋ฆฟ_${projectType}_${new Date().toISOString().split("T")[0]}.xlsx` + link.click() + window.URL.revokeObjectURL(url) + toast.success("ํ
ํ๋ฆฟ ํ์ผ์ด ๋ค์ด๋ก๋๋์์ต๋๋ค.") + } catch (error) { + toast.error("ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: " + (error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ")) + } finally { + setIsDownloadingTemplate(false) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("ํ์ผ์ ์ ํํด์ฃผ์ธ์.") + return + } + setIsProcessing(true) + setProgress(0) + try { + setProcessStep("ํ์ผ ์ฝ๋ ์ค...") + setProgress(20) + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("๋ฐ์ดํฐ ๊ฒ์ฆ ์ค...") + setProgress(40) + const worksheet = workbook.getWorksheet("Documents") || workbook.getWorksheet(1) + if (!worksheet) throw new Error("Documents ์ํธ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.") + + setProcessStep("๋ฌธ์ ๋ฐ ์คํ
์ด์ง ๋ฐ์ดํฐ ํ์ฑ ์ค...") + setProgress(60) + const parseResult = await parseDocumentsWithStages(worksheet, projectType, contractId) + + setProcessStep("์๋ฒ์ ์
๋ก๋ ์ค...") + setProgress(90) + const allStages: any[] = [] + parseResult.validData.forEach((doc) => { + if (doc.stages) { + doc.stages.forEach((stage) => { + allStages.push({ + docNumber: doc.docNumber, + stageName: stage.stageName, + planDate: stage.planDate, + }) + }) + } + }) + + const result = await uploadImportData({ + contractId, + documents: parseResult.validData, + stages: allStages, + projectType, + }) + + if (result.success) { + setImportResult({ + documents: parseResult.validData, + stages: allStages, + errors: parseResult.errors, + warnings: result.warnings || [], + }) + setProgress(100) + toast.success(`${parseResult.validData.length}๊ฐ ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค.`) + } else { + throw new Error(result.error || "์ํฌํธ์ ์คํจํ์ต๋๋ค.") + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "์ํฌํธ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "์ ์ ์๋ ์ค๋ฅ"], + warnings: [], + }) + } finally { + setIsProcessing(false) + setProcessStep("") + setProgress(0) + } + } + + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + router.refresh() + handleClose() + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Excel ํ์ผ ์ํฌํธ + </DialogTitle> + <DialogDescription> + Excel ํ์ผ์ ์ฌ์ฉํ์ฌ ๋ฌธ์์ ์คํ
์ด์ง ๊ณํ์ ์ผ๊ด ๋ฑ๋กํฉ๋๋ค. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ ์น์
*/} + <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> + <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. ํ
ํ๋ฆฟ ๋ค์ด๋ก๋</h4> + <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> + ์ฌ๋ฐ๋ฅธ ํ์๊ณผ ์ค๋งํธ ๊ฒ์ฆ์ด ์ ์ฉ๋ ํ
ํ๋ฆฟ์ ๋ค์ด๋ก๋ํ์ธ์. + </p> + <Button variant="outline" size="sm" onClick={handleDownloadTemplate} disabled={isDownloadingTemplate}> + {isDownloadingTemplate ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />} + ํ
ํ๋ฆฟ ๋ค์ด๋ก๋ + </Button> + </div> + + {/* ํ์ผ ์
๋ก๋ ์น์
*/} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. ํ์ผ ์
๋ก๋</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel ํ์ผ ์ ํ</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> + ์ ํ๋ ํ์ผ: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* ์งํ ์ํ */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">์ฒ๋ฆฌ ์ค...</span> + </div> + <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> + )} + + {/* ์ํฌํธ ๊ฒฐ๊ณผ */} + {importResult && <ImportResultDisplay importResult={importResult} />} + + {/* ํ์ผ ํ์ ๊ฐ์ด๋ */} + <FileFormatGuide projectType={projectType} /> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "๋ซ๊ธฐ" : "์ทจ์"} + </Button> + {!importResult ? ( + <Button onClick={handleImport} disabled={!file || isProcessing}> + {isProcessing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />} + {isProcessing ? "์ฒ๋ฆฌ ์ค..." : "์ํฌํธ ์์"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}>์๋ฃ ๋ฐ ์๋ก๊ณ ์นจ</Button> + ) : null} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// Sub Components +// ============================================================================= +function ImportResultDisplay({ importResult }: { importResult: ImportResult }) { + return ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}๊ฐ</strong> ๋ฌธ์๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์ํฌํธ๋์์ต๋๋ค. + {importResult.stages.length > 0 && <> ({importResult.stages.length}๊ฐ ์คํ
์ด์ง ๊ณํ๋ ์ง ํฌํจ)</>} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>๊ฒฝ๊ณ :</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm"> + {warning} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>์ค๋ฅ:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm"> + {error} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + ) +} + +function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) { + return ( + <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">ํ์ผ ํ์ ๊ฐ์ด๋</h4> + <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> + <p> + <strong>ํตํฉ Documents ์ํธ:</strong> + </p> + <ul className="ml-4 list-disc"> + <li>Document Number* (๋ฌธ์๋ฒํธ)</li> + <li>Document Name* (๋ฌธ์๋ช
)</li> + <li>Document Class* (๋ฌธ์ํด๋์ค - ๋๋กญ๋ค์ด ์ ํ)</li> + <li>Project Doc No.* (ํ๋ก์ ํธ ๋ฌธ์๋ฒํธ)</li> + <li>๊ฐ Stage Name ์ปฌ๋ผ (๊ณํ๋ ์ง ์
๋ ฅ: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600 dark:text-green-400"> + <strong>์ค๋งํธ ๊ฒ์ฆ ๊ธฐ๋ฅ:</strong> + </p> + <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> + <li>Document Class ๋๋กญ๋ค์ด์ผ๋ก ์ ํํ ๊ฐ ์ ํ</li> + <li>์ ํํ Class์ ๋ง์ง ์๋ Stage๋ ์๋์ผ๋ก ํ์ ์ฒ๋ฆฌ</li> + <li>์๋ชป๋ Stage์ ๋ ์ง ์
๋ ฅ์ ๋นจ๊ฐ์์ผ๋ก ๊ฒฝ๊ณ </li> + <li>๋ ์ง ํ์ ์๋ ๊ฒ์ฆ</li> + </ul> + <p className="mt-2 text-yellow-600 dark:text-yellow-400"> + <strong>์์ ๊ฐ์ด๋:</strong> + </p> + <ul className="ml-4 list-disc text-yellow-600 dark:text-yellow-400"> + <li>๐ฆ ํ๋์ ํค๋: ํ์ ์
๋ ฅ ํญ๋ชฉ</li> + <li>๐ฉ ์ด๋ก์ ํค๋: ํด๋น Class์ ์ ํจํ Stage</li> + <li>โฌ ํ์ ์
: ํด๋น Class์์ ์ฌ์ฉ ๋ถ๊ฐ๋ฅํ Stage</li> + <li>๐ฅ ๋นจ๊ฐ์ ์
: ์๋ชป๋ ์
๋ ฅ (๊ฒ์ฆ ์คํจ)</li> + </ul> + <p className="mt-2 text-red-600 dark:text-red-400">* ํ์ ํญ๋ชฉ</p> + </div> + </div> + ) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +function validateFileExtension(file: File): boolean { + const allowedExtensions = [".xlsx", ".xls"] + const fileName = file.name.toLowerCase() + return allowedExtensions.some((ext) => fileName.endsWith(ext)) +} + +function validateFileSize(file: File, maxSizeMB: number): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +function styleHeaderRow( + headerRow: ExcelJS.Row, + bgColor: string = "FF4472C4", + startCol?: number, + endCol?: number +) { + const start = startCol || 1 + const end = endCol || headerRow.cellCount + + for (let i = start; i <= end; i++) { + const cell = headerRow.getCell(i) + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + } + cell.font = { + color: { argb: "FFFFFFFF" }, + bold: true, + } + cell.alignment = { + horizontal: "center", + vertical: "middle", + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + } + headerRow.height = 20 +} + +// ============================================================================= +// Template Creation - ํตํฉ ์ํธ + ์กฐ๊ฑด๋ถ์์/๊ฒ์ฆ +// ============================================================================= +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) throw new Error(res.error || "๋ฐ์ดํฐ ๋ก๋ฉ ์คํจ") + + const documentClasses = res.data.classes as Array<{ id: number; code: string; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // ํด๋์ค๋ณ ์ต์
๋งต + const optionsByClassId = new Map<number, string[]>() + for (const c of documentClasses) optionsByClassId.set(c.id, []) + for (const o of options) optionsByClassId.get(o.documentClassId)!.push(o.optionValue) + + // ์ ๋ํฌ Stage + const allStageNames = Array.from(new Set(options.map((o) => o.optionValue))) + + const workbook = new ExcelJS.Workbook() + // ํ์ผ ์ด ๋ ๊ฐ์ ์ ์ฒด ๊ณ์ฐ + workbook.calcProperties.fullCalcOnLoad = true + + // ================= Documents ์ํธ ================= + const worksheet = workbook.addWorksheet("Documents") + + const headers = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Project Doc No.*"] : []), + ...allStageNames, + ] + const headerRow = worksheet.addRow(headers) + + // ํ์ ํค๋ (ํ๋) + const requiredCols = projectType === "plant" ? 4 : 3 + styleHeaderRow(headerRow, "FF4472C4", 1, requiredCols) + // Stage ํค๋ (์ด๋ก) + styleHeaderRow(headerRow, "FF27AE60", requiredCols + 1, headers.length) + + // ์ํ ๋ฐ์ดํฐ + const firstClass = documentClasses[0] + const firstClassStages = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : [] + const sampleRow = [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + "์ํ ๋ฌธ์๋ช
", + firstClass ? firstClass.description : "", + ...(projectType === "plant" ? ["V-001"] : []), + ...allStageNames.map((s) => (firstClassStages.includes(s) ? "2024-03-01" : "")), + ] + worksheet.addRow(sampleRow) + + const docNumberColIndex = 1; // A: Document Number* + const docNameColIndex = 2; // B: Document Name* + const docNumberColLetter = getExcelColumnName(docNumberColIndex); + const docNameColLetter = getExcelColumnName(docNameColIndex); + + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Document Number๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + // 1) ๋น๊ฐ ๊ธ์ง (๊ธธ์ด > 0) + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Document Number๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + + // ๋๋กญ๋ค์ด: Document Class + const docClassColIndex = 3 // "Document Class*"๋ ํญ์ 3์ด + const docClassColLetter = getExcelColumnName(docClassColIndex) + worksheet.dataValidations.add(`${docClassColLetter}2:${docClassColLetter}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`], + showErrorMessage: true, + errorTitle: "์๋ชป๋ ์
๋ ฅ", + error: "๋๋กญ๋ค์ด ๋ชฉ๋ก์์ Document Class๋ฅผ ์ ํํ์ธ์.", + }) + + // 2) ์ค๋ณต ๊ธ์ง (COUNTIF๋ก ํ์ฌ ๊ฐ์ด ๋ฒ์์์ 1ํ๋ง ๋ฑ์ฅํด์ผ ํจ) + // - Validation์ ํ ์
์ 1๊ฐ๋ง ๊ฐ๋ฅํ๋ฏ๋ก, ์ค๋ณต ๊ฒ์ฆ์ "Custom" ํ๋๋ก ํตํฉํ๋ ๋ฐฉ๋ฒ๋ ์์. + // - ์ฌ๊ธฐ์๋ '์ค๋ณต ๊ธ์ง'๋ฅผ ์ถ๊ฐ์ ์ผ๋ก **Guidance์ฉ**์ผ๋ก Conditional Formatting(๋นจ๊ฐ์)์ผ๋ก ๊ฐ์ํํฉ๋๋ค. + worksheet.addConditionalFormatting({ + ref: `${docNumberColLetter}2:${docNumberColLetter}1000`, + rules: [ + // ๋น๊ฐ ๋นจ๊ฐ + { + type: "expression", + formulae: [`LEN(${docNumberColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // ์ค๋ณต ๋นจ๊ฐ: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${docNumberColLetter}$2:$${docNumberColLetter}$1000,${docNumberColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + + + // ===== Document Name* (B์ด): ๋น๊ฐ ๊ธ์ง + ๋น์นธ ๋นจ๊ฐ ===== +worksheet.dataValidations.add(`${docNameColLetter}2:${docNameColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Document Name์ ํ์ ํญ๋ชฉ์
๋๋ค.", +}); + +worksheet.addConditionalFormatting({ + ref: `${docNameColLetter}2:${docNameColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docNameColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Document Class* (C์ด): ๋๋กญ๋ค์ด + allowBlank:false๋ก ์ฐจ๋จ์ ๋์ด ์์ โ ๋น์นธ ๋นจ๊ฐ๋ง ์ถ๊ฐ ===== +worksheet.addConditionalFormatting({ + ref: `${docClassColLetter}2:${docClassColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docClassColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Project Doc No.* (Plant ์ ์ฉ): (์ด๋ฏธ ์์ฑํ์ ์ฝ๋ ์ ์ง) ===== +if (projectType === "plant") { + const vendorDocColIndex = 4; // D + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Project Doc No.๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // ์ค๋ณต ๋นจ๊ฐ: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${vendorDocColLetter}$2:$${vendorDocColLetter}$1000,${vendorDocColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + +} + + if (projectType === "plant") { + const vendorDocColIndex = 4; // Document Number, Name, Class ๋ค์์ด Project Doc No.* + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + // ๊ณต๋ฐฑ ๋ถ๊ฐ: ๊ธ์์ > 0 + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "ํ์ ์
๋ ฅ", + error: "Project Doc No.๋ ํ์ ํญ๋ชฉ์
๋๋ค.", + }); + + // UX: ๋น์ด์์ผ๋ฉด ๋นจ๊ฐ ๋ฐฐ๊ฒฝ์ผ๋ก ํ์ (์กฐ๊ฑด๋ถ ์์) + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, // ์ฐํ ๋นจ๊ฐ + }, + }, + ], + }); + } + + // ๋ ์ง ์
ํ์ + ๊ฒ์ฆ/์กฐ๊ฑด๋ถ์์ + const stageStartCol = requiredCols + 1 + const stageEndCol = stageStartCol + allStageNames.length - 1 + + // ================= ๋งคํธ๋ฆญ์ค ์ํธ (Class-Stage Matrix) ================= + const matrixSheet = workbook.addWorksheet("Class-Stage Matrix") + const matrixHeaders = ["Document Class", ...allStageNames] + const matrixHeaderRow = matrixSheet.addRow(matrixHeaders) + styleHeaderRow(matrixHeaderRow, "FF34495E") + for (const docClass of documentClasses) { + const validStages = new Set(optionsByClassId.get(docClass.id) ?? []) + const row = [docClass.description, ...allStageNames.map((stage) => (validStages.has(stage) ? "โ" : ""))] + const dataRow = matrixSheet.addRow(row) + allStageNames.forEach((stage, idx) => { + const cell = dataRow.getCell(idx + 2) + if (validStages.has(stage)) { + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFD4EDDA" } } + cell.font = { color: { argb: "FF28A745" } } + } + }) + } + matrixSheet.columns = [{ width: 30 }, ...allStageNames.map(() => ({ width: 15 }))] + + // ๋งคํธ๋ฆญ์ค ๋ฒ์ ๊ณ์ฐ (B ~ ๋ง์ง๋ง Stage ์ด) + const matrixStageFirstColLetter = "B" + const matrixStageLastColLetter = getExcelColumnName(1 + allStageNames.length) // 1:A, 2:B ... (A๋ Class, B๋ถํฐ Stage) + const matrixClassCol = "$A:$A" + const matrixHeaderRowRange = "$1:$1" + const matrixBodyRange = `$${matrixStageFirstColLetter}:$${matrixStageLastColLetter}` + + // ================= ๊ฐ์ด๋ ์ํธ ================= + const guideSheet = workbook.addWorksheet("์ฌ์ฉ ๊ฐ์ด๋") + const guideContent: string[][] = [ + ["๐ ํตํฉ ๋ฌธ์ ์ํฌํธ ๊ฐ์ด๋"], + [""], + ["1. ํ๋์ ์ํธ์์ ๋ชจ๋ ์ ๋ณด ๊ด๋ฆฌ"], + [" โข Document Number*: ๊ณ ์ ํ ๋ฌธ์ ๋ฒํธ"], + [" โข Document Name*: ๋ฌธ์๋ช
"], + [" โข Document Class*: ๋๋กญ๋ค์ด์์ ์ ํ"], + ...(projectType === "plant" ? [[" โข Project Doc No.: ๋ฒค๋ ๋ฌธ์ ๋ฒํธ"]] : []), + [" โข Stage ์ปฌ๋ผ๋ค: ๊ฐ ์คํ
์ด์ง์ ๊ณํ ๋ ์ง (YYYY-MM-DD)"], + [""], + ["2. ์ค๋งํธ ๊ฒ์ฆ ๊ธฐ๋ฅ"], + [" โข Document Class๋ฅผ ์ ํํ๋ฉด ํด๋นํ์ง ์๋ Stage๋ ์๋์ผ๋ก ๋นํ์ฑํ(ํ์)"], + [" โข ๋น์ ํจ Stage์ ๋ ์ง ์
๋ ฅ ์ ์
๋ ฅ ์์ฒด๊ฐ ๋งํ๊ณ ๊ฒฝ๊ณ ํ์"], + [" โข ๋ ์ง ํ์ ์๋ ๊ฒ์ฆ"], + [""], + ["3. Class-Stage Matrix ์ํธ ํ์ฉ"], + [" โข ๊ฐ Document Class๋ณ๋ก ์ฌ์ฉ ๊ฐ๋ฅํ Stage ํ์ธ"], + [" โข โ ํ์๊ฐ ์๋ Stage๋ง ํด๋น Class์์ ์ฌ์ฉ ๊ฐ๋ฅ"], + [""], + ["4. ์์ฑ ์์"], + [" โ Document Number, Name ์
๋ ฅ"], + [" โก Document Class ๋๋กญ๋ค์ด์์ ์ ํ"], + [" โข Class-Stage Matrix ํ์ธํ์ฌ ์ ํจํ Stage ํ์
"], + [" โฃ ํด๋น Stage ์ปฌ๋ผ์๋ง ๋ ์ง ์
๋ ฅ"], + [""], + ["5. ์ฃผ์์ฌํญ"], + [" โข * ํ์๋ ํ์ ํญ๋ชฉ"], + [" โข Document Number๋ ์ค๋ณต ๋ถ๊ฐ"], + [" โข ํด๋น Class์ ๋ง์ง ์๋ Stage์ ๋ ์ง ์
๋ ฅ ์ ๋ฌด์/์ฐจ๋จ"], + [" โข ๋ ์ง๋ YYYY-MM-DD ํ์ ์ค์"], + ] + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row) + if (i === 0) r.getCell(1).font = { bold: true, size: 14 } + else if (row[0] && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true } + }) + guideSheet.getColumn(1).width = 70 + + // ================= ReferenceData (์จ๊น) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }) + referenceSheet.getCell("A1").value = "DocumentClasses" + documentClasses.forEach((dc, idx) => { + referenceSheet.getCell(`A${idx + 2}`).value = dc.description + }) + + // ================= Stage ์ด๋ณ ์์/๊ฒ์ฆ ================= + // ๋ฌธ์ ์ํธ ์ปฌ๋ผ ๋๋น + worksheet.columns = [ + { width: 18 }, // Doc Number + { width: 30 }, // Doc Name + { width: 30 }, // Doc Class + ...(projectType === "plant" ? [{ width: 18 }] : []), + ...allStageNames.map(() => ({ width: 12 })), + ] + + // ๊ฐ Stage ์ด ์ฒ๋ฆฌ + for (let stageIdx = 0; stageIdx < allStageNames.length; stageIdx++) { + const colIndex = stageStartCol + stageIdx + const colLetter = getExcelColumnName(colIndex) + + // ๋ ์ง ํ์ ํ์ + for (let row = 2; row <= 1000; row++) { + worksheet.getCell(`${colLetter}${row}`).numFmt = "yyyy-mm-dd" + } + + // ---- ์ปค์คํ
๋ฐ์ดํฐ ๊ฒ์ฆ (๋น์นธ OR (ํด๋น Class์ ์ ํจํ Stage AND ์ซ์(=๋ ์ง))) ---- + // INDEX('Class-Stage Matrix'!$B:$ZZ, MATCH($C2,'Class-Stage Matrix'!$A:$A,0), MATCH(H$1,'Class-Stage Matrix'!$1:$1,0)) + const validationFormula = + `=OR(` + + `LEN(${colLetter}2)=0,` + + `AND(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)<>\"\",` + + `ISNUMBER(${colLetter}2)` + + `)` + + `)` + worksheet.dataValidations.add(`${colLetter}2:${colLetter}1000`, { + type: "custom", + allowBlank: true, + formulae: [validationFormula], + showErrorMessage: true, + errorTitle: "ํ์ฉ๋์ง ์์ ์
๋ ฅ", + error: "์ด Stage๋ ์ ํํ Document Class์์ ์ฌ์ฉํ ์ ์๊ฑฐ๋ ๋ ์ง ํ์์ด ์๋๋๋ค.", + }) + + // ---- ์กฐ๊ฑด๋ถ ์์ (์ ํจํ์ง ์์ Stage โ ํ์ ๋ฐฐ๊ฒฝ) ---- + // TRUE์ด๋ฉด ์์ ์ ์ฉ: INDEX(...)="" -> ์ ํจํ์ง ์์ + const cfFormula = + `IFERROR(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)=\"\",` + + `TRUE` + // ๋งค์น ์คํจ ๋ฑ ์ค๋ฅ ์์๋ ํ์ ์ฒ๋ฆฌ + `)` + worksheet.addConditionalFormatting({ + ref: `${colLetter}2:${colLetter}1000`, + rules: [ + { + type: "expression", + formulae: [cfFormula], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFEFEFEF" } }, // ์ฐํ์ + }, + }, + ], + }) + } + + return workbook +} + +// ============================================================================= +// Parse Documents with Stages - ํตํฉ ํ์ฑ +// ============================================================================= +async function parseDocumentsWithStages( + worksheet: ExcelJS.Worksheet, + projectType: "ship" | "plant", + contractId: number +): Promise<{ validData: ParsedDocument[]; errors: string[] }> { + const documents: ParsedDocument[] = [] + const errors: string[] = [] + const seenDocNumbers = new Set<string>() + + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) { + errors.push("Document Class ์ ๋ณด๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ต๋๋ค") + return { validData: [], errors } + } + const documentClasses = res.data.classes as Array<{ id: number; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // ํด๋์ค๋ณ ์ ํจํ ์คํ
์ด์ง ๋งต + const validStagesByClass = new Map<string, Set<string>>() + for (const c of documentClasses) { + const stages = options.filter((o) => o.documentClassId === c.id).map((o) => o.optionValue) + validStagesByClass.set(c.description, new Set(stages)) + } + + // ํค๋ ํ์ฑ + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + const docNumberIdx = headers.findIndex((h) => h.includes("Document Number")) + const docNameIdx = headers.findIndex((h) => h.includes("Document Name")) + const docClassIdx = headers.findIndex((h) => h.includes("Document Class")) + const vendorDocNoIdx = projectType === "plant" ? headers.findIndex((h) => h.includes("Project Doc No")) : -1 + + if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) { + errors.push("ํ์ ํค๋๊ฐ ๋๋ฝ๋์์ต๋๋ค") + return { validData: [], errors } + } + + const stageStartIdx = projectType === "plant" ? 4 : 3 // headers slice ๊ธฐ์ค(0-index) + const stageHeaders = headers.slice(stageStartIdx) + + // ๋ฐ์ดํฐ ํ ํ์ฑ + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const docName = String(row.getCell(docNameIdx + 1).value || "").trim() + const docClass = String(row.getCell(docClassIdx + 1).value || "").trim() + const vendorDocNo = vendorDocNoIdx >= 0 ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim() : undefined + + if (!docNumber && !docName) return + if (!docNumber) { + errors.push(`ํ ${rowNumber}: Document Number๊ฐ ์์ต๋๋ค`) + return + } + if (!docName) { + errors.push(`ํ ${rowNumber}: Document Name์ด ์์ต๋๋ค`) + return + } + if (!docClass) { + errors.push(`ํ ${rowNumber}: Document Class๊ฐ ์์ต๋๋ค`) + return + } + if (projectType === "plant" && !vendorDocNo) { + errors.push(`ํ ${rowNumber}: Project Doc No.๊ฐ ์์ต๋๋ค`) + return + } + if (seenDocNumbers.has(docNumber)) { + errors.push(`ํ ${rowNumber}: ์ค๋ณต๋ Document Number: ${docNumber}`) + return + } + seenDocNumbers.add(docNumber) + + const validStages = validStagesByClass.get(docClass) + if (!validStages) { + errors.push(`ํ ${rowNumber}: ์ ํจํ์ง ์์ Document Class: ${docClass}`) + return + } + + + + const stages: { stageName: string; planDate: string }[] = [] + stageHeaders.forEach((stageName, idx) => { + if (validStages.has(stageName)) { + const cell = row.getCell(stageStartIdx + idx + 1) + let planDate = "" + if (cell.value) { + if (cell.value instanceof Date) { + planDate = cell.value.toISOString().split("T")[0] + } else { + const dateStr = String(cell.value).trim() + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) planDate = dateStr + } + if (planDate) stages.push({ stageName, planDate }) + } + } + }) + + documents.push({ + docNumber, + title: docName, + documentClass: docClass, + vendorDocNumber: vendorDocNo, + stages, + }) + }) + + return { validData: documents, errors } +} diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx index c0f17afc..01fc61df 100644 --- a/lib/vendor-document-list/plant/upload/columns.tsx +++ b/lib/vendor-document-list/plant/upload/columns.tsx @@ -25,7 +25,8 @@ import { CheckCircle2, XCircle, AlertCircle, - Clock + Clock, + Download } from "lucide-react" interface GetColumnsProps { @@ -360,6 +361,16 @@ export function getColumns({ </> )} + + {/* โ
์ปค๋ฒ ํ์ด์ง ๋ค์ด๋ก๋ */} + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "downloadCover" })} + className="gap-2" + > + <Download className="h-4 w-4" /> + Download Cover Page + </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx index 92507900..84b04092 100644 --- a/lib/vendor-document-list/plant/upload/table.tsx +++ b/lib/vendor-document-list/plant/upload/table.tsx @@ -20,6 +20,7 @@ import { ProjectFilter } from "./components/project-filter" import { SingleUploadDialog } from "./components/single-upload-dialog" import { HistoryDialog } from "./components/history-dialog" import { ViewSubmissionDialog } from "./components/view-submission-dialog" +import { toast } from "sonner" interface StageSubmissionsTableProps { promises: Promise<[ @@ -159,6 +160,30 @@ export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubm columnResizeMode: "onEnd", }) + + React.useEffect(() => { + if (!rowAction) return; + + const { type, row } = rowAction; + + if (type === "downloadCover") { + // 2) ์๋ฒ์์ ์์ฑ ํ ๋ค์ด๋ก๋ (์: API ํธ์ถ) + (async () => { + try { + const res = await fetch(`/api/stages/${row.original.stageId}/cover`, { method: "POST" }); + if (!res.ok) throw new Error("failed"); + const { fileUrl } = await res.json(); // ์๋ฒ ์๋ต: { fileUrl: string } + window.open(fileUrl, "_blank", "noopener,noreferrer"); + } catch (e) { + toast.error("์ปค๋ฒ ํ์ด์ง ์์ฑ์ ์คํจํ์ต๋๋ค."); + console.error(e); + } finally { + setRowAction(null); + } + })(); + } + }, [rowAction, setRowAction]); + return ( <> <DataTable table={table}> diff --git a/lib/vendors/contacts-table/add-contact-dialog.tsx b/lib/vendors/contacts-table/add-contact-dialog.tsx index 5376583a..22c557b4 100644 --- a/lib/vendors/contacts-table/add-contact-dialog.tsx +++ b/lib/vendors/contacts-table/add-contact-dialog.tsx @@ -8,6 +8,13 @@ import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, Dialog import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Form, FormControl, FormField, @@ -29,6 +36,20 @@ interface AddContactDialogProps { export function AddContactDialog({ vendorId }: AddContactDialogProps) { const [open, setOpen] = React.useState(false) + // ๋ด๋น์
๋ฌด ์ต์
+ const taskOptions = [ + { value: "ํ์ฌ๋ํ", label: "ํ์ฌ๋ํ President/Director" }, + { value: "์์
๊ด๋ฆฌ", label: "์์
๊ด๋ฆฌ Sales Management" }, + { value: "์ค๊ณ/๊ธฐ์ ", label: "์ค๊ณ/๊ธฐ์ Engineering/Design" }, + { value: "๊ตฌ๋งค", label: "๊ตฌ๋งค Procurement" }, + { value: "๋ฉ๊ธฐ/์ถํ/์ด์ก", label: "๋ฉ๊ธฐ/์ถํ/์ด์ก Delivery Control" }, + { value: "PM/์์ฐ๊ด๋ฆฌ", label: "PM/์์ฐ๊ด๋ฆฌ PM/Manufacturing" }, + { value: "ํ์ง๊ด๋ฆฌ", label: "ํ์ง๊ด๋ฆฌ Quality Management" }, + { value: "์ธ๊ธ๊ณ์ฐ์/๋ฉํ์๊ด๋ฆฌ", label: "์ธ๊ธ๊ณ์ฐ์/๋ฉํ์๊ด๋ฆฌ Shipping Doc. Management" }, + { value: "A/S ๊ด๋ฆฌ", label: "A/S ๊ด๋ฆฌ A/S Management" }, + { value: "FSE", label: "FSE(์ผ๋์์
์) Field Service Engineer" } + ] + // react-hook-form ์ธํ
const form = useForm<CreateVendorContactSchema>({ resolver: zodResolver(createVendorContactSchema), @@ -37,6 +58,8 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { vendorId, contactName: "", contactPosition: "", + contactDepartment: "", + contactTask: "", contactEmail: "", contactPhone: "", isPrimary: false, @@ -88,7 +111,7 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { name="contactName" render={({ field }) => ( <FormItem> - <FormLabel>Contact Name</FormLabel> + <FormLabel>๋ด๋น์๋ช
</FormLabel> <FormControl> <Input placeholder="์: ํ๊ธธ๋" {...field} /> </FormControl> @@ -102,7 +125,7 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { name="contactPosition" render={({ field }) => ( <FormItem> - <FormLabel>Position / Title</FormLabel> + <FormLabel>์ง๊ธ</FormLabel> <FormControl> <Input placeholder="์: ๊ณผ์ฅ" {...field} /> </FormControl> @@ -113,12 +136,12 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { <FormField control={form.control} - name="contactEmail" + name="contactDepartment" render={({ field }) => ( <FormItem> - <FormLabel>Email</FormLabel> + <FormLabel>๋ถ์</FormLabel> <FormControl> - <Input placeholder="name@company.com" {...field} /> + <Input placeholder="์: ์์
๋ถ" {...field} /> </FormControl> <FormMessage /> </FormItem> @@ -127,36 +150,58 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { <FormField control={form.control} - name="contactPhone" + name="contactTask" + render={({ field }) => ( + <FormItem> + <FormLabel>๋ด๋น์
๋ฌด</FormLabel> + <Select onValueChange={field.onChange} value={field.value || undefined}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="๋ด๋น์
๋ฌด๋ฅผ ์ ํํ์ธ์" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {taskOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactEmail" render={({ field }) => ( <FormItem> - <FormLabel>Phone</FormLabel> + <FormLabel>์ด๋ฉ์ผ</FormLabel> <FormControl> - <Input placeholder="010-1234-5678" {...field} /> + <Input placeholder="name@company.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> - {/* ๋จ์ checkbox */} <FormField control={form.control} - name="isPrimary" + name="contactPhone" render={({ field }) => ( <FormItem> - <div className="flex items-center space-x-2 mt-2"> - <input - type="checkbox" - checked={field.value} - onChange={(e) => field.onChange(e.target.checked)} - /> - <FormLabel>Is Primary?</FormLabel> - </div> + <FormLabel>์ ํ๋ฒํธ</FormLabel> + <FormControl> + <Input placeholder="010-1234-5678" {...field} /> + </FormControl> <FormMessage /> </FormItem> )} /> + + </div> <DialogFooter> diff --git a/lib/vendors/contacts-table/contact-table.tsx b/lib/vendors/contacts-table/contact-table.tsx index 2991187e..65b12451 100644 --- a/lib/vendors/contacts-table/contact-table.tsx +++ b/lib/vendors/contacts-table/contact-table.tsx @@ -16,6 +16,7 @@ import { getColumns } from "./contact-table-columns" import { getVendorContacts, } from "../service" import { VendorContact, vendors } from "@/db/schema/vendors" import { VendorsTableToolbarActions } from "./contact-table-toolbar-actions" +import { EditContactDialog } from "./edit-contact-dialog" interface VendorsTableProps { promises: Promise< @@ -33,6 +34,23 @@ export function VendorContactsTable({ promises , vendorId}: VendorsTableProps) { const [{ data, pageCount }] = React.use(promises) const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorContact> | null>(null) + const [editDialogOpen, setEditDialogOpen] = React.useState(false) + const [selectedContact, setSelectedContact] = React.useState<VendorContact | null>(null) + + // Edit ์ก์
์ฒ๋ฆฌ + React.useEffect(() => { + if (rowAction?.type === "update") { + setSelectedContact(rowAction.row.original) + setEditDialogOpen(true) + setRowAction(null) + } + }, [rowAction]) + + // ๋ฐ์ดํฐ ์๋ก๊ณ ์นจ ํจ์ + const handleEditSuccess = React.useCallback(() => { + // ํ์ด์ง๋ฅผ ์๋ก๊ณ ์นจํ๊ฑฐ๋ ๋ฐ์ดํฐ๋ฅผ ๋ค์ ๊ฐ์ ธ์ค๊ธฐ + window.location.reload() + }, []) // getColumns() ํธ์ถ ์, router๋ฅผ ์ฃผ์
const columns = React.useMemo( @@ -82,6 +100,13 @@ export function VendorContactsTable({ promises , vendorId}: VendorsTableProps) { <VendorsTableToolbarActions table={table} vendorId={vendorId} /> </DataTableAdvancedToolbar> </DataTable> + + <EditContactDialog + contact={selectedContact} + open={editDialogOpen} + onOpenChange={setEditDialogOpen} + onSuccess={handleEditSuccess} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendors/contacts-table/edit-contact-dialog.tsx b/lib/vendors/contacts-table/edit-contact-dialog.tsx new file mode 100644 index 00000000..e123568e --- /dev/null +++ b/lib/vendors/contacts-table/edit-contact-dialog.tsx @@ -0,0 +1,231 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" + +import { + updateVendorContactSchema, + type UpdateVendorContactSchema, +} from "../validations" +import { updateVendorContact } from "../service" +import { VendorContact } from "@/db/schema/vendors" + +interface EditContactDialogProps { + contact: VendorContact | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function EditContactDialog({ contact, open, onOpenChange, onSuccess }: EditContactDialogProps) { + // ๋ด๋น์
๋ฌด ์ต์
+ const taskOptions = [ + { value: "ํ์ฌ๋ํ", label: "ํ์ฌ๋ํ President/Director" }, + { value: "์์
๊ด๋ฆฌ", label: "์์
๊ด๋ฆฌ Sales Management" }, + { value: "์ค๊ณ/๊ธฐ์ ", label: "์ค๊ณ/๊ธฐ์ Engineering/Design" }, + { value: "๊ตฌ๋งค", label: "๊ตฌ๋งค Procurement" }, + { value: "๋ฉ๊ธฐ/์ถํ/์ด์ก", label: "๋ฉ๊ธฐ/์ถํ/์ด์ก Delivery Control" }, + { value: "PM/์์ฐ๊ด๋ฆฌ", label: "PM/์์ฐ๊ด๋ฆฌ PM/Manufacturing" }, + { value: "ํ์ง๊ด๋ฆฌ", label: "ํ์ง๊ด๋ฆฌ Quality Management" }, + { value: "์ธ๊ธ๊ณ์ฐ์/๋ฉํ์๊ด๋ฆฌ", label: "์ธ๊ธ๊ณ์ฐ์/๋ฉํ์๊ด๋ฆฌ Shipping Doc. Management" }, + { value: "A/S ๊ด๋ฆฌ", label: "A/S ๊ด๋ฆฌ A/S Management" }, + { value: "FSE", label: "FSE(์ผ๋์์
์) Field Service Engineer" } + ] + + // react-hook-form ์ธํ
+ const form = useForm<UpdateVendorContactSchema>({ + resolver: zodResolver(updateVendorContactSchema), + defaultValues: { + contactName: "", + contactPosition: "", + contactDepartment: "", + contactTask: "", + contactEmail: "", + contactPhone: "", + isPrimary: false, + }, + }) + + // contact๊ฐ ๋ณ๊ฒฝ๋๋ฉด ํผ ์ด๊ธฐํ + React.useEffect(() => { + if (contact) { + form.reset({ + contactName: contact.contactName || "", + contactPosition: contact.contactPosition || "", + contactDepartment: contact.contactDepartment || "", + contactTask: contact.contactTask || "", + contactEmail: contact.contactEmail || "", + contactPhone: contact.contactPhone || "", + isPrimary: contact.isPrimary || false, + }) + } + }, [contact, form]) + + async function onSubmit(data: UpdateVendorContactSchema) { + if (!contact) return + + const result = await updateVendorContact(contact.id, data) + if (result.error) { + alert(`์๋ฌ: ${result.error}`) + return + } + + // ์ฑ๊ณต ์ ๋ชจ๋ฌ ๋ซ๊ณ ํผ ๋ฆฌ์
+ form.reset() + onOpenChange(false) + onSuccess() + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + onOpenChange(nextOpen) + } + + if (!contact) return null + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>์ฐ๋ฝ์ฒ ์์ </DialogTitle> + <DialogDescription> + ์ฐ๋ฝ์ฒ ์ ๋ณด๋ฅผ ์์ ํ๊ณ <b>Update</b> ๋ฒํผ์ ๋๋ฅด์ธ์. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form์ ์ด์ฉํด react-hook-form๊ณผ ์ฐ๊ฒฐ */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + <FormField + control={form.control} + name="contactName" + render={({ field }) => ( + <FormItem> + <FormLabel>๋ด๋น์๋ช
</FormLabel> + <FormControl> + <Input placeholder="์: ํ๊ธธ๋" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPosition" + render={({ field }) => ( + <FormItem> + <FormLabel>์ง๊ธ</FormLabel> + <FormControl> + <Input placeholder="์: ๊ณผ์ฅ" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactDepartment" + render={({ field }) => ( + <FormItem> + <FormLabel>๋ถ์</FormLabel> + <FormControl> + <Input placeholder="์: ์์
๋ถ" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactTask" + render={({ field }) => ( + <FormItem> + <FormLabel>๋ด๋น์
๋ฌด</FormLabel> + <Select onValueChange={field.onChange} value={field.value || undefined}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="๋ด๋น์
๋ฌด๋ฅผ ์ ํํ์ธ์" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {taskOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>์ด๋ฉ์ผ</FormLabel> + <FormControl> + <Input placeholder="name@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>์ ํ๋ฒํธ</FormLabel> + <FormControl> + <Input placeholder="010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + ์ทจ์ + </Button> + <Button type="submit" disabled={form.formState.isSubmitting}> + ์์ + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts index d2be43ca..5b9b1116 100644 --- a/lib/vendors/repository.ts +++ b/lib/vendors/repository.ts @@ -175,6 +175,26 @@ export const getVendorContactsById = async (id: number): Promise<VendorContact | return contact }; +export const getVendorContactById = async (id: number): Promise<VendorContact | null> => { + const contactsRes = await db.select().from(vendorContacts).where(eq(vendorContacts.id, id)).execute(); + if (contactsRes.length === 0) return null; + + const contact = contactsRes[0]; + return contact +}; + +export async function updateVendorContactById( + tx: PgTransaction<any, any, any>, + id: number, + data: Partial<VendorContact> +) { + return tx + .update(vendorContacts) + .set(data) + .where(eq(vendorContacts.id, id)) + .returning(); +} + export async function selectVendorContacts( tx: PgTransaction<any, any, any>, params: { diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index f4ba815c..e6a2a139 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -34,6 +34,8 @@ import { countVendorMaterials, selectVendorMaterials, insertVendorMaterial, + getVendorContactById, + updateVendorContactById, } from "./repository"; @@ -42,6 +44,7 @@ import type { GetVendorsSchema, GetVendorContactsSchema, CreateVendorContactSchema, + UpdateVendorContactSchema, GetVendorItemsSchema, CreateVendorItemSchema, GetRfqHistorySchema, @@ -635,6 +638,8 @@ export async function createVendorContact(input: CreateVendorContactSchema) { vendorId: input.vendorId, contactName: input.contactName, contactPosition: input.contactPosition || "", + contactDepartment: input.contactDepartment || "", + contactTask: input.contactTask || "", contactEmail: input.contactEmail, contactPhone: input.contactPhone || "", isPrimary: input.isPrimary || false, @@ -651,6 +656,35 @@ export async function createVendorContact(input: CreateVendorContactSchema) { } } +export async function updateVendorContact(id: number, input: UpdateVendorContactSchema) { + unstable_noStore(); // Next.js ์๋ฒ ์ก์
์บ์ฑ ๋ฐฉ์ง + try { + const vendorContact = await getVendorContactById(id); + if (!vendorContact) { + return { data: null, error: "Contact not found" }; + } + + await db.transaction(async (tx) => { + // DB Update + await updateVendorContactById(tx, id, { + contactName: input.contactName, + contactPosition: input.contactPosition, + contactDepartment: input.contactDepartment, + contactTask: input.contactTask, + contactEmail: input.contactEmail, + contactPhone: input.contactPhone, + isPrimary: input.isPrimary, + }); + }); + + // ์บ์ ๋ฌดํจํ (ํ๋ ฅ์
์ฒด ์ฐ๋ฝ์ฒ ๋ชฉ๋ก ๋ฑ) + revalidateTag(`vendor-contacts-${vendorContact.vendorId}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} ///item diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 44237963..88a39651 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -334,22 +334,26 @@ export const createVendorSchema = z export const createVendorContactSchema = z.object({ vendorId: z.number(), contactName: z.string() - .min(1, "Contact name is required") - .max(255, "Max length 255"), // ์ ๊ท ์์ฑ ์ ๋ฐ๋์ ์
๋ ฅ - contactPosition: z.string().max(100, "Max length 100"), - contactEmail: z.string().email(), - contactPhone: z.string().max(50, "Max length 50").optional(), + .min(1, "๋ด๋น์๋ช
์ ํ์ ์
๋ ฅ์ฌํญ์
๋๋ค.") + .max(255, "์ต๋ 255์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค."), // ์ ๊ท ์์ฑ ์ ๋ฐ๋์ ์
๋ ฅ + contactPosition: z.string().max(100, "์ต๋ 100์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค."), + contactDepartment: z.string().max(100, "์ต๋ 100์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค."), + contactTask: z.string().max(100, "์ต๋ 100์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค."), + contactEmail: z.string().email("์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค."), + contactPhone: z.string().max(50, "์ต๋ 50์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.").optional(), isPrimary: z.boolean(), }); export const updateVendorContactSchema = z.object({ contactName: z.string() - .min(1, "Contact name is required") - .max(255, "Max length 255"), // ์ ๊ท ์์ฑ ์ ๋ฐ๋์ ์
๋ ฅ - contactPosition: z.string().max(100, "Max length 100").optional(), - contactEmail: z.string().email().optional(), - contactPhone: z.string().max(50, "Max length 50").optional(), + .min(1, "๋ด๋น์๋ช
์ ํ์ ์
๋ ฅ์ฌํญ์
๋๋ค.") + .max(255, "์ต๋ 255์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค."), // ์ ๊ท ์์ฑ ์ ๋ฐ๋์ ์
๋ ฅ + contactPosition: z.string().max(100, "์ต๋ 100์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.").optional(), + contactDepartment: z.string().max(100, "์ต๋ 100์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.").optional(), + contactTask: z.string().max(100, "์ต๋ 100์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.").optional(), + contactEmail: z.string().email("์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค.").optional(), + contactPhone: z.string().max(50, "์ต๋ 50์๊น์ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.").optional(), isPrimary: z.boolean().optional(), }); |
