o2o-negosium-original/negodata/front/src/components/ImageDropzone.tsx
Mina Choi 7186446068 [feat] negodata/front: 협상대화 카드내용 렌더 + 견적상세 헤더·테이블 UI 정리
- 협상대화: 사용 카드 번호/멘트(Slate 서식본)/와일드 조건·메모 표시, SlateRenderer
  밑줄·헥스색·{변수} 치환 보정, 말풍선 우측정렬·발신자 아바타 제거
- 견적상세 헤더: DB 컬럼명 라벨 제거, 견적정보/(진행상태+세팅)/상품(이미지) 2열 재배치,
  협상카드 탭에 사용 카드 수 표시
- DataTable 행 패딩 축소, 협력사명 아바타 제거, 카드관리 본인 카드 안내문
- orval 재생성(QuotationCardData 신규 필드)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:21:58 +09:00

236 lines
8.1 KiB
TypeScript

import React, { useState, useRef, DragEvent, ChangeEvent } from 'react';
import { Upload, Image as ImageIcon, X, AlertCircle, Link2, Loader2 } from 'lucide-react';
interface ImageDropzoneProps {
value: string;
onChange: (url: string) => void;
onClear: () => void;
/**
* 파일을 받아 업로드하고 저장 URL 을 돌려준다(예: Azure Blob).
* 주어지면 드롭/선택한 파일을 업로드해 그 URL 을 value 로 쓴다.
* 없으면 과거처럼 base64 data URL 로 폴백한다(재사용 컴포넌트 호환).
*/
onUpload?: (file: File) => Promise<string>;
label?: string;
className?: string;
}
const MAX_SIZE = 4 * 1024 * 1024; // 백엔드 max_image_mb=4 와 일치
export default function ImageDropzone({
value,
onChange,
onClear,
onUpload,
label = '상품 대표 이미지 업로드',
className = ''
}: ImageDropzoneProps) {
const [isDragActive, setIsDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [urlDraft, setUrlDraft] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const processFile = async (file: File) => {
setError(null);
if (!file.type.startsWith('image/')) {
setError('이미지 파일(*.png, *.jpg, *.jpeg, *.gif, *.webp)만 업로드할 수 있습니다.');
return;
}
if (file.size > MAX_SIZE) {
setError('이미지 최댓값은 4MB입니다. 더 작고 최적화된 이미지를 권장합니다.');
return;
}
// 업로드 핸들러가 있으면 서버(blob)로 올리고 URL 을 받는다.
if (onUpload) {
setUploading(true);
try {
const url = await onUpload(file);
onChange(url);
} catch (err) {
setError(err instanceof Error ? err.message : '이미지 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
return;
}
// 폴백: base64 data URL.
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result && typeof e.target.result === 'string') {
onChange(e.target.result);
} else {
setError('이미지 해독 과정에 이상이 발생했습니다.');
}
};
reader.onerror = () => setError('파일을 수집하지 못했습니다.');
reader.readAsDataURL(file);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!uploading) setIsDragActive(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
if (uploading) return;
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
void processFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
void processFile(e.target.files[0]);
}
e.target.value = ''; // 같은 파일 재선택 허용
};
const handleButtonClick = () => {
if (!uploading) fileInputRef.current?.click();
};
const applyUrl = () => {
const url = urlDraft.trim();
if (!url) return;
setError(null);
onChange(url);
setUrlDraft('');
};
return (
<div className={`space-y-1.5 font-mono text-xs ${className}`}>
<label className="font-semibold text-foreground flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
{label}
</label>
{value ? (
// 미리보기 (blob URL / 외부 URL / base64 모두 동일하게 표시)
<div className="relative group border border-border rounded-lg overflow-hidden bg-background h-44 flex items-center justify-center">
<img
src={value}
alt="Uploaded preview"
referrerPolicy="no-referrer"
className="h-full w-full object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2">
<button
type="button"
onClick={handleButtonClick}
className="px-3 py-1.5 bg-background text-foreground hover:bg-muted font-bold rounded text-[11px] cursor-pointer"
>
다른 이미지로 교체
</button>
<button
type="button"
onClick={onClear}
className="px-3 py-1.5 bg-destructive text-destructive-foreground hover:opacity-90 font-bold rounded text-[11px] cursor-pointer flex items-center gap-1"
>
<X size={12} />
이미지 제거
</button>
</div>
</div>
) : (
// 드롭존
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleButtonClick}
className={`relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center min-h-44 transition-all ${
uploading ? 'cursor-wait' : 'cursor-pointer'
} ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-foreground/30 bg-muted/20'
}`}
>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
disabled={uploading}
/>
{uploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 size={22} className="animate-spin text-primary" />
<p className="font-semibold text-foreground text-xs">업로드 중…</p>
</div>
) : (
<>
<div className="p-2.5 bg-background border border-border rounded-full shadow-xs mb-3 text-muted-foreground">
{isDragActive ? (
<Upload size={22} className="animate-bounce text-primary" />
) : (
<ImageIcon size={22} />
)}
</div>
<div className="space-y-1">
<p className="font-semibold text-foreground text-xs">
{isDragActive ? '여기에 드롭하여 업로드' : '이미지 드래그 앤 드롭 또는 파일 탐색'}
</p>
<p className="text-[10px] text-muted-foreground max-w-[280px] leading-relaxed">
PNG, JPG, JPEG, GIF, WEBP 파일 지원 (최대 4MB)
</p>
</div>
</>
)}
</div>
)}
{/* 이미지 URL 직접 입력 */}
<div className="flex items-center gap-1.5 pt-1">
<div className="relative flex-1">
<Link2 size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="url"
value={urlDraft}
onChange={(e) => setUrlDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyUrl();
}
}}
placeholder="또는 이미지 URL 붙여넣기 (https://…)"
className="w-full pl-6 pr-2 py-1.5 text-[11px] rounded border border-border bg-background text-foreground placeholder:text-muted-foreground/70 focus:outline-none focus:border-primary"
/>
</div>
<button
type="button"
onClick={applyUrl}
disabled={!urlDraft.trim()}
className="px-2.5 py-1.5 text-[11px] font-bold rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0"
>
적용
</button>
</div>
{error && (
<div className="flex items-center gap-1.5 text-[10px] text-destructive mt-1">
<AlertCircle size={12} />
<span>{error}</span>
</div>
)}
</div>
);
}