summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx424
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx28
-rw-r--r--config/menuConfig.ts5
-rw-r--r--lib/change-vendor/service.ts258
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx61
-rw-r--r--lib/vendor-regular-registrations/repository.ts3
-rw-r--r--lib/vendor-regular-registrations/service.ts651
7 files changed, 1016 insertions, 414 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx b/app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx
new file mode 100644
index 00000000..47776bba
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(system)/change-vendor/change-vendor-client.tsx
@@ -0,0 +1,424 @@
+'use client';
+
+import * as React from 'react';
+import { useState, useTransition } from 'react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Loader2, Search, User, Building2 } from 'lucide-react';
+import { searchUsers, getUserVendorInfo, getVendorList, changeVendor } from '@/lib/change-vendor/service';
+import { toast } from 'sonner';
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ companyId: number | null;
+}
+
+interface UserVendorInfo {
+ userId: number;
+ userName: string;
+ userEmail: string;
+ currentVendorId: number | null;
+ currentVendorName: string | null;
+ currentVendorCode: string | null;
+}
+
+interface Vendor {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null;
+ status: string;
+}
+
+export function ChangeVendorClient() {
+ const [isPending, startTransition] = useTransition();
+ const [isSearchingUsers, setIsSearchingUsers] = React.useState(false);
+ const [isSearchingVendors, setIsSearchingVendors] = React.useState(false);
+ const [isLoadingVendorInfo, setIsLoadingVendorInfo] = React.useState(false);
+
+ const [userSearchQuery, setUserSearchQuery] = useState('');
+ const [users, setUsers] = useState<User[]>([]);
+ const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
+ const [userVendorInfo, setUserVendorInfo] = useState<UserVendorInfo | null>(null);
+
+ const [vendorSearchQuery, setVendorSearchQuery] = useState('');
+ const [vendors, setVendors] = useState<Vendor[]>([]);
+ const [selectedVendorId, setSelectedVendorId] = useState<string>('');
+
+ const [error, setError] = useState<string | null>(null);
+
+ // 유저 검색
+ const handleSearchUsers = async () => {
+ if (isSearchingUsers) return;
+
+ setIsSearchingUsers(true);
+ setError(null);
+ setSelectedUserId(null);
+ setUserVendorInfo(null);
+ setSelectedVendorId('');
+
+ try {
+ const result = await searchUsers(userSearchQuery);
+
+ if (result.error) {
+ setError(result.error);
+ return;
+ }
+
+ setUsers(result.data || []);
+
+ if (result.data && result.data.length === 0) {
+ toast.info('검색 결과가 없습니다.');
+ }
+ } catch (err) {
+ setError('유저 검색 중 오류가 발생했습니다.');
+ console.error(err);
+ } finally {
+ setIsSearchingUsers(false);
+ }
+ };
+
+ // 유저 검색어 입력 시 엔터키 처리
+ const handleUserSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === 'Enter') {
+ handleSearchUsers();
+ }
+ };
+
+ // 유저 선택 시 해당 유저의 벤더 정보 조회
+ const handleUserSelect = async (userId: number) => {
+ setSelectedUserId(userId);
+ setUserVendorInfo(null);
+ setSelectedVendorId('');
+ setIsLoadingVendorInfo(true);
+ setError(null);
+
+ try {
+ const result = await getUserVendorInfo(userId);
+
+ if (result.error) {
+ setError(result.error);
+ return;
+ }
+
+ setUserVendorInfo(result.data);
+
+ // 벤더 목록도 함께 로드
+ await loadVendors();
+ } catch (err) {
+ setError('유저 정보를 불러오는 중 오류가 발생했습니다.');
+ console.error(err);
+ } finally {
+ setIsLoadingVendorInfo(false);
+ }
+ };
+
+ // 벤더 목록 로드
+ const loadVendors = async () => {
+ setIsSearchingVendors(true);
+ try {
+ const result = await getVendorList(vendorSearchQuery);
+
+ if (result.error) {
+ setError(result.error);
+ return;
+ }
+
+ setVendors(result.data || []);
+ } catch (err) {
+ setError('벤더 목록을 불러오는 중 오류가 발생했습니다.');
+ console.error(err);
+ } finally {
+ setIsSearchingVendors(false);
+ }
+ };
+
+ // 벤더 검색
+ const handleSearchVendors = async () => {
+ if (isSearchingVendors) return;
+
+ setIsSearchingVendors(true);
+ setError(null);
+ setSelectedVendorId('');
+
+ try {
+ const result = await getVendorList(vendorSearchQuery);
+
+ if (result.error) {
+ setError(result.error);
+ return;
+ }
+
+ setVendors(result.data || []);
+
+ if (result.data && result.data.length === 0) {
+ toast.info('검색 결과가 없습니다.');
+ }
+ } catch (err) {
+ setError('벤더 검색 중 오류가 발생했습니다.');
+ console.error(err);
+ } finally {
+ setIsSearchingVendors(false);
+ }
+ };
+
+ // 벤더 검색어 입력 시 엔터키 처리
+ const handleVendorSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === 'Enter') {
+ handleSearchVendors();
+ }
+ };
+
+ // 벤더 변경 처리
+ const handleChangeVendor = () => {
+ if (!selectedUserId) {
+ toast.error('유저를 선택해주세요.');
+ return;
+ }
+
+ if (!selectedVendorId) {
+ toast.error('변경할 벤더를 선택해주세요.');
+ return;
+ }
+
+ const vendorId = Number(selectedVendorId);
+
+ if (userVendorInfo?.currentVendorId === vendorId) {
+ toast.error('현재 선택된 벤더와 동일합니다.');
+ return;
+ }
+
+ startTransition(async () => {
+ try {
+ const result = await changeVendor(selectedUserId, vendorId);
+
+ if (result.error || !result.success) {
+ toast.error(result.error || '벤더 변경에 실패했습니다.');
+ return;
+ }
+
+ toast.success(`"${result.data?.userName}"님의 벤더가 "${result.data?.vendorName}"로 변경되었습니다.`);
+
+ // 유저 정보 새로고침
+ await handleUserSelect(selectedUserId);
+ } catch (err) {
+ toast.error('벤더 변경 중 오류가 발생했습니다.');
+ console.error(err);
+ }
+ });
+ };
+
+ return (
+ <div className="space-y-6">
+ {/* 1단계: 유저 검색 및 선택 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <User className="h-5 w-5" />
+ 유저 검색 및 선택
+ </CardTitle>
+ <CardDescription>
+ 벤더를 변경할 유저를 검색하고 선택하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 유저 검색 */}
+ <div className="flex gap-2">
+ <div className="flex-1">
+ <Input
+ placeholder="유저명 또는 이메일로 검색..."
+ value={userSearchQuery}
+ onChange={(e) => setUserSearchQuery(e.target.value)}
+ onKeyDown={handleUserSearchKeyDown}
+ disabled={isSearchingUsers}
+ />
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleSearchUsers}
+ disabled={isSearchingUsers}
+ >
+ {isSearchingUsers ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Search className="h-4 w-4" />
+ )}
+ <span className="ml-2">검색</span>
+ </Button>
+ </div>
+
+ {/* 유저 선택 */}
+ {users.length > 0 && (
+ <div className="grid gap-2">
+ <Label>유저 선택</Label>
+ <Select
+ value={selectedUserId?.toString() || ''}
+ onValueChange={(value) => handleUserSelect(Number(value))}
+ disabled={isLoadingVendorInfo}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="유저를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {users.map((user) => (
+ <SelectItem key={user.id} value={user.id.toString()}>
+ <div className="flex flex-col">
+ <span>{user.name}</span>
+ <span className="text-xs text-muted-foreground">{user.email}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ {isLoadingVendorInfo && (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+ </div>
+ )}
+
+ {/* 선택한 유저의 현재 벤더 정보 */}
+ {userVendorInfo && (
+ <div className="rounded-lg border p-4 bg-muted/50">
+ <div className="grid gap-2">
+ <div className="flex items-center gap-2">
+ <User className="h-4 w-4 text-muted-foreground" />
+ <Label className="text-sm font-medium">선택한 유저</Label>
+ </div>
+ <div className="text-sm">
+ <div className="font-semibold">{userVendorInfo.userName}</div>
+ <div className="text-muted-foreground">{userVendorInfo.userEmail}</div>
+ </div>
+ <div className="mt-2 pt-2 border-t">
+ <div className="flex items-center gap-2 mb-1">
+ <Building2 className="h-4 w-4 text-muted-foreground" />
+ <Label className="text-sm font-medium">현재 벤더</Label>
+ </div>
+ <div className="text-sm font-semibold">
+ {userVendorInfo.currentVendorName || '벤더 정보 없음'}
+ {userVendorInfo.currentVendorCode && (
+ <span className="text-muted-foreground ml-2">
+ ({userVendorInfo.currentVendorCode})
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 2단계: 벤더 검색 및 선택 (유저 선택 후에만 표시) */}
+ {selectedUserId && userVendorInfo && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Building2 className="h-5 w-5" />
+ 벤더 변경
+ </CardTitle>
+ <CardDescription>
+ 변경할 벤더를 검색하고 선택하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 벤더 검색 */}
+ <div className="flex gap-2">
+ <div className="flex-1">
+ <Input
+ placeholder="벤더명 또는 벤더코드로 검색..."
+ value={vendorSearchQuery}
+ onChange={(e) => setVendorSearchQuery(e.target.value)}
+ onKeyDown={handleVendorSearchKeyDown}
+ disabled={isSearchingVendors}
+ />
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleSearchVendors}
+ disabled={isSearchingVendors}
+ >
+ {isSearchingVendors ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Search className="h-4 w-4" />
+ )}
+ <span className="ml-2">검색</span>
+ </Button>
+ </div>
+
+ {/* 벤더 선택 */}
+ {vendors.length > 0 && (
+ <div className="grid gap-2">
+ <Label htmlFor="vendor-select">변경할 벤더 선택</Label>
+ <Select
+ value={selectedVendorId}
+ onValueChange={setSelectedVendorId}
+ disabled={isPending}
+ >
+ <SelectTrigger id="vendor-select">
+ <SelectValue placeholder="벤더를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {vendors.map((vendor) => (
+ <SelectItem key={vendor.id} value={vendor.id.toString()}>
+ <div className="flex flex-col">
+ <span>{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="text-xs text-muted-foreground">
+ {vendor.vendorCode}
+ </span>
+ )}
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ {error && (
+ <Alert variant="destructive">
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ )}
+
+ {/* 저장 버튼 */}
+ <Button
+ onClick={handleChangeVendor}
+ disabled={isPending || !selectedVendorId}
+ className="w-full"
+ >
+ {isPending ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 변경 중...
+ </>
+ ) : (
+ <>
+ <Building2 className="mr-2 h-4 w-4" />
+ 벤더 변경
+ </>
+ )}
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ );
+}
diff --git a/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx b/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
new file mode 100644
index 00000000..4b4b0a8d
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(system)/change-vendor/page.tsx
@@ -0,0 +1,28 @@
+import * as React from 'react';
+import { type Metadata } from 'next';
+import { Shell } from '@/components/shell';
+import { ChangeVendorClient } from './change-vendor-client';
+
+export const metadata: Metadata = {
+ title: '벤더 변경',
+ description: '유저의 벤더를 검색하고 변경할 수 있습니다.',
+};
+
+export const dynamic = 'force-dynamic';
+
+export default async function ChangeVendorPage() {
+ return (
+ <Shell className="gap-6">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">벤더 변경</h2>
+ <p className="text-muted-foreground">
+ 유저를 검색하고 선택한 후, 해당 유저의 벤더를 변경할 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ <ChangeVendorClient />
+ </Shell>
+ );
+}
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 948ef20e..e24a283b 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -511,6 +511,11 @@ export const mainNav: MenuSection[] = [
href: '/evcp/page-visits',
groupKey: 'groups.access_history',
},
+ {
+ titleKey: 'menu.information_system.change_vendor',
+ href: '/evcp/change-vendor',
+ groupKey: 'groups.menu',
+ },
],
},
];
diff --git a/lib/change-vendor/service.ts b/lib/change-vendor/service.ts
new file mode 100644
index 00000000..09c93a26
--- /dev/null
+++ b/lib/change-vendor/service.ts
@@ -0,0 +1,258 @@
+'use server';
+
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import db from '@/db/db';
+import { users, vendors } from '@/db/schema';
+import { and, eq, ilike, or, asc } from 'drizzle-orm';
+import { revalidatePath } from 'next/cache';
+import { getErrorMessage } from '@/lib/handle-error';
+import { unstable_noStore } from 'next/cache';
+
+/**
+ * 유저 목록 검색 (limit 100)
+ */
+export async function searchUsers(search?: string) {
+ unstable_noStore();
+
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return { data: null, error: '로그인이 필요합니다.' };
+ }
+
+ let whereCondition;
+
+ // 검색어가 있으면 이름 또는 이메일로 검색
+ if (search && search.trim()) {
+ const searchPattern = `%${search.trim()}%`;
+ whereCondition = or(
+ ilike(users.name, searchPattern),
+ ilike(users.email, searchPattern)
+ );
+ }
+
+ const usersList = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ companyId: users.companyId,
+ })
+ .from(users)
+ .where(whereCondition)
+ .orderBy(asc(users.name))
+ .limit(100);
+
+ return {
+ data: usersList,
+ error: null,
+ };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 특정 유저의 벤더 정보 조회
+ */
+export async function getUserVendorInfo(userId: number) {
+ unstable_noStore();
+
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return { data: null, error: '로그인이 필요합니다.' };
+ }
+
+ // 사용자 정보 조회
+ const user = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ companyId: users.companyId,
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ if (!user[0]) {
+ return { data: null, error: '사용자를 찾을 수 없습니다.' };
+ }
+
+ // 현재 벤더 정보 조회 (companyId가 있는 경우)
+ let vendor = null;
+ if (user[0].companyId) {
+ const vendorResult = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, user[0].companyId))
+ .limit(1);
+
+ vendor = vendorResult[0] || null;
+ }
+
+ return {
+ data: {
+ userId: user[0].id,
+ userName: user[0].name,
+ userEmail: user[0].email,
+ currentVendorId: user[0].companyId,
+ currentVendorName: vendor?.vendorName || null,
+ currentVendorCode: vendor?.vendorCode || null,
+ },
+ error: null,
+ };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 벤더 목록 조회 (활성 상태만, 검색 기능 포함, limit 100)
+ */
+export async function getVendorList(search?: string) {
+ unstable_noStore();
+
+ try {
+ const statusCondition = eq(vendors.status, 'ACTIVE');
+
+ // 검색어가 있으면 벤더명 또는 벤더코드로 검색
+ if (search && search.trim()) {
+ const searchPattern = `%${search.trim()}%`;
+ const searchConditions = [
+ ilike(vendors.vendorName, searchPattern),
+ ilike(vendors.vendorCode, searchPattern),
+ ];
+
+ const whereCondition = and(
+ statusCondition,
+ or(...searchConditions)
+ );
+
+ const vendorsList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(whereCondition)
+ .orderBy(asc(vendors.vendorName))
+ .limit(100);
+
+ return {
+ data: vendorsList,
+ error: null,
+ };
+ }
+
+ // 검색어가 없으면 활성 상태만 조회
+ const whereCondition = statusCondition;
+
+ const vendorsList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(whereCondition)
+ .orderBy(asc(vendors.vendorName))
+ .limit(100);
+
+ return {
+ data: vendorsList,
+ error: null,
+ };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 벤더 변경 서버 액션 (특정 유저의 벤더 변경)
+ */
+export async function changeVendor(userId: number, newVendorId: number) {
+ unstable_noStore();
+
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return { success: false, error: '로그인이 필요합니다.' };
+ }
+
+ // 대상 사용자 확인
+ const targetUser = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ if (!targetUser[0]) {
+ return { success: false, error: '사용자를 찾을 수 없습니다.' };
+ }
+
+ // 새 벤더 정보 확인
+ const newVendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, newVendorId))
+ .limit(1);
+
+ if (!newVendor[0]) {
+ return { success: false, error: '벤더를 찾을 수 없습니다.' };
+ }
+
+ if (newVendor[0].status !== 'ACTIVE') {
+ return { success: false, error: '활성 상태인 벤더만 선택할 수 있습니다.' };
+ }
+
+ // 사용자의 companyId 업데이트
+ await db
+ .update(users)
+ .set({
+ companyId: newVendorId,
+ updatedAt: new Date(),
+ })
+ .where(eq(users.id, userId));
+
+ // 세션 재검증을 위해 경로 재검증
+ revalidatePath('/', 'layout');
+
+ return {
+ success: true,
+ data: {
+ userId: targetUser[0].id,
+ userName: targetUser[0].name,
+ userEmail: targetUser[0].email,
+ vendorId: newVendor[0].id,
+ vendorName: newVendor[0].vendorName,
+ vendorCode: newVendor[0].vendorCode,
+ },
+ error: null,
+ };
+ } catch (error) {
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
index 42470ecc..bf90bc6e 100644
--- a/lib/rfq-last/vendor/send-rfq-dialog.tsx
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -545,7 +545,7 @@ export function SendRfqDialog({
setVendorsWithRecipients(
selectedVendors.map(v => ({
...v,
- selectedMainEmail: v.primaryEmail || v.vendorEmail || '',
+ selectedMainEmail: '', // 기본값 제거 - 사용자가 직접 선택하도록
additionalEmails: [],
customEmails: []
}))
@@ -838,10 +838,53 @@ export function SendRfqDialog({
// 전송 처리
const handleSend = async () => {
try {
- // 유효성 검사
- const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail);
- if (vendorsWithoutEmail.length > 0) {
- toast.error(`${vendorsWithoutEmail.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요.`);
+ // 주 수신자가 없는 벤더 확인 (가장 먼저 검증)
+ const vendorsWithoutMainEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail || v.selectedMainEmail.trim() === '');
+
+ if (vendorsWithoutMainEmail.length > 0) {
+ // 사용 가능한 이메일이 있는지 확인
+ const vendorsWithAvailableEmails = vendorsWithoutMainEmail.filter(v => {
+ const hasRepresentativeEmail = !!v.representativeEmail;
+ const hasVendorEmail = !!v.vendorEmail;
+ const hasContacts = v.contacts && v.contacts.length > 0;
+ const hasCustomEmails = v.customEmails && v.customEmails.length > 0;
+ const hasAdditionalEmails = v.additionalEmails && v.additionalEmails.length > 0;
+
+ return hasRepresentativeEmail || hasVendorEmail || hasContacts || hasCustomEmails || hasAdditionalEmails;
+ });
+
+ if (vendorsWithAvailableEmails.length > 0) {
+ // 사용 가능한 이메일이 있지만 주 수신자가 선택되지 않은 경우
+ toast.error(
+ `${vendorsWithAvailableEmails.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요. ` +
+ `(CC에서 선택하거나 수신자 추가 버튼으로 이메일을 추가한 후 주 수신자로 선택해주세요)`
+ );
+ return;
+ }
+
+ // 사용 가능한 이메일이 전혀 없는 경우
+ const vendorsWithoutAnyEmail = vendorsWithoutMainEmail.filter(v => {
+ const hasRepresentativeEmail = !!v.representativeEmail;
+ const hasVendorEmail = !!v.vendorEmail;
+ const hasContacts = v.contacts && v.contacts.length > 0;
+ const hasCustomEmails = v.customEmails && v.customEmails.length > 0;
+
+ return !hasRepresentativeEmail && !hasVendorEmail && !hasContacts && !hasCustomEmails;
+ });
+
+ if (vendorsWithoutAnyEmail.length > 0) {
+ toast.error(
+ `${vendorsWithoutAnyEmail.map(v => v.vendorName).join(', ')}에 사용 가능한 이메일이 없습니다. ` +
+ `수신자 추가 버튼(+)을 눌러 이메일을 추가해주세요.`
+ );
+ return;
+ }
+ }
+
+ // 모든 벤더가 주 수신자를 가지고 있는지 최종 확인
+ const finalCheck = vendorsWithRecipients.filter(v => !v.selectedMainEmail || v.selectedMainEmail.trim() === '');
+ if (finalCheck.length > 0) {
+ toast.error(`${finalCheck.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요.`);
return;
}
@@ -874,6 +917,12 @@ export function SendRfqDialog({
);
}, [vendorsWithRecipients]);
+ // 발송 가능 여부 확인 (모든 벤더가 주 수신자를 가지고 있어야 함)
+ const canSend = React.useMemo(() => {
+ if (vendorsWithRecipients.length === 0) return false;
+ return vendorsWithRecipients.every(v => v.selectedMainEmail && v.selectedMainEmail.trim() !== '');
+ }, [vendorsWithRecipients]);
+
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
@@ -1653,7 +1702,7 @@ export function SendRfqDialog({
</Button>
<Button
onClick={handleSend}
- disabled={isSending || isGeneratingPdfs}
+ disabled={isSending || isGeneratingPdfs || !canSend}
>
{isGeneratingPdfs ? (
<>
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts
index e69e78bf..f2a33cda 100644
--- a/lib/vendor-regular-registrations/repository.ts
+++ b/lib/vendor-regular-registrations/repository.ts
@@ -162,7 +162,8 @@ export async function getVendorRegularRegistrations(
// 모든 조건 충족 여부 확인
const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true);
- const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED");
+ // 진행현황과 dialog에서 VENDOR_SIGNED도 완료로 간주하므로, 조건충족 체크도 동일하게 처리
+ const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED" || c.status === "VENDOR_SIGNED");
const safetyQualificationCompleted = !!registration.safetyQualificationContent;
// 모든 조건이 충족되면 status를 "approval_ready"(조건충족)로 자동 변경
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
index ae6ba2a2..eaf62ac7 100644
--- a/lib/vendor-regular-registrations/service.ts
+++ b/lib/vendor-regular-registrations/service.ts
@@ -1,5 +1,4 @@
"use server"
-import { revalidateTag, unstable_cache } from "next/cache";
import {
getVendorRegularRegistrations,
createVendorRegularRegistration,
@@ -67,69 +66,60 @@ async function updatePendingApprovals() {
}
}
-// 캐싱과 에러 핸들링이 포함된 조회 함수
+// 에러 핸들링이 포함된 조회 함수 (캐시 없음)
export async function fetchVendorRegularRegistrations(input?: {
search?: string;
status?: string[];
page?: number;
perPage?: number;
}) {
- return unstable_cache(
- async () => {
- try {
- // 장기미등록 상태 업데이트 실행
- await updatePendingApprovals();
-
- const registrations = await getVendorRegularRegistrations();
-
- let filteredData = registrations;
-
- // 검색 필터링
- if (input?.search) {
- const searchLower = input.search.toLowerCase();
- filteredData = filteredData.filter(
- (reg) =>
- reg.companyName.toLowerCase().includes(searchLower) ||
- reg.businessNumber.toLowerCase().includes(searchLower) ||
- reg.potentialCode?.toLowerCase().includes(searchLower) ||
- reg.representative?.toLowerCase().includes(searchLower)
- );
- }
-
- // 상태 필터링
- if (input?.status && input.status.length > 0) {
- filteredData = filteredData.filter((reg) =>
- input.status!.includes(reg.status)
- );
- }
+ try {
+ // 장기미등록 상태 업데이트 실행
+ await updatePendingApprovals();
+
+ const registrations = await getVendorRegularRegistrations();
+
+ let filteredData = registrations;
+
+ // 검색 필터링
+ if (input?.search) {
+ const searchLower = input.search.toLowerCase();
+ filteredData = filteredData.filter(
+ (reg) =>
+ reg.companyName.toLowerCase().includes(searchLower) ||
+ reg.businessNumber.toLowerCase().includes(searchLower) ||
+ reg.potentialCode?.toLowerCase().includes(searchLower) ||
+ reg.representative?.toLowerCase().includes(searchLower)
+ );
+ }
- // 페이지네이션
- const page = input?.page || 1;
- const perPage = input?.perPage || 50;
- const offset = (page - 1) * perPage;
- const paginatedData = filteredData.slice(offset, offset + perPage);
- const pageCount = Math.ceil(filteredData.length / perPage);
-
- return {
- success: true,
- data: paginatedData,
- pageCount,
- total: filteredData.length,
- };
- } catch (error) {
- console.error("Error in fetchVendorRegularRegistrations:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.",
- };
- }
- },
- [JSON.stringify(input || {})],
- {
- revalidate: 60, // 1분 캐시로 단축
- tags: ["vendor-regular-registrations"],
+ // 상태 필터링
+ if (input?.status && input.status.length > 0) {
+ filteredData = filteredData.filter((reg) =>
+ input.status!.includes(reg.status)
+ );
}
- )();
+
+ // 페이지네이션
+ const page = input?.page || 1;
+ const perPage = input?.perPage || 50;
+ const offset = (page - 1) * perPage;
+ const paginatedData = filteredData.slice(offset, offset + perPage);
+ const pageCount = Math.ceil(filteredData.length / perPage);
+
+ return {
+ success: true,
+ data: paginatedData,
+ pageCount,
+ total: filteredData.length,
+ };
+ } catch (error) {
+ console.error("Error in fetchVendorRegularRegistrations:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "정규업체 등록 목록을 가져오는 중 오류가 발생했습니다.",
+ };
+ }
}
export async function getCurrentUserInfo() {
@@ -402,9 +392,7 @@ export async function updateMajorItems(
return { success: false, error: "등록 정보를 찾을 수 없습니다." };
}
- // 캐시 무효화
- revalidateTag("vendor-regular-registrations");
-
+
return {
success: true,
message: "주요품목이 업데이트되었습니다.",
@@ -418,259 +406,223 @@ export async function updateMajorItems(
}
}
-// 벤더용 현황 조회 함수들
+// 벤더용 현황 조회 함수들 (캐시 없음)
export async function fetchVendorRegistrationStatus(vendorId: number) {
- return unstable_cache(
- async () => {
- try {
- // 벤더 기본 정보
- const vendor = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- taxId: vendors.taxId,
- representativeName: vendors.representativeName,
- country: vendors.country,
- createdAt: vendors.createdAt,
- updatedAt: vendors.updatedAt,
- })
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .limit(1)
-
- if (!vendor[0]) {
- return {
- success: false,
- error: "벤더 정보를 찾을 수 없습니다.",
- }
- }
+ try {
+ // 벤더 기본 정보
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ taxId: vendors.taxId,
+ representativeName: vendors.representativeName,
+ country: vendors.country,
+ createdAt: vendors.createdAt,
+ updatedAt: vendors.updatedAt,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1)
- // 정규업체 등록 정보 (없을 수도 있음 - 기존 정규업체이거나 아직 등록 진행 안함)
- const registration = await db
- .select({
- id: vendorRegularRegistrations.id,
- vendorId: vendorRegularRegistrations.vendorId,
- potentialCode: vendorRegularRegistrations.potentialCode,
- status: vendorRegularRegistrations.status,
- majorItems: vendorRegularRegistrations.majorItems,
- registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
- assignedDepartment: vendorRegularRegistrations.assignedDepartment,
- assignedDepartmentCode: vendorRegularRegistrations.assignedDepartmentCode,
- assignedUser: vendorRegularRegistrations.assignedUser,
- assignedUserCode: vendorRegularRegistrations.assignedUserCode,
- remarks: vendorRegularRegistrations.remarks,
- safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent,
- gtcSkipped: vendorRegularRegistrations.gtcSkipped,
- createdAt: vendorRegularRegistrations.createdAt,
- updatedAt: vendorRegularRegistrations.updatedAt,
- })
- .from(vendorRegularRegistrations)
- .where(eq(vendorRegularRegistrations.vendorId, vendorId))
- .limit(1)
-
- // 정규업체 등록 정보가 없는 경우 (정상적인 상황)
- if (!registration[0]) {
- return {
- success: false,
- error: "정규업체 등록 진행 정보가 없습니다.", // 에러가 아닌 정보성 메시지
- noRegistration: true // 등록 정보가 없음을 명시적으로 표시
- }
- }
+ if (!vendor[0]) {
+ return {
+ success: false,
+ error: "벤더 정보를 찾을 수 없습니다.",
+ }
+ }
- // 벤더 첨부파일 조회
- const vendorFiles = await db
- .select()
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendorId))
-
- // 실사 결과 조회 (vendor_investigation_attachments)
- const investigationFiles = await db
- .select({
- attachmentId: vendorInvestigationAttachments.id,
- fileName: vendorInvestigationAttachments.fileName,
- filePath: vendorInvestigationAttachments.filePath,
- createdAt: vendorInvestigationAttachments.createdAt,
- })
- .from(vendorInvestigationAttachments)
- .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
- .where(eq(vendorInvestigations.vendorId, vendorId))
-
- // PQ 제출 정보
- const pqSubmission = await db
- .select()
- .from(vendorPQSubmissions)
- .where(eq(vendorPQSubmissions.vendorId, vendorId))
- .orderBy(desc(vendorPQSubmissions.createdAt))
- .limit(1)
-
- // 기본계약 정보 - 템플릿 정보와 함께 조회
- const allVendorContracts = await db
- .select({
- templateId: basicContract.templateId,
- templateName: basicContractTemplates.templateName,
- status: basicContract.status,
- createdAt: basicContract.createdAt,
- filePath: basicContract.filePath,
- fileName: basicContract.fileName,
- })
- .from(basicContract)
- .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
- .where(eq(basicContract.vendorId, vendorId))
- .orderBy(desc(basicContract.createdAt))
-
- // 계약 필터링 (기술자료, 비밀유지 제외)
- const filteredContracts = allVendorContracts.filter(contract =>
- contract.templateName &&
- !contract.templateName.includes("기술자료") &&
- !contract.templateName.includes("비밀유지")
- )
+ // 정규업체 등록 정보 (없을 수도 있음 - 기존 정규업체이거나 아직 등록 진행 안함)
+ const registration = await db
+ .select({
+ id: vendorRegularRegistrations.id,
+ vendorId: vendorRegularRegistrations.vendorId,
+ potentialCode: vendorRegularRegistrations.potentialCode,
+ status: vendorRegularRegistrations.status,
+ majorItems: vendorRegularRegistrations.majorItems,
+ registrationRequestDate: vendorRegularRegistrations.registrationRequestDate,
+ assignedDepartment: vendorRegularRegistrations.assignedDepartment,
+ assignedDepartmentCode: vendorRegularRegistrations.assignedDepartmentCode,
+ assignedUser: vendorRegularRegistrations.assignedUser,
+ assignedUserCode: vendorRegularRegistrations.assignedUserCode,
+ remarks: vendorRegularRegistrations.remarks,
+ safetyQualificationContent: vendorRegularRegistrations.safetyQualificationContent,
+ gtcSkipped: vendorRegularRegistrations.gtcSkipped,
+ createdAt: vendorRegularRegistrations.createdAt,
+ updatedAt: vendorRegularRegistrations.updatedAt,
+ })
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.vendorId, vendorId))
+ .limit(1)
- // 템플릿 이름별로 가장 최신 계약만 유지
- const vendorContracts = filteredContracts.reduce((acc: typeof filteredContracts, contract) => {
- const existing = acc.find((c: typeof contract) => c.templateName === contract.templateName)
- if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) {
- return acc.filter((c: typeof contract) => c.templateName !== contract.templateName).concat(contract)
- }
- return acc
- }, [] as typeof filteredContracts)
-
- console.log(`🏢 Partners 벤더 ID ${vendorId} 기본계약 정보:`, {
- allContractsCount: allVendorContracts.length,
- filteredContractsCount: filteredContracts.length,
- finalContractsCount: vendorContracts.length,
- vendorContracts: vendorContracts.map((c: any) => ({
- templateName: c.templateName,
- status: c.status,
- createdAt: c.createdAt
- }))
- })
+ // 정규업체 등록 정보가 없는 경우 (정상적인 상황)
+ if (!registration[0]) {
+ return {
+ success: false,
+ error: "정규업체 등록 진행 정보가 없습니다.", // 에러가 아닌 정보성 메시지
+ noRegistration: true // 등록 정보가 없음을 명시적으로 표시
+ }
+ }
- // 업무담당자 정보
- const businessContacts = await db
- .select()
- .from(vendorBusinessContacts)
- .where(eq(vendorBusinessContacts.vendorId, vendorId))
+ // 벤더 첨부파일 조회
+ const vendorFiles = await db
+ .select()
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendorId))
- // 추가정보
- const additionalInfo = await db
- .select()
- .from(vendorAdditionalInfo)
- .where(eq(vendorAdditionalInfo.vendorId, vendorId))
- .limit(1)
-
- // 문서 제출 현황 계산
- const documentStatus = {
- businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"), // CREDIT_EVALUATION -> CREDIT_REPORT
- bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY"), // BANK_COPY -> BANK_ACCOUNT_COPY
- auditResult: investigationFiles.length > 0, // DocumentStatusDialog에서 사용하는 키
- cpDocument: vendorContracts.some(c => c.status === "COMPLETED"),
- gtc: vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED"),
- standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED"),
- safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED"),
- ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED"),
- domesticCredit: vendorContracts.some(c => c.templateName?.includes("신용") && c.status === "COMPLETED"),
- safetyQualification: investigationFiles.length > 0,
- }
+ // 실사 결과 조회 (vendor_investigation_attachments)
+ const investigationFiles = await db
+ .select({
+ attachmentId: vendorInvestigationAttachments.id,
+ fileName: vendorInvestigationAttachments.fileName,
+ filePath: vendorInvestigationAttachments.filePath,
+ createdAt: vendorInvestigationAttachments.createdAt,
+ })
+ .from(vendorInvestigationAttachments)
+ .innerJoin(vendorInvestigations, eq(vendorInvestigationAttachments.investigationId, vendorInvestigations.id))
+ .where(eq(vendorInvestigations.vendorId, vendorId))
- // 문서별 파일 정보 (다운로드용)
- const documentFiles = {
- businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"),
- creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"),
- bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
- auditResult: investigationFiles,
- }
+ // PQ 제출 정보
+ const pqSubmission = await db
+ .select()
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.vendorId, vendorId))
+ .orderBy(desc(vendorPQSubmissions.createdAt))
+ .limit(1)
- // 미완성 항목 계산
- const missingDocuments = Object.entries(documentStatus)
- .filter(([, value]) => !value)
- .map(([key]) => key)
+ // 기본계약 정보 - 템플릿 정보와 함께 조회
+ const allVendorContracts = await db
+ .select({
+ templateId: basicContract.templateId,
+ templateName: basicContractTemplates.templateName,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ filePath: basicContract.filePath,
+ fileName: basicContract.fileName,
+ })
+ .from(basicContract)
+ .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id))
+ .where(eq(basicContract.vendorId, vendorId))
+ .orderBy(desc(basicContract.createdAt))
+
+ // 계약 필터링 (기술자료, 비밀유지 제외)
+ const filteredContracts = allVendorContracts.filter(contract =>
+ contract.templateName &&
+ !contract.templateName.includes("기술자료") &&
+ !contract.templateName.includes("비밀유지")
+ )
+
+ // 템플릿 이름별로 가장 최신 계약만 유지
+ const vendorContracts = filteredContracts.reduce((acc: typeof filteredContracts, contract) => {
+ const existing = acc.find((c: typeof contract) => c.templateName === contract.templateName)
+ if (!existing || (contract.createdAt && existing.createdAt && contract.createdAt > existing.createdAt)) {
+ return acc.filter((c: typeof contract) => c.templateName !== contract.templateName).concat(contract)
+ }
+ return acc
+ }, [] as typeof filteredContracts)
+
+ console.log(`🏢 Partners 벤더 ID ${vendorId} 기본계약 정보:`, {
+ allContractsCount: allVendorContracts.length,
+ filteredContractsCount: filteredContracts.length,
+ finalContractsCount: vendorContracts.length,
+ vendorContracts: vendorContracts.map((c: any) => ({
+ templateName: c.templateName,
+ status: c.status,
+ createdAt: c.createdAt
+ }))
+ })
+
+ // 업무담당자 정보
+ const businessContacts = await db
+ .select()
+ .from(vendorBusinessContacts)
+ .where(eq(vendorBusinessContacts.vendorId, vendorId))
- const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]
- const existingContactTypes = businessContacts.map(contact => contact.contactType)
- const missingContactTypes = requiredContactTypes.filter(type => !existingContactTypes.includes(type))
-
- // 추가정보 완료 여부 (업무담당자 + 추가정보 테이블 모두 필요)
- const contactsCompleted = missingContactTypes.length === 0
- const additionalInfoTableCompleted = !!additionalInfo[0]
- const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted
-
- console.log(`🔍 Partners 벤더 ID ${vendorId} 전체 데이터:`, {
- vendor: vendor[0],
- registration: registration[0],
- safetyQualificationContent: registration[0]?.safetyQualificationContent,
- gtcSkipped: registration[0]?.gtcSkipped,
- requiredContactTypes,
- existingContactTypes,
- missingContactTypes,
- contactsCompleted,
- additionalInfoTableCompleted,
- additionalInfoData: additionalInfo[0],
- finalAdditionalInfoCompleted: additionalInfoCompleted,
- basicContractsCount: vendorContracts.length
- })
+ // 추가정보
+ const additionalInfo = await db
+ .select()
+ .from(vendorAdditionalInfo)
+ .where(eq(vendorAdditionalInfo.vendorId, vendorId))
+ .limit(1)
- return {
- success: true,
- data: {
- vendor: vendor[0],
- registration: registration[0] || null,
- documentStatus,
- documentFiles, // 문서별 파일 정보 추가
- missingDocuments,
- businessContacts,
- missingContactTypes,
- additionalInfo: additionalInfo[0] || null, // 실제 추가정보 데이터 반환
- additionalInfoCompleted, // 완료 여부는 별도 필드로 추가
- pqSubmission: pqSubmission[0] || null,
- auditPassed: investigationFiles.length > 0,
- basicContracts: vendorContracts, // 기본계약 정보 추가
- incompleteItemsCount: {
- documents: missingDocuments.length,
- contacts: missingContactTypes.length,
- additionalInfo: !additionalInfo[0] ? 1 : 0,
- }
- }
- }
- } catch (error) {
- console.error("Error in fetchVendorRegistrationStatus:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "현황 조회 중 오류가 발생했습니다.",
- }
- }
- },
- [`vendor-registration-status-${vendorId}`],
- {
- revalidate: 300, // 5분 캐시
- tags: ["vendor-registration-status", `vendor-${vendorId}`],
+ // 문서 제출 현황 계산
+ const documentStatus = {
+ businessRegistration: vendorFiles.some(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.some(f => f.attachmentType === "CREDIT_REPORT"), // CREDIT_EVALUATION -> CREDIT_REPORT
+ bankCopy: vendorFiles.some(f => f.attachmentType === "BANK_ACCOUNT_COPY"), // BANK_COPY -> BANK_ACCOUNT_COPY
+ auditResult: investigationFiles.length > 0, // DocumentStatusDialog에서 사용하는 키
+ cpDocument: vendorContracts.some(c => c.status === "COMPLETED"),
+ gtc: vendorContracts.some(c => c.templateName?.includes("GTC") && c.status === "COMPLETED"),
+ standardSubcontract: vendorContracts.some(c => c.templateName?.includes("표준하도급") && c.status === "COMPLETED"),
+ safetyHealth: vendorContracts.some(c => c.templateName?.includes("안전보건") && c.status === "COMPLETED"),
+ ethics: vendorContracts.some(c => c.templateName?.includes("윤리") && c.status === "COMPLETED"),
+ domesticCredit: vendorContracts.some(c => c.templateName?.includes("신용") && c.status === "COMPLETED"),
+ safetyQualification: investigationFiles.length > 0,
}
- )()
-}
-// 서명/직인 업로드 (임시 - 실제로는 파일 업로드 로직 필요)
-export async function uploadVendorSignature(vendorId: number, signatureData: {
- type: "signature" | "seal"
- signerName?: string
- imageFile: string // base64 or file path
-}) {
- try {
- // TODO: 실제 파일 업로드 및 저장 로직 구현
- console.log("Signature upload for vendor:", vendorId, signatureData)
+ // 문서별 파일 정보 (다운로드용)
+ const documentFiles = {
+ businessRegistration: vendorFiles.filter(f => f.attachmentType === "BUSINESS_REGISTRATION"),
+ creditEvaluation: vendorFiles.filter(f => f.attachmentType === "CREDIT_REPORT"),
+ bankCopy: vendorFiles.filter(f => f.attachmentType === "BANK_ACCOUNT_COPY"),
+ auditResult: investigationFiles,
+ }
+
+ // 미완성 항목 계산
+ const missingDocuments = Object.entries(documentStatus)
+ .filter(([, value]) => !value)
+ .map(([key]) => key)
+
+ const requiredContactTypes = ["sales", "design", "delivery", "quality", "tax_invoice"]
+ const existingContactTypes = businessContacts.map(contact => contact.contactType)
+ const missingContactTypes = requiredContactTypes.filter(type => !existingContactTypes.includes(type))
- // 캐시 무효화
- revalidateTag(`vendor-registration-status`)
- revalidateTag(`vendor-${vendorId}`)
+ // 추가정보 완료 여부 (업무담당자 + 추가정보 테이블 모두 필요)
+ const contactsCompleted = missingContactTypes.length === 0
+ const additionalInfoTableCompleted = !!additionalInfo[0]
+ const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted
+ console.log(`🔍 Partners 벤더 ID ${vendorId} 전체 데이터:`, {
+ vendor: vendor[0],
+ registration: registration[0],
+ safetyQualificationContent: registration[0]?.safetyQualificationContent,
+ gtcSkipped: registration[0]?.gtcSkipped,
+ requiredContactTypes,
+ existingContactTypes,
+ missingContactTypes,
+ contactsCompleted,
+ additionalInfoTableCompleted,
+ additionalInfoData: additionalInfo[0],
+ finalAdditionalInfoCompleted: additionalInfoCompleted,
+ basicContractsCount: vendorContracts.length
+ })
+
return {
success: true,
- message: "서명/직인이 등록되었습니다.",
+ data: {
+ vendor: vendor[0],
+ registration: registration[0] || null,
+ documentStatus,
+ documentFiles, // 문서별 파일 정보 추가
+ missingDocuments,
+ businessContacts,
+ missingContactTypes,
+ additionalInfo: additionalInfo[0] || null, // 실제 추가정보 데이터 반환
+ additionalInfoCompleted, // 완료 여부는 별도 필드로 추가
+ pqSubmission: pqSubmission[0] || null,
+ auditPassed: investigationFiles.length > 0,
+ basicContracts: vendorContracts, // 기본계약 정보 추가
+ incompleteItemsCount: {
+ documents: missingDocuments.length,
+ contacts: missingContactTypes.length,
+ additionalInfo: !additionalInfo[0] ? 1 : 0,
+ }
+ }
}
} catch (error) {
- console.error("Error uploading signature:", error)
+ console.error("Error in fetchVendorRegistrationStatus:", error)
return {
success: false,
- error: error instanceof Error ? error.message : "서명/직인 등록 중 오류가 발생했습니다.",
+ error: error instanceof Error ? error.message : "현황 조회 중 오류가 발생했습니다.",
}
}
}
@@ -703,10 +655,6 @@ export async function saveVendorBusinessContacts(
})))
}
- // 캐시 무효화
- revalidateTag("vendor-registration-status")
- revalidateTag(`vendor-${vendorId}`)
-
return {
success: true,
message: "업무담당자 정보가 저장되었습니다.",
@@ -762,10 +710,6 @@ export async function saveVendorAdditionalInfo(
})
}
- // 캐시 무효화
- revalidateTag("vendor-registration-status")
- revalidateTag(`vendor-${vendorId}`)
-
return {
success: true,
message: "추가정보가 저장되었습니다.",
@@ -798,9 +742,6 @@ export async function updateSafetyQualification(
return { success: false, error: "등록 정보를 찾을 수 없습니다." };
}
- // 캐시 무효화
- revalidateTag("vendor-regular-registrations");
-
return {
success: true,
message: "안전적격성 평가가 등록되었습니다.",
@@ -888,110 +829,6 @@ export async function fetchRegistrationRequestData(registrationId: number) {
}
}
-// 정규업체 등록 요청 서버 액션
-// export async function submitRegistrationRequest(
-// registrationId: number,
-// requestData: RegistrationRequestData
-// ) {
-// try {
-// const session = await getServerSession(authOptions);
-// if (!session?.user) {
-// return { success: false, error: "인증이 필요합니다." };
-// }
-
-// // 현재 등록 정보 조회
-// const registration = await db
-// .select()
-// .from(vendorRegularRegistrations)
-// .where(eq(vendorRegularRegistrations.id, registrationId))
-// .limit(1);
-
-// if (!registration[0]) {
-// return { success: false, error: "등록 정보를 찾을 수 없습니다." };
-// }
-
-// // 조건충족 상태인지 확인
-// console.log("📋 업데이트 전 현재 데이터:", {
-// registrationId,
-// currentStatus: registration[0].status,
-// currentRemarks: registration[0].remarks,
-// currentUpdatedAt: registration[0].updatedAt
-// });
-
-// if (registration[0].status !== "approval_ready") {
-// return { success: false, error: "조건충족 상태가 아닙니다." };
-// }
-
-// // 정규업체 등록 요청 데이터를 JSON으로 저장
-// const registrationRequestData = {
-// requestDate: new Date(),
-// requestedBy: session.user.id,
-// requestedByName: session.user.name,
-// requestData: requestData,
-// status: "requested" // 요청됨
-// };
-
-// // 트랜잭션으로 상태 변경
-// const updateResult = await db.transaction(async (tx) => {
-// return await tx
-// .update(vendorRegularRegistrations)
-// .set({
-// status: "registration_requested",
-// remarks: `정규업체 등록 요청됨 - ${new Date().toISOString()}\n요청자: ${session.user.name}`,
-// updatedAt: new Date(),
-// })
-// .where(eq(vendorRegularRegistrations.id, registrationId));
-// });
-
-// console.log("🔄 업데이트 결과:", {
-// registrationId,
-// updateResult,
-// statusToSet: "registration_requested"
-// });
-
-
-
-// // MDG 인터페이스 연동
-// const mdgResult = await sendRegistrationRequestToMDG(registrationId, requestData);
-
-// if (!mdgResult.success) {
-// console.error('❌ MDG 송신 실패:', mdgResult.error);
-// // MDG 송신 실패해도 등록 요청은 성공으로 처리 (재시도 가능하도록)
-// } else {
-// console.log('✅ MDG 송신 성공:', mdgResult.message);
-// }
-
-// // Knox 결재 연동은 별도의 결재 워크플로우에서 처리됩니다.
-// // UI에서 registerVendorWithApproval()을 호출하여 결재 프로세스를 시작합니다.
-
-// console.log("✅ 정규업체 등록 요청 데이터:", {
-// registrationId,
-// companyName: requestData.companyNameKor,
-// businessNumber: requestData.businessNumber,
-// representative: requestData.representativeNameKor,
-// requestedBy: session.user.name,
-// requestDate: new Date().toISOString()
-// });
-
-// // 캐시 무효화 - 더 강력한 무효화
-// revalidateTag("vendor-regular-registrations");
-// revalidateTag(`vendor-regular-registration-${registrationId}`);
-// revalidateTag("vendor-registration-status");
-
-// return {
-// success: true,
-// message: `정규업체 등록 요청이 성공적으로 제출되었습니다.\n${mdgResult.success ? 'MDG 인터페이스 연동이 완료되었습니다.' : 'MDG 인터페이스 연동에 실패했습니다. (재시도 가능)'}\n결재 승인 후 정규업체 등록이 완료됩니다.`
-// };
-
-// } catch (error) {
-// console.error("정규업체 등록 요청 오류:", error);
-// return {
-// success: false,
-// error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다."
-// };
-// }
-// }
-
// MDG로 정규업체 등록 요청 데이터를 보내는 함수
export async function sendRegistrationRequestToMDG(
registrationId: number