summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.production1
-rw-r--r--app/[lng]/evcp/(evcp)/permissions/page.tsx72
-rw-r--r--app/[lng]/evcp/(evcp)/permissions/settings/page.tsx54
-rw-r--r--app/[lng]/partners/(partners)/document-upload/page.tsx2
-rw-r--r--app/api/rfq-attachments/upload/route.ts20
-rw-r--r--components/ProjectSelector.tsx50
-rw-r--r--components/docu-list-rule/docu-list-rule-client.tsx80
-rw-r--r--components/form-data/add-formTag-dialog.tsx68
-rw-r--r--components/permissions/menu-permission-generator.tsx404
-rw-r--r--components/permissions/menu-permission-manager.tsx552
-rw-r--r--components/permissions/permission-assignment-manager.tsx319
-rw-r--r--components/permissions/permission-crud-manager.tsx562
-rw-r--r--components/permissions/permission-group-manager.tsx799
-rw-r--r--components/permissions/role-permission-manager.tsx178
-rw-r--r--components/permissions/user-permission-manager.tsx573
-rw-r--r--config/menuConfig.ts10
-rw-r--r--config/vendorContactsColumnsConfig.ts52
-rw-r--r--db/schema/consent.ts2
-rw-r--r--db/schema/index.ts2
-rw-r--r--db/schema/permissions.ts204
-rw-r--r--db/schema/rfqLast.ts5
-rw-r--r--db/schema/users.ts40
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx9
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx2
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx43
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx2
-rw-r--r--lib/bidding/service.ts6
-rw-r--r--lib/docu-list-rule/number-type-configs/service.ts7
-rw-r--r--lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx277
-rw-r--r--lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx251
-rw-r--r--lib/docu-list-rule/types.ts1
-rw-r--r--lib/email-template/editor/template-content-editor.tsx6
-rw-r--r--lib/email-template/table/create-template-sheet.tsx10
-rw-r--r--lib/email-template/table/update-template-sheet.tsx6
-rw-r--r--lib/permissions/permission-assignment-actions.ts83
-rw-r--r--lib/permissions/permission-group-actions.ts270
-rw-r--r--lib/permissions/permission-settings-actions.ts229
-rw-r--r--lib/permissions/service.ts434
-rw-r--r--lib/rfq-last/service.ts20
-rw-r--r--lib/rfq-last/table/create-general-rfq-dialog.tsx44
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx18
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx6
-rw-r--r--lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx4
-rw-r--r--lib/rfq-last/vendor/batch-update-conditions-dialog.tsx2
-rw-r--r--lib/rfqs/service.ts2
-rw-r--r--lib/sedp/sync-form.ts40
-rw-r--r--lib/tags/service.ts348
-rw-r--r--lib/vendor-document-list/plant/document-stage-actions.ts0
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx1433
-rw-r--r--lib/vendor-document-list/plant/document-stage-toolbar.tsx2
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx316
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts458
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx234
-rw-r--r--lib/vendor-document-list/plant/excel-import-export.ts788
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage copy 2.tsx899
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage copy.tsx908
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage.tsx899
-rw-r--r--lib/vendor-document-list/plant/upload/columns.tsx13
-rw-r--r--lib/vendor-document-list/plant/upload/table.tsx25
-rw-r--r--lib/vendors/contacts-table/add-contact-dialog.tsx81
-rw-r--r--lib/vendors/contacts-table/contact-table.tsx25
-rw-r--r--lib/vendors/contacts-table/edit-contact-dialog.tsx231
-rw-r--r--lib/vendors/repository.ts20
-rw-r--r--lib/vendors/service.ts34
-rw-r--r--lib/vendors/validations.ts24
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(),
});