summaryrefslogtreecommitdiff
path: root/components/pq-input
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-03 04:48:47 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-03 04:48:47 +0000
commitdefda07c0bb4b0bd444ca8dc4fd3f89322bda0ce (patch)
treed7f257781f107d7ec2fd4ef76cb4f840f5065a06 /components/pq-input
parent00743c8b4190fac9117c2d9c08981bbfdce576de (diff)
(대표님) edp, tbe, dolce 등
Diffstat (limited to 'components/pq-input')
-rw-r--r--components/pq-input/pq-input-tabs.tsx278
-rw-r--r--components/pq-input/pq-review-wrapper.tsx63
2 files changed, 231 insertions, 110 deletions
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
index a37a52db..3f7e1718 100644
--- a/components/pq-input/pq-input-tabs.tsx
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -15,7 +15,7 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
-import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download } from "lucide-react"
+import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown, Download, Loader2 } from "lucide-react"
import prettyBytes from "pretty-bytes"
import { useToast } from "@/hooks/use-toast"
import {
@@ -68,6 +68,7 @@ import {
// Additional UI
import { Badge } from "@/components/ui/badge"
+import { Checkbox } from "@/components/ui/checkbox"
// Server actions
import {
@@ -156,6 +157,14 @@ export function PQInputTabs({
const [allSaved, setAllSaved] = React.useState(false)
const [showConfirmDialog, setShowConfirmDialog] = React.useState(false)
+ // 필터 상태 관리
+ const [filterOptions, setFilterOptions] = React.useState({
+ showAll: true,
+ showSaved: true,
+ showNotSaved: true,
+ })
+
+
const { toast } = useToast()
const shouldDisableInput = isReadOnly;
@@ -166,10 +175,10 @@ export function PQInputTabs({
const parseCode = (code: string) => {
return code.split('-').map(part => parseInt(part, 10))
}
-
+
const aCode = parseCode(a.code)
const bCode = parseCode(b.code)
-
+
for (let i = 0; i < Math.max(aCode.length, bCode.length); i++) {
const aPart = aCode[i] || 0
const bPart = bCode[i] || 0
@@ -181,6 +190,14 @@ export function PQInputTabs({
})
}
+ // 필터링 함수
+ const shouldShowItem = (isSaved: boolean) => {
+ if (filterOptions.showAll) return true;
+ if (isSaved && filterOptions.showSaved) return true;
+ if (!isSaved && filterOptions.showNotSaved) return true;
+ return false;
+ }
+
// ----------------------------------------------------------------------
// A) Create initial form values
// Mark items as "saved" if they have existing answer or attachments
@@ -219,6 +236,7 @@ export function PQInputTabs({
return { answers }
}
+
// ----------------------------------------------------------------------
// B) Set up react-hook-form
// ----------------------------------------------------------------------
@@ -339,7 +357,7 @@ export function PQInputTabs({
if (answerData.answer) {
switch (inputFormat) {
case "EMAIL":
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(answerData.answer)) {
toast({
title: "이메일 형식 오류",
@@ -350,22 +368,24 @@ export function PQInputTabs({
}
break
case "PHONE":
- const phoneRegex = /^[\d-]+$/
+ case "FAX":
+ // 전화번호/팩스번호는 숫자만 허용
+ const phoneRegex = /^\d+$/
if (!phoneRegex.test(answerData.answer)) {
toast({
- title: "전화번호 형식 오류",
- description: "올바른 전화번호 형식을 입력해주세요. (예: 02-1234-5678)",
+ title: `${inputFormat === "PHONE" ? "전화번호" : "팩스번호"} 형식 오류`,
+ description: `숫자만 입력해주세요.`,
variant: "destructive",
})
return
}
break
case "NUMBER":
- const numberRegex = /^-?\d*\.?\d*$/
+ const numberRegex = /^-?\d+(\.\d+)?$/
if (!numberRegex.test(answerData.answer)) {
toast({
title: "숫자 형식 오류",
- description: "숫자만 입력해주세요. (소수점, 음수 허용)",
+ description: "올바른 숫자 형식을 입력해주세요. (예: 123, -123, 123.45)",
variant: "destructive",
})
return
@@ -389,7 +409,7 @@ export function PQInputTabs({
for (const localFile of answerData.newUploads) {
try {
const uploadResult = await uploadVendorFileAction(localFile.fileObj)
- const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`)
+ const currentUploaded = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)]
currentUploaded.push({
fileName: uploadResult.fileName,
url: uploadResult.url,
@@ -435,10 +455,7 @@ export function PQInputTabs({
if (saveResult.ok) {
// Mark as saved
form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false })
- toast({
- title: "Saved",
- description: "Item saved successfully",
- })
+ // Individual save toast removed - only show toast in handleSaveAll
}
} catch (error) {
console.error("Save error:", error)
@@ -470,6 +487,7 @@ export function PQInputTabs({
try {
setIsSaving(true)
const answers = form.getValues().answers
+ let savedCount = 0
// Only save items that are dirty or have new uploads
for (let i = 0; i < answers.length; i++) {
@@ -478,17 +496,26 @@ export function PQInputTabs({
if (!itemDirty && !hasNewUploads) continue
await handleSaveItem(i)
+ savedCount++
}
- toast({
- title: "All Saved",
- description: "All items saved successfully",
- })
+ // 저장된 항목이 있을 때만 토스트 메시지 표시
+ if (savedCount > 0) {
+ toast({
+ title: "임시 저장 완료",
+ description: `항목이 저장되었습니다.`,
+ })
+ } else {
+ toast({
+ title: "저장할 항목 없음",
+ description: "변경된 항목이 없습니다.",
+ })
+ }
} catch (error) {
console.error("Save all error:", error)
toast({
- title: "Save Error",
- description: "Failed to save all items",
+ title: "저장 실패",
+ description: "일괄 저장 중 오류가 발생했습니다.",
variant: "destructive",
})
} finally {
@@ -614,53 +641,125 @@ export function PQInputTabs({
{renderProjectInfo()}
<Tabs defaultValue={data[0]?.groupName || ""} className="w-full">
- {/* Top Controls */}
- <div className="flex justify-between items-center mb-4">
- <TabsList className="grid grid-cols-4">
- {data.map((group) => (
- <TabsTrigger
- key={group.groupName}
- value={group.groupName}
- className="truncate"
+ {/* Top Controls - Sticky Header */}
+ <div className="sticky top-0 z-10 bg-background border-b border-border mb-4 pb-4">
+ {/* Filter Controls */}
+ <div className="mb-3 flex items-center gap-4">
+ <span className="text-sm font-medium">필터:</span>
+ <div className="flex items-center gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="showAll"
+ checked={filterOptions.showAll}
+ onCheckedChange={(checked) => {
+ const newOptions = { ...filterOptions, showAll: !!checked };
+ if (!checked && !filterOptions.showSaved && !filterOptions.showNotSaved) {
+ // 최소 하나는 체크되어 있어야 함
+ newOptions.showSaved = true;
+ }
+ setFilterOptions(newOptions);
+ }}
+ />
+ <label htmlFor="showAll" className="text-sm">전체 항목</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="showSaved"
+ checked={filterOptions.showSaved}
+ onCheckedChange={(checked) => {
+ const newOptions = { ...filterOptions, showSaved: !!checked };
+ if (!checked && !filterOptions.showAll && !filterOptions.showNotSaved) {
+ // 최소 하나는 체크되어 있어야 함
+ newOptions.showAll = true;
+ }
+ setFilterOptions(newOptions);
+ }}
+ />
+ <label htmlFor="showSaved" className="text-sm text-green-600">Save 항목</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="showNotSaved"
+ checked={filterOptions.showNotSaved}
+ onCheckedChange={(checked) => {
+ const newOptions = { ...filterOptions, showNotSaved: !!checked };
+ if (!checked && !filterOptions.showAll && !filterOptions.showSaved) {
+ // 최소 하나는 체크되어 있어야 함
+ newOptions.showAll = true;
+ }
+ setFilterOptions(newOptions);
+ }}
+ />
+ <label htmlFor="showNotSaved" className="text-sm text-amber-600">Not Save 항목</label>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-between items-center">
+ <TabsList className="grid grid-cols-4">
+ {data.map((group) => (
+ <TabsTrigger
+ key={group.groupName}
+ value={group.groupName}
+ className="truncate"
+ >
+ <div className="flex items-center gap-2">
+ {/* Mobile: truncated version */}
+ <span className="block sm:hidden">
+ {group.groupName.length > 5
+ ? group.groupName.slice(0, 5) + "..."
+ : group.groupName}
+ </span>
+ {/* Desktop: full text */}
+ <span className="hidden sm:block">{group.groupName}</span>
+ <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium">
+ {group.items.length}
+ </span>
+ </div>
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ <div className="flex gap-2">
+ {/* Save All button */}
+ <Button
+ type="button"
+ variant="outline"
+ disabled={isSaving || !isAnyItemDirty || shouldDisableInput}
+ onClick={handleSaveAll}
>
- <div className="flex items-center gap-2">
- {/* Mobile: truncated version */}
- <span className="block sm:hidden">
- {group.groupName.length > 5
- ? group.groupName.slice(0, 5) + "..."
- : group.groupName}
- </span>
- {/* Desktop: full text */}
- <span className="hidden sm:block">{group.groupName}</span>
- <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium">
- {group.items.length}
- </span>
- </div>
- </TabsTrigger>
- ))}
- </TabsList>
-
- <div className="flex gap-2">
- {/* Save All button */}
- <Button
- type="button"
- variant="outline"
- disabled={isSaving || !isAnyItemDirty || shouldDisableInput}
- onClick={handleSaveAll}
- >
- {isSaving ? "Saving..." : "임시 저장"}
- <Save className="ml-2 h-4 w-4" />
- </Button>
-
- {/* Submit PQ button */}
- <Button
- type="button"
- disabled={!allSaved || isSubmitting || shouldDisableInput}
- onClick={handleSubmitPQ}
- >
- {isSubmitting ? "Submitting..." : "최종 제출"}
- <CheckCircle2 className="ml-2 h-4 w-4" />
- </Button>
+ {isSaving ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ 임시 저장
+ </>
+ )}
+ </Button>
+
+ {/* Submit PQ button */}
+ <Button
+ type="button"
+ disabled={!allSaved || isSubmitting || shouldDisableInput}
+ onClick={handleSubmitPQ}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 제출 중...
+ </>
+ ) : (
+ <>
+ <CheckCircle2 className="mr-2 h-4 w-4" />
+ 최종 제출
+ </>
+ )}
+ </Button>
+ </div>
</div>
</div>
@@ -681,7 +780,12 @@ export function PQInputTabs({
const isItemDirty = !!dirtyFieldsItem
const hasNewUploads = newUploads.length > 0
const canSave = isItemDirty || hasNewUploads
-
+
+ // 면제된 항목은 입력 비활성화
+ const isDisabled = shouldDisableInput
+
+ // 필터링 적용
+ if (!shouldShowItem(isSaved)) return null
return (
<Collapsible key={criteriaId} defaultOpen={isReadOnly || !isSaved} className="w-full">
@@ -698,7 +802,6 @@ export function PQInputTabs({
</CollapsibleTrigger>
<CardTitle className="text-md">
{code} - {checkPoint}
-
</CardTitle>
</div>
{description && (
@@ -731,14 +834,16 @@ export function PQInputTabs({
</span>
)}
+ {/* 개별 저장 버튼 주석처리
<Button
size="sm"
variant="outline"
- disabled={isSaving || !canSave}
+ disabled={isSaving || !canSave || isDisabled}
onClick={() => handleSaveItem(answerIndex)}
>
Save
</Button>
+ */}
</div>
</div>
</CardHeader>
@@ -798,7 +903,7 @@ export function PQInputTabs({
<Input
{...field}
type="email"
- disabled={shouldDisableInput}
+ disabled={isDisabled}
placeholder="example@company.com"
onChange={(e) => {
field.onChange(e)
@@ -811,14 +916,18 @@ export function PQInputTabs({
/>
);
case "PHONE":
+ case "FAX":
return (
<Input
{...field}
type="tel"
- disabled={shouldDisableInput}
+ disabled={isDisabled}
placeholder="02-1234-5678"
onChange={(e) => {
- field.onChange(e)
+ // 전화번호 형식만 허용 (숫자, -, +, 공백)
+ const value = e.target.value;
+ const filteredValue = value.replace(/[^\d\-\+\s]/g, '');
+ field.onChange(filteredValue);
form.setValue(
`answers.${answerIndex}.saved`,
false,
@@ -832,7 +941,7 @@ export function PQInputTabs({
<Input
{...field}
type="text"
- disabled={shouldDisableInput}
+ disabled={isDisabled}
placeholder="숫자를 입력하세요"
onChange={(e) => {
// 숫자만 허용
@@ -853,7 +962,7 @@ export function PQInputTabs({
<div className="space-y-2">
<Textarea
{...field}
- disabled={shouldDisableInput}
+ disabled={isDisabled}
className="min-h-24"
placeholder="텍스트 답변을 입력하세요"
onChange={(e) => {
@@ -874,7 +983,7 @@ export function PQInputTabs({
return (
<Textarea
{...field}
- disabled={shouldDisableInput}
+ disabled={isDisabled}
className="min-h-24"
placeholder="답변을 입력해주세요."
onChange={(e) => {
@@ -916,7 +1025,7 @@ export function PQInputTabs({
handleDropAccepted(criteriaId, files)
}
onDropRejected={handleDropRejected}
- disabled={shouldDisableInput}
+ disabled={isDisabled}
>
{() => (
<FormItem>
@@ -1050,8 +1159,8 @@ export function PQInputTabs({
</div>
)}
- {/* SHI 코멘트 필드 (읽기 전용) */}
- {item.shiComment && (
+ {/* SHI 코멘트 필드 (읽기 전용) - 승인 상태에서는 거부사유 숨김 */}
+ {item.shiComment && currentPQ?.status !== "APPROVED" && (
<FormField
control={form.control}
name={`answers.${answerIndex}.shiComment`}
@@ -1082,7 +1191,7 @@ export function PQInputTabs({
<FormControl>
<Textarea
{...field}
- disabled={shouldDisableInput}
+ disabled={isDisabled}
className="min-h-20 bg-muted/50"
placeholder="벤더 Reply를 입력하세요."
onChange={(e) => {
@@ -1180,10 +1289,17 @@ export function PQInputTabs({
onClick={() => setShowConfirmDialog(false)}
disabled={isSubmitting}
>
- Cancel
+ 취소
</Button>
<Button onClick={handleConfirmSubmission} disabled={isSubmitting}>
- {isSubmitting ? "Submitting..." : "Confirm Submit"}
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 제출 중...
+ </>
+ ) : (
+ "제출 확인"
+ )}
</Button>
</DialogFooter>
</DialogContent>
diff --git a/components/pq-input/pq-review-wrapper.tsx b/components/pq-input/pq-review-wrapper.tsx
index ca5f314f..44916dce 100644
--- a/components/pq-input/pq-review-wrapper.tsx
+++ b/components/pq-input/pq-review-wrapper.tsx
@@ -21,7 +21,7 @@ import {
DialogTitle
} from "@/components/ui/dialog"
import { useToast } from "@/hooks/use-toast"
-import { CheckCircle, AlertCircle, Paperclip } from "lucide-react"
+import { CheckCircle, AlertCircle, Paperclip, Square } from "lucide-react"
import { PQGroupData } from "@/lib/pq/service"
import { approvePQAction, rejectPQAction, updateSHICommentAction } from "@/lib/pq/service"
// import * as ExcelJS from 'exceljs';
@@ -48,14 +48,14 @@ interface PQReviewWrapperProps {
pqData: PQGroupData[]
vendorId: number
pqSubmission: PQSubmission
- canReview: boolean
+ vendorInfo?: any // 협력업체 정보 (선택사항)
}
export function PQReviewWrapper({
pqData,
vendorId,
pqSubmission,
- canReview
+ vendorInfo
}: PQReviewWrapperProps) {
const router = useRouter()
const { toast } = useToast()
@@ -66,6 +66,7 @@ export function PQReviewWrapper({
const [rejectReason, setRejectReason] = React.useState("")
const [shiComments, setShiComments] = React.useState<Record<number, string>>({})
const [isUpdatingComment, setIsUpdatingComment] = React.useState<number | null>(null)
+
// 코드 순서로 정렬하는 함수 (1-1-1, 1-1-2, 1-2-1 순서)
const sortByCode = (items: any[]) => {
@@ -88,6 +89,7 @@ export function PQReviewWrapper({
})
}
+
// 기존 SHI 코멘트를 로컬 상태에 초기화
React.useEffect(() => {
const initialComments: Record<number, string> = {}
@@ -369,25 +371,27 @@ export function PQReviewWrapper({
<Card key={item.criteriaId}>
<CardHeader>
<div className="flex justify-between items-start">
- <div>
- <CardTitle className="text-base">
- {item.code} - {item.checkPoint}
-
-
- </CardTitle>
- {item.description && (
- <CardDescription className="mt-1 whitespace-pre-wrap">
- {item.description}
- </CardDescription>
- )}
- {item.remarks && (
- <div className="mt-2 p-2 rounded-md">
- <p className="text-sm font-medium text-muted-foreground mb-1">Remark:</p>
- <p className="text-sm whitespace-pre-wrap">
- {item.remarks}
- </p>
+ <div className="flex-1">
+ <div className="flex items-start gap-3">
+ <div className="flex-1">
+ <CardTitle className="text-base">
+ {item.code} - {item.checkPoint}
+ </CardTitle>
+ {item.description && (
+ <CardDescription className="mt-1 whitespace-pre-wrap">
+ {item.description}
+ </CardDescription>
+ )}
+ {item.remarks && (
+ <div className="mt-2 p-2 rounded-md">
+ <p className="text-sm font-medium text-muted-foreground mb-1">Remark:</p>
+ <p className="text-sm whitespace-pre-wrap">
+ {item.remarks}
+ </p>
+ </div>
+ )}
</div>
- )}
+ </div>
</div>
{/* 항목 상태 표시 */}
{!!item.answer || item.attachments.length > 0 ? (
@@ -606,26 +610,27 @@ export function PQReviewWrapper({
))}
{/* 검토 버튼 */}
- {canReview && (
<div className="fixed bottom-4 right-4 bg-background p-4 rounded-lg shadow-md border">
<div className="flex gap-2">
- {/* <Button
- variant="outline"
+
+
+ {/* <Button
+ variant="outline"
onClick={handleExportToExcel}
disabled={isExporting}
>
<Download className="h-4 w-4 mr-2" />
{isExporting ? "내보내기 중..." : "Excel 내보내기"}
</Button> */}
- <Button
- variant="outline"
+ <Button
+ variant="outline"
onClick={() => setShowRejectDialog(true)}
disabled={isRejecting}
>
{isRejecting ? "거부 중..." : "거부"}
</Button>
- <Button
- variant="default"
+ <Button
+ variant="default"
onClick={() => setShowApproveDialog(true)}
disabled={isApproving}
>
@@ -633,7 +638,7 @@ export function PQReviewWrapper({
</Button>
</div>
</div>
- )}
+
{/* 승인 확인 다이얼로그 */}
<Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>