summaryrefslogtreecommitdiff
path: root/lib/bidding/list/bidding-detail-dialogs.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/list/bidding-detail-dialogs.tsx')
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx554
1 files changed, 149 insertions, 405 deletions
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx
index 4fbca616..065000ce 100644
--- a/lib/bidding/list/bidding-detail-dialogs.tsx
+++ b/lib/bidding/list/bidding-detail-dialogs.tsx
@@ -9,58 +9,49 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
-import {
- CalendarIcon,
- ClockIcon,
+import {
+ CalendarIcon,
+ ClockIcon,
MapPinIcon,
FileTextIcon,
- DownloadIcon,
- EyeIcon,
- PackageIcon,
- HashIcon,
- DollarSignIcon,
- WeightIcon,
- ExternalLinkIcon
+ ExternalLinkIcon,
+ FileXIcon,
+ UploadIcon
} from "lucide-react"
-import { toast } from "sonner"
import { BiddingListItem } from "@/db/schema"
import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
-import { getPRDetailsAction, getSpecificationMeetingDetailsAction } from "../service"
+import { getSpecificationMeetingDetailsAction } from "../service"
+import { bidClosureAction } from "../actions"
import { formatDate } from "@/lib/utils"
+import { toast } from "sonner"
// 타입 정의
interface SpecificationMeetingDetails {
id: number;
biddingId: number;
meetingDate: string;
- meetingTime: string | null;
- location: string | null;
- address: string | null;
- contactPerson: string | null;
- contactPhone: string | null;
- contactEmail: string | null;
- agenda: string | null;
- materials: string | null;
- notes: string | null;
+ meetingTime?: string | null;
+ location?: string | null;
+ address?: string | null;
+ contactPerson?: string | null;
+ contactPhone?: string | null;
+ contactEmail?: string | null;
+ agenda?: string | null;
+ materials?: string | null;
+ notes?: string | null;
isRequired: boolean;
createdAt: string;
updatedAt: string;
@@ -70,60 +61,13 @@ interface SpecificationMeetingDetails {
originalFileName: string;
fileSize: number;
filePath: string;
- title: string | null;
+ title?: string | null;
uploadedAt: string;
- uploadedBy: string | null;
- }>;
-}
-
-interface PRDetails {
- documents: Array<{
- id: number;
- documentName: string;
- fileName: string;
- originalFileName: string;
- fileSize: number;
- filePath: string;
- registeredAt: string;
- registeredBy: string | null;
- version: string | null;
- description: string | null;
- createdAt: string;
- updatedAt: string;
- }>;
- items: Array<{
- id: number;
- itemNumber: string;
- itemInfo: string | null;
- quantity: number | null;
- quantityUnit: string | null;
- requestedDeliveryDate: string | null;
- prNumber: string | null;
- annualUnitPrice: number | null;
- currency: string | null;
- totalWeight: number | null;
- weightUnit: string | null;
- materialDescription: string | null;
- hasSpecDocument: boolean;
- createdAt: string;
- updatedAt: string;
- specDocuments: Array<{
- id: number;
- fileName: string;
- originalFileName: string;
- fileSize: number;
- filePath: string;
- uploadedAt: string;
- title: string | null;
- }>;
+ uploadedBy?: string | null;
}>;
}
-interface ActionResult<T> {
- success: boolean;
- data?: T;
- error?: string;
-}
+// PR 관련 타입과 컴포넌트는 bidding-pr-documents-dialog.tsx로 이동됨
// 파일 다운로드 훅
const useFileDownload = () => {
@@ -212,52 +156,6 @@ const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({
);
};
-// 파일 다운로드 버튼 컴포넌트 (간소화된 버전)
-interface FileDownloadButtonProps {
- filePath: string;
- fileName: string;
- fileSize?: number;
- title?: string | null;
- variant?: "download" | "preview";
- size?: "sm" | "default" | "lg";
-}
-
-const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({
- filePath,
- fileName,
- fileSize,
- title,
- variant = "download",
- size = "sm"
-}) => {
- const { handleDownload, downloadingFiles } = useFileDownload();
- const fileInfo = getFileInfo(fileName);
- const fileKey = `${filePath}_${fileName}`;
- const isDownloading = downloadingFiles.has(fileKey);
-
- const Icon = variant === "preview" && fileInfo.canPreview ? EyeIcon : DownloadIcon;
-
- return (
- <Button
- onClick={() => handleDownload(filePath, fileName, { action: variant })}
- disabled={isDownloading}
- size={size}
- variant="outline"
- className="gap-2"
- >
- <Icon className="h-4 w-4" />
- {isDownloading ? "처리중..." : (
- variant === "preview" && fileInfo.canPreview ? "미리보기" : "다운로드"
- )}
- {fileSize && size !== "sm" && (
- <span className="text-xs text-muted-foreground">
- ({formatFileSize(fileSize)})
- </span>
- )}
- </Button>
- );
-};
-
// 사양설명회 다이얼로그
interface SpecificationMeetingDialogProps {
open: boolean;
@@ -458,285 +356,131 @@ export function SpecificationMeetingDialog({
);
}
-// PR 문서 다이얼로그
-interface PrDocumentsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- bidding: BiddingListItem | null;
-}
-
-export function PrDocumentsDialog({
- open,
- onOpenChange,
- bidding
-}: PrDocumentsDialogProps) {
- const [data, setData] = useState<PRDetails | null>(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<string | null>(null);
-
- useEffect(() => {
- if (open && bidding) {
- fetchPRData();
- }
- }, [open, bidding]);
-
- const fetchPRData = async () => {
- if (!bidding) return;
-
- setLoading(true);
- setError(null);
-
- try {
- const result = await getPRDetailsAction(bidding.id);
-
- if (result.success && result.data) {
- setData(result.data);
- } else {
- setError(result.error || "PR 문서 정보를 불러올 수 없습니다.");
- }
- } catch (err) {
- setError("데이터 로딩 중 오류가 발생했습니다.");
- console.error("Failed to fetch PR data:", err);
- } finally {
- setLoading(false);
- }
- };
-
- const formatCurrency = (amount: number | null, currency: string | null) => {
- if (amount === null) return "-";
- return `${amount.toLocaleString()} ${currency || ""}`;
- };
-
- const formatWeight = (weight: number | null, unit: string | null) => {
- if (weight === null) return "-";
- return `${weight.toLocaleString()} ${unit || ""}`;
- };
+// PR 문서 다이얼로그는 bidding-pr-documents-dialog.tsx로 이동됨
+// import { PrDocumentsDialog } from './bidding-pr-documents-dialog'로 사용하세요
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-7xl max-h-[90vh]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <PackageIcon className="h-5 w-5" />
- PR 문서
- </DialogTitle>
- <DialogDescription>
- {bidding?.title}의 PR 문서 및 아이템 정보입니다.
- </DialogDescription>
- </DialogHeader>
-
- <ScrollArea className="max-h-[75vh]">
- {loading ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
- <p className="text-sm text-muted-foreground">로딩 중...</p>
- </div>
- </div>
- ) : error ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center">
- <p className="text-sm text-destructive mb-2">{error}</p>
- <Button onClick={fetchPRData} size="sm">
- 다시 시도
- </Button>
- </div>
- </div>
- ) : data ? (
- <div className="space-y-6">
- {/* PR 문서 목록 */}
- {data.documents.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <FileTextIcon className="h-5 w-5" />
- PR 문서 ({data.documents.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>문서명</TableHead>
- <TableHead>파일명</TableHead>
- <TableHead>버전</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>등록일</TableHead>
- <TableHead>등록자</TableHead>
- <TableHead className="text-right">다운로드</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {data.documents.map((doc) => (
- <TableRow key={doc.id}>
- <TableCell className="font-medium">
- {doc.documentName}
- {doc.description && (
- <div className="text-xs text-muted-foreground mt-1">
- {doc.description}
- </div>
- )}
- </TableCell>
- <TableCell>
- <FileDownloadLink
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- />
- </TableCell>
- <TableCell>
- {doc.version ? (
- <Badge variant="outline">{doc.version}</Badge>
- ) : "-"}
- </TableCell>
- <TableCell>{formatFileSize(doc.fileSize)}</TableCell>
- <TableCell>
- {new Date(doc.registeredAt).toLocaleDateString('ko-KR')}
- </TableCell>
- <TableCell>{doc.registeredBy || "-"}</TableCell>
- <TableCell className="text-right">
- <FileDownloadButton
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- variant="download"
- size="sm"
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- )}
+// 폐찰하기 다이얼로그
+interface BidClosureDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+ userId: string;
+}
- {/* PR 아이템 테이블 */}
- {data.items.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <HashIcon className="h-5 w-5" />
- PR 아이템 ({data.items.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[100px]">아이템 번호</TableHead>
- <TableHead className="w-[150px]">PR 번호</TableHead>
- <TableHead>아이템 정보</TableHead>
- <TableHead className="w-[120px]">수량</TableHead>
- <TableHead className="w-[120px]">단가</TableHead>
- <TableHead className="w-[120px]">중량</TableHead>
- <TableHead className="w-[120px]">요청 납기</TableHead>
- <TableHead className="w-[200px]">스펙 문서</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {data.items.map((item) => (
- <TableRow key={item.id}>
- <TableCell className="font-medium">
- {item.itemNumber}
- </TableCell>
- <TableCell>
- {item.prNumber || "-"}
- </TableCell>
- <TableCell>
- <div>
- {item.itemInfo && (
- <div className="font-medium text-sm mb-1">{item.itemInfo}</div>
- )}
- {item.materialDescription && (
- <div className="text-xs text-muted-foreground">
- {item.materialDescription}
- </div>
- )}
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <PackageIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"}
- </span>
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <DollarSignIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {formatCurrency(item.annualUnitPrice, item.currency)}
- </span>
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <WeightIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {formatWeight(item.totalWeight, item.weightUnit)}
- </span>
- </div>
- </TableCell>
- <TableCell>
- {item.requestedDeliveryDate ? (
- <div className="flex items-center gap-1">
- <CalendarIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')}
- </span>
- </div>
- ) : "-"}
- </TableCell>
- <TableCell>
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs">
- {item.hasSpecDocument ? "있음" : "없음"}
- </Badge>
- {item.specDocuments.length > 0 && (
- <span className="text-xs text-muted-foreground">
- ({item.specDocuments.length}개)
- </span>
- )}
- </div>
- {item.specDocuments.length > 0 && (
- <div className="space-y-1">
- {item.specDocuments.map((doc, index) => (
- <div key={doc.id} className="text-xs">
- <FileDownloadLink
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- title={doc.title}
- className="text-xs"
- />
- </div>
- ))}
- </div>
- )}
- </div>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- )}
+export function BidClosureDialog({
+ open,
+ onOpenChange,
+ bidding,
+ userId
+}: BidClosureDialogProps) {
+ const [description, setDescription] = useState('')
+ const [files, setFiles] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!bidding || !description.trim()) {
+ toast.error('폐찰 사유를 입력해주세요.')
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const result = await bidClosureAction(bidding.id, {
+ description: description.trim(),
+ files
+ }, userId)
+
+ if (result.success) {
+ toast.success(result.message)
+ onOpenChange(false)
+ // 페이지 새로고침 또는 상태 업데이트
+ window.location.reload()
+ } else {
+ toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.')
+ }
+ } catch (error) {
+ toast.error('폐찰 처리 중 오류가 발생했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files) {
+ setFiles(Array.from(e.target.files))
+ }
+ }
+
+ if (!bidding) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileXIcon className="h-5 w-5 text-destructive" />
+ 폐찰하기
+ </DialogTitle>
+ <DialogDescription>
+ {bidding.title} ({bidding.biddingNumber})를 폐찰합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label>
+ <Textarea
+ id="description"
+ placeholder="폐찰 사유를 입력해주세요..."
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="min-h-[100px]"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="files">첨부파일</Label>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={handleFileChange}
+ className="cursor-pointer"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png"
+ />
+ {files.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: {files.map(f => f.name).join(', ')}
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant="destructive"
+ disabled={isSubmitting || !description.trim()}
+ >
+ {isSubmitting ? '처리 중...' : '폐찰하기'}
+ </Button>
+ </div>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+}
- {/* 데이터가 없는 경우 */}
- {data.documents.length === 0 && data.items.length === 0 && (
- <div className="text-center py-8">
- <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
- <p className="text-muted-foreground">PR 문서가 없습니다.</p>
- </div>
- )}
- </div>
- ) : null}
- </ScrollArea>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
+// Re-export for backward compatibility
+export { PrDocumentsDialog } from './bidding-pr-documents-dialog' \ No newline at end of file