summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html2
-rw-r--r--lib/bidding/actions.ts6
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx4
-rw-r--r--lib/bidding/pre-quote/service.ts17
-rw-r--r--lib/bidding/service.ts137
-rw-r--r--lib/itb/service.ts2
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx6
-rw-r--r--lib/pq/service.ts7
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx6
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx4
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx30
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx5
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx21
-rw-r--r--lib/users/auth/partners-auth.ts2
-rw-r--r--lib/vendor-investigation/table/investigation-result-sheet.tsx49
-rw-r--r--lib/vendor-investigation/validations.ts89
-rw-r--r--lib/vendor-regular-registrations/repository.ts3
-rw-r--r--lib/vendors/table/request-vendor-investigate-dialog.tsx345
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx1
19 files changed, 264 insertions, 472 deletions
diff --git a/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html b/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html
index e2a7cf6d..dc5e5fd5 100644
--- a/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html
+++ b/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html
@@ -29,7 +29,7 @@
font-weight: 700;
"
>
- VENDOR 실사의뢰 (재의뢰)
+ VENDOR 실사의뢰
</th>
</tr>
</thead>
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index d0c7a0cd..5909cd62 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -613,11 +613,11 @@ export async function cancelDisposalAction(
}
}
- // 3. 입찰 상태를 입찰 진행중으로 변경
+ // 3. 입찰 상태를 입찰생성으로 변경
await tx
.update(biddings)
.set({
- status: 'evaluation_of_bidding',
+ status: 'bidding_generated',
updatedAt: new Date(),
updatedBy: userName,
})
@@ -734,7 +734,7 @@ export async function earlyOpenBiddingAction(biddingId: number) {
// 5. 참여협력사 중 최종응찰 버튼을 클릭한 업체들만 있는지 검증
// bidding_submitted 상태인 업체들이 있는지 확인 (이미 위에서 검증됨)
- // 6. 조기개찰 상태로 변경
+ // 6. 입찰평가중 상태로 변경
await tx
.update(biddings)
.set({
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index e3db8861..491f29f7 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -180,8 +180,8 @@ export function BiddingDetailVendorToolbarActions({
<>
<div className="flex items-center gap-2">
{/* 상태별 액션 버튼 */}
- {/* 차수증가: 입찰공고 또는 입찰 진행중 상태 */}
- {(bidding.status === 'bidding_generated' || bidding.status === 'bidding_opened') && (
+ {/* 차수증가: 입찰평가중 또는 입찰 진행중 상태 */}
+ {(bidding.status === 'evaluation_of_bidding ' || bidding.status === 'bidding_opened') && (
<Button
variant="outline"
size="sm"
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index ea92f294..19b418ae 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -1492,18 +1492,15 @@ export async function sendBiddingBasicContracts(
try {
await sendEmail({
to: vendor.selectedMainEmail,
- template: 'basic-contract-notification',
+ subject: `[eVCP] 기본계약서 서명 요청`,
+ template: "contract-sign-request",
context: {
vendorName: vendor.vendorName,
- biddingId: biddingId,
- contractCount: contractTypes.length,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
- message: message || '',
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
- })
+ templateName: contractTypes.map(ct => ct.templateName).join(', '),
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/basic-contract`,
+ language:'ko'
+ },
+ });
} catch (emailError) {
console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
// 이메일 발송 실패해도 계약 생성은 유지
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index a7cd8286..b60fc73d 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -33,6 +33,11 @@ import {
like,
notInArray
} from 'drizzle-orm'
+import { revalidatePath } from 'next/cache'
+import { filterColumns } from '@/lib/filter-columns'
+import { GetBiddingsSchema } from './validation'
+
+
// 사용자 이메일로 사용자 코드 조회
export async function getUserCodeByEmail(email: string): Promise<string | null> {
@@ -49,9 +54,6 @@ export async function getUserCodeByEmail(email: string): Promise<string | null>
return null
}
}
-import { revalidatePath } from 'next/cache'
-import { filterColumns } from '@/lib/filter-columns'
-import { CreateBiddingSchema, GetBiddingsSchema, UpdateBiddingSchema } from './validation'
import { saveFile } from '../file-stroage'
// userId를 user.name으로 변환하는 유틸리티 함수
@@ -973,6 +975,74 @@ function generateNextSequence(currentMax: string | null): string {
return result.padStart(4, '0');
}
+// 입찰 참여 현황 카운트 계산 함수
+export async function getParticipantCountsForBidding(biddingId: number) {
+ try {
+ // 전체 참여자 수 (예상 참여자)
+ const expectedResult = await db
+ .select({ count: count() })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ const expected = expectedResult[0]?.count || 0
+
+ // 참여 완료 수
+ const participatedResult = await db
+ .select({ count: count() })
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.invitationStatus, 'bidding_submitted')
+ ))
+
+ const participated = participatedResult[0]?.count || 0
+
+ // 거부/취소 수
+ const declinedResult = await db
+ .select({ count: count() })
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ or(
+ eq(biddingCompanies.invitationStatus, 'bidding_declined'),
+ eq(biddingCompanies.invitationStatus, 'bidding_cancelled')
+ )
+ ))
+
+ const declined = declinedResult[0]?.count || 0
+
+ // 대기중 수
+ const pendingResult = await db
+ .select({ count: count() })
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ or(
+ eq(biddingCompanies.invitationStatus, 'pending'),
+ eq(biddingCompanies.invitationStatus, 'bidding_sent'),
+ eq(biddingCompanies.invitationStatus, 'bidding_accepted')
+ )
+ ))
+
+ const pending = pendingResult[0]?.count || 0
+
+ return {
+ participantExpected: expected,
+ participantParticipated: participated,
+ participantDeclined: declined,
+ participantPending: pending
+ }
+ } catch (error) {
+ console.error('Error in getParticipantCountsForBidding:', error)
+ return {
+ participantExpected: 0,
+ participantParticipated: 0,
+ participantDeclined: 0,
+ participantPending: 0
+ }
+ }
+}
+
// 자동 입찰번호 생성
export async function generateBiddingNumber(
contractType: string,
@@ -982,9 +1052,9 @@ export async function generateBiddingNumber(
): Promise<string> {
// 계약 타입별 접두사 설정
const typePrefix = {
- 'general': 'E',
- 'unit_price': 'F',
- 'sale': 'G'
+ 'general': 'E', // 일반계약
+ 'unit_price': 'F', // 단가계약
+ 'sale': 'G', // 매각계약
};
const prefix = typePrefix[contractType as keyof typeof typePrefix] || 'E';
@@ -3163,8 +3233,8 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u
prNumber: existingBidding.prNumber,
hasPrDocument: existingBidding.hasPrDocument,
- // 상태는 내정가 산정으로 초기화
- status: 'set_target_price',
+ // 상태는 입찰생성으로 초기화
+ status: 'bidding_generated',
isPublic: existingBidding.isPublic,
isUrgent: existingBidding.isUrgent,
@@ -3530,8 +3600,8 @@ export async function getBiddingsForReceive(input: GetBiddingsSchema) {
orderByColumns.push(desc(biddings.createdAt))
}
- // bid-receive 페이지용 데이터 조회 (필요한 컬럼만 선택)
- const data = await db
+ // bid-receive 페이지용 데이터 조회 (참여 현황 제외하고 기본 정보만)
+ const biddingData = await db
.select({
// 기본 입찰 정보
id: biddings.id,
@@ -3549,42 +3619,6 @@ export async function getBiddingsForReceive(input: GetBiddingsSchema) {
createdAt: biddings.createdAt,
updatedAt: biddings.updatedAt,
- // 참여 현황 집계
- participantExpected: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- ), 0)
- `.as('participant_expected'),
-
- participantParticipated: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status = 'bidding_submitted'
- ), 0)
- `.as('participant_participated'),
-
- participantDeclined: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status IN ('bidding_declined', 'bidding_cancelled')
- ), 0)
- `.as('participant_declined'),
-
- participantPending: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status IN ('pending', 'bidding_sent', 'bidding_accepted')
- ), 0)
- `.as('participant_pending'),
-
// 개찰 정보
openedAt: biddings.openedAt,
openedBy: biddings.openedBy,
@@ -3595,6 +3629,17 @@ export async function getBiddingsForReceive(input: GetBiddingsSchema) {
.limit(input.perPage)
.offset(offset)
+ // 각 입찰에 대한 참여 현황 카운트 계산
+ const data = await Promise.all(
+ biddingData.map(async (bidding) => {
+ const participantCounts = await getParticipantCountsForBidding(bidding.id)
+ return {
+ ...bidding,
+ ...participantCounts
+ }
+ })
+ )
+
const pageCount = Math.ceil(total / input.perPage)
return { data, pageCount, total }
diff --git a/lib/itb/service.ts b/lib/itb/service.ts
index 181285cc..882210b3 100644
--- a/lib/itb/service.ts
+++ b/lib/itb/service.ts
@@ -719,7 +719,7 @@ export async function approvePurchaseRequestsAndCreateRfqs(
rfqsLastId: rfq.id,
rfqItem: `${index + 1}`.padStart(3, '0'),
prItem: `${index + 1}`.padStart(3, '0'),
- prNo: rfqCode,
+ prNo: '-',
materialCategory: request.majorItemMaterialCategory,
materialCode: item.itemCode,
materialDescription: item.itemName,
diff --git a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
index aaf10a71..f5a7ff91 100644
--- a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx
@@ -59,7 +59,7 @@ const requestInvestigationFormSchema = z.object({
}),
investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."),
investigationMethod: z.string().optional(),
- investigationNotes: z.string().optional(),
+ investigationNotes: z.string().min(1, "실사 목적을 입력해주세요."),
})
type RequestInvestigationFormValues = z.infer<typeof requestInvestigationFormSchema>
@@ -264,10 +264,10 @@ export function RequestInvestigationDialog({
name="investigationNotes"
render={({ field }) => (
<FormItem>
- <FormLabel>특이사항 (선택사항)</FormLabel>
+ <FormLabel>실사목적</FormLabel>
<FormControl>
<Textarea
- placeholder="실사 관련 특이사항을 입력하세요"
+ placeholder="실사 목적을 입력하세요"
className="resize-none min-h-[60px]"
{...field}
disabled={isPending}
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index fd71888e..57ce5f88 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -2821,7 +2821,7 @@ export async function requestPqSupplementAction({
requesterEmail = requester?.email || null;
}
- const reviewUrl = `http://${host}/evcp/pq/${vendorId}/${pqSubmissionId}`;
+ const reviewUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/partners/pq_new`;
await sendEmail({
to: vendor.email,
@@ -3472,9 +3472,8 @@ export async function getQMManagers() {
.where(
and(
eq(users.isActive, true),
- ne(users.domain, "partners")
- // ilike(users.deptName, "%품질경영팀(%")
- // 테스트 간 임시제거 후 추가 예정(1103)
+ ne(users.domain, "partners"),
+ ilike(users.deptName, "%품질경영팀(%")
)
)
.orderBy(users.name);
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
index 6976e1c5..62f14579 100644
--- a/lib/rfq-last/table/rfq-table-columns.tsx
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -337,7 +337,8 @@ export function getRfqColumns({
cell: ({ row }) => {
const received = row.original.quotationReceivedCount || 0;
const total = row.original.vendorCount || 0;
- return `${received}/${total}`;
+ // return `${received}/${total}`;
+ return <Badge variant="outline">{received}</Badge>;
},
size: 100,
},
@@ -698,7 +699,8 @@ export function getRfqColumns({
cell: ({ row }) => {
const received = row.original.quotationReceivedCount || 0;
const total = row.original.vendorCount || 0;
- return `${received}/${total}`;
+ // return `${received}/${total}`;
+ return <Badge variant="outline">{received}</Badge>;
},
size: 100,
},
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index a6dc1ad4..7216c4d0 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -216,7 +216,7 @@ export function RfqTableToolbarActions<TData>({
)}
</>
)}
- <Button
+ {/* <Button
variant="outline"
size="sm"
className="flex items-center gap-2"
@@ -224,7 +224,7 @@ export function RfqTableToolbarActions<TData>({
>
<FileDown className="h-4 w-4" />
엑셀 다운로드
- </Button>
+ </Button> */}
</div>
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 266ba39b..281316eb 100644
--- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
+++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
@@ -406,7 +406,7 @@ export default function QuotationItemsTable({ prItems, decimalPlaces = 2 }: Quot
// 저장 버튼 클릭 핸들러
const handleSaveDetail = () => {
- setValue(`quotationItems.${index}.deviationReason`, localDeviationReason)
+ setValue(`quotationItems.${index}.deviationReason`, localTechnicalCompliance ? "" : localDeviationReason)
setValue(`quotationItems.${index}.itemRemark`, localItemRemark)
setValue(`quotationItems.${index}.technicalCompliance`, localTechnicalCompliance)
setValue(`quotationItems.${index}.alternativeProposal`, localAlternativeProposal)
@@ -430,7 +430,7 @@ export default function QuotationItemsTable({ prItems, decimalPlaces = 2 }: Quot
<div className="space-y-4">
{/* PR 아이템 정보 */}
- <Card>
+ {/* <Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">PR 아이템 정보</CardTitle>
</CardHeader>
@@ -466,7 +466,7 @@ export default function QuotationItemsTable({ prItems, decimalPlaces = 2 }: Quot
</div>
)}
</CardContent>
- </Card>
+ </Card> */}
{/* 제조사 정보 */}
{/* <Card>
@@ -534,17 +534,19 @@ export default function QuotationItemsTable({ prItems, decimalPlaces = 2 }: Quot
/>
</div>
)}
-
- <div className="space-y-2">
- <Label htmlFor={`deviationReason-${index}`}>편차 사유</Label>
- <Textarea
- id={`deviationReason-${index}`}
- value={localDeviationReason}
- onChange={(e) => setLocalDeviationReason(e.target.value)}
- placeholder="요구사항과 다른 부분이 있는 경우 사유를 입력하세요"
- className="min-h-[80px]"
- />
- </div>
+
+ {!localTechnicalCompliance && (
+ <div className="space-y-2">
+ <Label htmlFor={`deviationReason-${index}`}>편차 사유</Label>
+ <Textarea
+ id={`deviationReason-${index}`}
+ value={localDeviationReason}
+ onChange={(e) => setLocalDeviationReason(e.target.value)}
+ placeholder="요구사항과 다른 부분이 있는 경우 사유를 입력하세요"
+ className="min-h-[80px]"
+ />
+ </div>
+ )}
</CardContent>
</Card>
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
index b7e67881..abd2b516 100644
--- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -466,6 +466,11 @@ export default function VendorResponseEditor({
<form onSubmit={(e) => {
e.preventDefault() // 기본 submit 동작 방지
handleFormSubmit(false)
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault() // 엔터 키로 인한 폼 제출 방지
+ }
}}>
<div className="space-y-6">
{/* 헤더 정보 */}
diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
index 181e33c8..9c112efa 100644
--- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx
+++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
@@ -93,14 +93,14 @@ const getStatusConfig = (status: string) => {
icon: <AlertCircle className="h-4 w-4" />,
color: "text-orange-600",
bgColor: "bg-orange-50",
- variant: "warning" as const,
+ variant: "secondary" as const,
};
case "최종확정":
return {
icon: <Shield className="h-4 w-4" />,
color: "text-indigo-600",
bgColor: "bg-indigo-50",
- variant: "success" as const,
+ variant: "default" as const,
};
case "취소":
return {
@@ -183,11 +183,10 @@ export function VendorResponseDetailDialog({
<div className="flex-1 overflow-y-auto px-6 ">
<Tabs defaultValue="overview" className="mb-2">
- <TabsList className="grid w-full grid-cols-4">
+ <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">개요</TabsTrigger>
<TabsTrigger value="quotation">견적정보</TabsTrigger>
<TabsTrigger value="items">품목상세</TabsTrigger>
- <TabsTrigger value="attachments">첨부파일</TabsTrigger>
</TabsList>
{/* 개요 탭 */}
@@ -775,7 +774,7 @@ export function VendorResponseDetailDialog({
</TabsContent>
{/* 첨부파일 탭 */}
- <TabsContent value="attachments" className="space-y-4">
+ {/* <TabsContent value="attachments" className="space-y-4">
{attachments.length > 0 ? (
<Card>
<CardHeader>
@@ -802,16 +801,6 @@ export function VendorResponseDetailDialog({
</div>
</div>
<div className="flex items-center gap-2">
- {/* <Button
- variant="ghost"
- size="sm"
- onClick={() => {
- // 파일 미리보기 로직
- console.log("Preview file:", file.filePath);
- }}
- >
- <Eye className="h-4 w-4" />
- </Button> */}
<Button
variant="ghost"
size="sm"
@@ -837,7 +826,7 @@ export function VendorResponseDetailDialog({
</CardContent>
</Card>
)}
- </TabsContent>
+ </TabsContent> */}
</Tabs>
</div>
diff --git a/lib/users/auth/partners-auth.ts b/lib/users/auth/partners-auth.ts
index ac0dec08..b530b608 100644
--- a/lib/users/auth/partners-auth.ts
+++ b/lib/users/auth/partners-auth.ts
@@ -251,7 +251,7 @@ export async function validateResetTokenAction(
}
if (!resetToken[0].isActive) {
- return { valid: false, error: '이미 사용된 토큰입니다' };
+ return { valid: false, error: '비밀번호 설정을 이미 완료하였습니다.' };
}
if (resetToken[0].expiresAt < new Date()) {
diff --git a/lib/vendor-investigation/table/investigation-result-sheet.tsx b/lib/vendor-investigation/table/investigation-result-sheet.tsx
index 36000333..740b837e 100644
--- a/lib/vendor-investigation/table/investigation-result-sheet.tsx
+++ b/lib/vendor-investigation/table/investigation-result-sheet.tsx
@@ -129,10 +129,11 @@ export function InvestigationResultSheet({
defaultValues: {
investigationId: investigation?.investigationId ?? 0,
completedAt: investigation?.completedAt ?? undefined,
+ requestedAt: investigation?.requestedAt ?? undefined, // 날짜 검증을 위해 추가
evaluationScore: investigation?.evaluationScore ?? undefined,
evaluationResult: investigation?.evaluationResult ?? undefined,
investigationNotes: investigation?.investigationNotes ?? "",
- attachments: undefined,
+ attachments: [],
},
})
@@ -175,10 +176,11 @@ export function InvestigationResultSheet({
form.reset({
investigationId: investigation.investigationId,
completedAt: investigation.completedAt ?? undefined,
+ requestedAt: investigation.requestedAt ?? undefined, // 날짜 검증을 위해 추가
evaluationScore: investigation.evaluationScore ?? undefined,
evaluationResult: investigation.evaluationResult ?? undefined,
investigationNotes: investigation.investigationNotes ?? "",
- attachments: undefined,
+ attachments: [],
})
// 기존 첨부파일 로드
@@ -240,9 +242,9 @@ export function InvestigationResultSheet({
// 선택된 파일에서 특정 파일 제거
const handleRemoveSelectedFile = (indexToRemove: number) => {
- const currentFiles = form.getValues("attachments") || []
+ const currentFiles = (form.getValues("attachments") as File[]) || []
const updatedFiles = currentFiles.filter((_: File, index: number) => index !== indexToRemove)
- form.setValue("attachments", updatedFiles.length > 0 ? updatedFiles : undefined)
+ form.setValue("attachments", updatedFiles as any)
if (updatedFiles.length === 0) {
toast.success("모든 선택된 파일이 제거되었습니다.")
@@ -326,7 +328,6 @@ export function InvestigationResultSheet({
name="attachments"
render={({ field: { onChange, ...field } }) => (
<FormItem>
- <FormLabel>{config.label}</FormLabel>
<FormControl>
<Dropzone
onDrop={(acceptedFiles, rejectedFiles) => {
@@ -346,7 +347,7 @@ export function InvestigationResultSheet({
if (acceptedFiles.length > 0) {
// 기존 파일들과 새로 선택된 파일들을 합치기
- const currentFiles = form.getValues("attachments") || []
+ const currentFiles = (form.getValues("attachments") as File[]) || []
const newFiles = [...currentFiles, ...acceptedFiles]
onChange(newFiles)
toast.success(`${acceptedFiles.length}개 파일이 추가되었습니다.`)
@@ -513,13 +514,23 @@ export function InvestigationResultSheet({
}
}
- // 2) 파일이 있으면 업로드
- if (values.attachments && values.attachments.length > 0) {
+ // 2) 첨부파일 검증 (필수: 기존 첨부파일 + 새 파일 합계 최소 1개)
+ const newFilesCount = values.attachments?.length || 0
+ const existingFilesCount = existingAttachments.length
+ const totalFilesCount = newFilesCount + existingFilesCount
+
+ if (totalFilesCount === 0) {
+ toast.error("최소 1개의 첨부파일이 필요합니다.")
+ return
+ }
+
+ // 새 파일이 있는 경우에만 업로드 진행
+ if (newFilesCount > 0) {
setUploadingFiles(true)
try {
await uploadFiles(values.attachments, values.investigationId)
- toast.success(`실사 결과와 ${values.attachments.length}개 파일이 업데이트되었습니다!`)
+ toast.success(`실사 결과와 ${newFilesCount}개 파일이 업데이트되었습니다!`)
// 첨부파일 목록 새로고침
loadExistingAttachments(values.investigationId)
@@ -530,6 +541,7 @@ export function InvestigationResultSheet({
setUploadingFiles(false)
}
} else {
+ // 기존 첨부파일만 있는 경우
toast.success("실사 결과가 업데이트되었습니다!")
}
@@ -668,7 +680,14 @@ export function InvestigationResultSheet({
if (result === "APPROVED") return <span className="text-green-600">합격 (승인)</span>
if (result === "SUPPLEMENT") return <span className="text-yellow-600">보완 필요 (방법 선택)</span>
if (result === "SUPPLEMENT_REINSPECT") return <span className="text-yellow-600">보완 필요 - 재실사</span>
- if (result === "SUPPLEMENT_DOCUMENT") return <span className="text-yellow-600">보완 필요 - 자료제출</span>
+ if (result === "SUPPLEMENT_DOCUMENT") return (
+ <div className="flex flex-col gap-1">
+ <span className="text-yellow-600">보완 필요 - 자료제출</span>
+ <span className="text-xs text-muted-foreground font-normal">
+ 💡 저장 시 협력업체에 자동으로 보완 요청 메일이 발송됩니다.
+ </span>
+ </div>
+ )
if (result === "REJECTED") return <span className="text-destructive">불합격</span>
return <span className="text-muted-foreground">-</span>
})()}
@@ -711,8 +730,14 @@ export function InvestigationResultSheet({
)}
/>
- {/* 파일 첨부 섹션 */}
- {renderFileUploadSection()}
+ {/* 파일 첨부 섹션 */}
+ <div className="space-y-2">
+ <FormLabel>실사 관련 첨부파일<span className="text-red-500 ml-1">*</span></FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 실사 결과 입력 시 최소 1개의 첨부파일이 필요합니다.
+ </div>
+ {renderFileUploadSection()}
+ </div>
</form>
</Form>
</div>
diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts
index 891ef178..84361ef9 100644
--- a/lib/vendor-investigation/validations.ts
+++ b/lib/vendor-investigation/validations.ts
@@ -98,6 +98,20 @@ export const updateVendorInvestigationProgressSchema = z
message: "실사 수행 예정일은 필수입니다.",
})
}
+
+ // 날짜 순서 검증: forecastedAt과 confirmedAt 간의 관계 검증
+ if (data.forecastedAt && data.confirmedAt) {
+ const forecastedDate = new Date(data.forecastedAt);
+ const confirmedDate = new Date(data.confirmedAt);
+
+ if (confirmedDate < forecastedDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["confirmedAt"],
+ message: "실사 계획 확정일은 실사 수행 예정일보다 과거일 수 없습니다.",
+ });
+ }
+ }
})
export type UpdateVendorInvestigationProgressSchema = z.infer<typeof updateVendorInvestigationProgressSchema>
@@ -114,15 +128,33 @@ export const updateVendorInvestigationResultSchema = z.object({
z.string().transform((str) => str ? new Date(str) : undefined)
]),
+ // 실사의뢰일 (날짜 검증을 위해 추가)
+ requestedAt: z.union([
+ z.date(),
+ z.string().transform((str) => str ? new Date(str) : undefined)
+ ]).optional(),
+
evaluationScore: z.number()
.int("평가 점수는 정수여야 합니다.")
.min(0, "평가 점수는 0점 이상이어야 합니다.")
.max(100, "평가 점수는 100점 이하여야 합니다."),
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
- attachments: z.any({
- required_error: "첨부파일은 필수입니다."
- }),
+ attachments: z.array(z.any()).min(1, "최소 1개의 첨부파일이 필요합니다."),
+}).superRefine((data, ctx) => {
+ // 날짜 검증: 실제 실사일이 실사의뢰일보다 과거가 되지 않도록 검증
+ if (data.requestedAt && data.completedAt) {
+ const requestedDate = new Date(data.requestedAt);
+ const completedDate = new Date(data.completedAt);
+
+ if (completedDate < requestedDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["completedAt"],
+ message: "실제 실사일은 실사의뢰일보다 과거일 수 없습니다.",
+ });
+ }
+ }
})
export type UpdateVendorInvestigationResultSchema = z.infer<typeof updateVendorInvestigationResultSchema>
@@ -137,28 +169,28 @@ export const updateVendorInvestigationSchema = z.object({
}),
investigationAddress: z.string().optional(),
investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(),
-
+
// 날짜 필드들
forecastedAt: z.union([
z.date(),
z.string().transform((str) => str ? new Date(str) : undefined)
]).optional(),
-
+
requestedAt: z.union([
z.date(),
z.string().transform((str) => str ? new Date(str) : undefined)
]).optional(),
-
+
confirmedAt: z.union([
z.date(),
z.string().transform((str) => str ? new Date(str) : undefined)
]).optional(),
-
+
completedAt: z.union([
z.date(),
z.string().transform((str) => str ? new Date(str) : undefined)
]).optional(),
-
+
evaluationScore: z.number()
.int("평가 점수는 정수여야 합니다.")
.min(0, "평가 점수는 0점 이상이어야 합니다.")
@@ -167,6 +199,47 @@ export const updateVendorInvestigationSchema = z.object({
evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "SUPPLEMENT_REINSPECT", "SUPPLEMENT_DOCUMENT", "REJECTED", "RESULT_SENT"]).optional(),
investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(),
attachments: z.any().optional(), // File 업로드를 위한 필드
+}).superRefine((data, ctx) => {
+ // 날짜 검증: 실사의뢰일(requestedAt)이 있는 경우 다른 날짜들이 실사의뢰일보다 과거가 되지 않도록 검증
+ if (data.requestedAt) {
+ const requestedDate = new Date(data.requestedAt);
+
+ // 실사수행/계획일(forecastedAt) 검증
+ if (data.forecastedAt) {
+ const forecastedDate = new Date(data.forecastedAt);
+ if (forecastedDate < requestedDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["forecastedAt"],
+ message: "실사 수행 예정일은 실사의뢰일보다 과거일 수 없습니다.",
+ });
+ }
+ }
+
+ // 실사 계획 확정일(confirmedAt) 검증
+ if (data.confirmedAt) {
+ const confirmedDate = new Date(data.confirmedAt);
+ if (confirmedDate < requestedDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["confirmedAt"],
+ message: "실사 계획 확정일은 실사의뢰일보다 과거일 수 없습니다.",
+ });
+ }
+ }
+
+ // 실제 실사일(completedAt) 검증
+ if (data.completedAt) {
+ const completedDate = new Date(data.completedAt);
+ if (completedDate < requestedDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["completedAt"],
+ message: "실제 실사일은 실사의뢰일보다 과거일 수 없습니다.",
+ });
+ }
+ }
+ }
})
export type UpdateVendorInvestigationSchema = z.infer<typeof updateVendorInvestigationSchema> \ No newline at end of file
diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts
index f2a33cda..314afb6c 100644
--- a/lib/vendor-regular-registrations/repository.ts
+++ b/lib/vendor-regular-registrations/repository.ts
@@ -170,7 +170,8 @@ export async function getVendorRegularRegistrations(
const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
// 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
- if (shouldUpdateStatus && registration.status !== "approval_ready") {
+ // 단, 결재진행중(pending_approval) 또는 등록요청완료(registration_completed), 등록실패(registration_failed) 상태인 경우 무시
+ if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "pending_approval" && registration.status !== "registration_completed" && registration.status !== "registration_failed") {
// 비동기 업데이트 (백그라운드에서 실행)
updateVendorRegularRegistration(registration.id, {
status: "approval_ready"
diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx
deleted file mode 100644
index a0d84128..00000000
--- a/lib/vendors/table/request-vendor-investigate-dialog.tsx
+++ /dev/null
@@ -1,345 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Check, SendHorizonal, AlertCircle, AlertTriangle } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from "@/components/ui/accordion"
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-
-import { Vendor } from "@/db/schema/vendors"
-import { requestInvestigateVendors, getExistingInvestigationsForVendors } from "@/lib/vendor-investigation/service"
-import { useSession } from "next-auth/react"
-import { formatDate } from "@/lib/utils"
-
-interface ApprovalVendorDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- vendors: Row<Vendor>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-// Helper function to get status badge variant and text
-function getStatusBadge(status: string) {
- switch (status) {
- case "REQUESTED":
- return { variant: "secondary", text: "Requested" }
- case "SCHEDULED":
- return { variant: "warning", text: "Scheduled" }
- case "IN_PROGRESS":
- return { variant: "default", text: "In Progress" }
- case "COMPLETED":
- return { variant: "success", text: "Completed" }
- case "CANCELLED":
- return { variant: "destructive", text: "Cancelled" }
- default:
- return { variant: "outline", text: status }
- }
-}
-
-export function RequestVendorsInvestigateDialog({
- vendors,
- showTrigger = true,
- onSuccess,
- ...props
-}: ApprovalVendorDialogProps) {
- const [isApprovePending, startApproveTransition] = React.useTransition()
- const [isLoading, setIsLoading] = React.useState(true)
- const [existingInvestigations, setExistingInvestigations] = React.useState<any[]>([])
- const isDesktop = useMediaQuery("(min-width: 640px)")
- const { data: session } = useSession()
-
- // Fetch existing investigations when dialog opens
- React.useEffect(() => {
- if (vendors.length > 0) {
- setIsLoading(true)
- const fetchExistingInvestigations = async () => {
- try {
- const vendorIds = vendors.map(vendor => vendor.id)
- const result = await getExistingInvestigationsForVendors(vendorIds)
- setExistingInvestigations(result)
- } catch (error) {
- console.error("Failed to fetch existing investigations:", error)
- toast.error("Failed to fetch existing investigations")
- } finally {
- setIsLoading(false)
- }
- }
-
- fetchExistingInvestigations()
- }
- }, [vendors])
-
- // Group vendors by investigation status
- const vendorsWithInvestigations = React.useMemo(() => {
- if (!existingInvestigations.length) return { withInvestigations: [], withoutInvestigations: vendors }
-
- const vendorMap = new Map(vendors.map(v => [v.id, v]))
- const withInvestigations: Array<{ vendor: typeof vendors[0], investigation: any }> = []
-
- // Find vendors with existing investigations
- existingInvestigations.forEach(inv => {
- const vendor = vendorMap.get(inv.vendorId)
- if (vendor) {
- withInvestigations.push({ vendor, investigation: inv })
- vendorMap.delete(inv.vendorId)
- }
- })
-
- // Remaining vendors don't have investigations
- const withoutInvestigations = Array.from(vendorMap.values())
-
- return { withInvestigations, withoutInvestigations }
- }, [vendors, existingInvestigations])
-
- function onApprove() {
- if (!session?.user?.id) {
- toast.error("사용자 인증 정보를 찾을 수 없습니다.")
- return
- }
-
- // Only request investigations for vendors without existing ones
- const vendorsToRequest = vendorsWithInvestigations.withoutInvestigations
-
- if (vendorsToRequest.length === 0) {
- toast.info("모든 선택된 업체에 이미 실사 요청이 있습니다.")
- props.onOpenChange?.(false)
- return
- }
-
- startApproveTransition(async () => {
- const { error } = await requestInvestigateVendors({
- ids: vendorsToRequest.map((vendor) => vendor.id),
- userId: Number(session.user.id)
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success(`${vendorsToRequest.length}개 업체에 대한 실사 요청을 보냈습니다.`)
- onSuccess?.()
- })
- }
-
- const renderContent = () => {
- return (
- <>
- <div className="space-y-4">
- {isLoading ? (
- <div className="flex items-center justify-center py-4">
- <Loader className="size-6 animate-spin text-muted-foreground" />
- </div>
- ) : (
- <>
- {vendorsWithInvestigations.withInvestigations.length > 0 && (
- <Alert>
- <AlertTriangle className="h-4 w-4" />
- <AlertTitle>기존 실사 요청 정보가 있습니다</AlertTitle>
- <AlertDescription>
- 선택한 {vendors.length}개 업체 중 {vendorsWithInvestigations.withInvestigations.length}개 업체에 대한
- 기존 실사 요청이 있습니다. 새로운 요청은 기존 데이터가 없는 업체에만 적용됩니다.
- </AlertDescription>
- </Alert>
- )}
-
- {vendorsWithInvestigations.withInvestigations.length > 0 && (
- <Accordion type="single" collapsible className="w-full">
- <AccordionItem value="existing-investigations">
- <AccordionTrigger className="font-medium">
- 기존 실사 요청 ({vendorsWithInvestigations.withInvestigations.length})
- </AccordionTrigger>
- <AccordionContent>
- <ScrollArea className="max-h-[200px]">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>업체명</TableHead>
- <TableHead>상태</TableHead>
- <TableHead>요청일</TableHead>
- <TableHead>예정 일정</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {vendorsWithInvestigations.withInvestigations.map(({ vendor, investigation }) => {
- const status = getStatusBadge(investigation.investigationStatus)
- return (
- <TableRow key={investigation.investigationId}>
- <TableCell className="font-medium">{vendor.vendorName}</TableCell>
- <TableCell>
- <Badge variant={status.variant as any}>{status.text}</Badge>
- </TableCell>
- <TableCell>{formatDate(investigation.createdAt, "KR")}</TableCell>
- <TableCell>
- {investigation.scheduledStartAt
- ? formatDate(investigation.scheduledStartAt, "KR")
- : "미정"}
- </TableCell>
- </TableRow>
- )
- })}
- </TableBody>
- </Table>
- </ScrollArea>
- </AccordionContent>
- </AccordionItem>
- </Accordion>
- )}
-
- <div>
- <h3 className="text-sm font-medium mb-2">
- 새로운 실사가 요청될 업체 ({vendorsWithInvestigations.withoutInvestigations.length})
- </h3>
- {vendorsWithInvestigations.withoutInvestigations.length > 0 ? (
- <ScrollArea className="max-h-[200px]">
- <ul className="space-y-1">
- {vendorsWithInvestigations.withoutInvestigations.map((vendor) => (
- <li key={vendor.id} className="text-sm py-1 px-2 border-b">
- {vendor.vendorName} ({vendor.vendorCode || "코드 없음"})
- </li>
- ))}
- </ul>
- </ScrollArea>
- ) : (
- <p className="text-sm text-muted-foreground py-2">
- 모든 선택된 업체에 이미 실사 요청이 있습니다.
- </p>
- )}
- </div>
- </>
- )}
- </div>
- </>
- )
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <SendHorizonal className="size-4" aria-hidden="true" />
- 실사 요청 ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle>Confirm Vendor Investigation Request</DialogTitle>
- <DialogDescription>
- 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다.
- 요청 후 협력업체실사담당자에게 알림이 전송됩니다.
- </DialogDescription>
- </DialogHeader>
-
- {renderContent()}
-
- <DialogFooter className="gap-2 sm:space-x-0 mt-4">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Request selected vendors"
- variant="default"
- onClick={onApprove}
- disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0}
- >
- {isApprovePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 요청하기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <Check className="size-4" aria-hidden="true" />
- Investigation Request ({vendors.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>Confirm Vendor Investigation</DrawerTitle>
- <DrawerDescription>
- 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다.
- 요청 후 협력업체실사담당자에게 알림이 전송됩니다.
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="px-4">
- {renderContent()}
- </div>
-
- <DrawerFooter className="gap-2 sm:space-x-0 mt-4">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Request selected vendors"
- variant="default"
- onClick={onApprove}
- disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0}
- >
- {isApprovePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 요청하기
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
index def46168..a2611b64 100644
--- a/lib/vendors/table/vendors-table-toolbar-actions.tsx
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -20,7 +20,6 @@ import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog"
import { RequestPQDialog } from "./request-pq-dialog"
import { RequestProjectPQDialog } from "./request-project-pq-dialog"
import { SendVendorsDialog } from "./send-vendor-dialog"
-import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog"
import { RequestInfoDialog } from "./request-additional-Info-dialog"
import { RequestContractDialog } from "./request-basicContract-dialog"
import { exportVendorsWithRelatedData } from "./vendor-all-export"