Reference photo upload (magic-byte check, 10MB, server-named files) + reroom render job on the existing RenderJob queue/callback/WS pipeline. Worker gains R1 reroom mode: 4-part prompt with fixed preserve-lock (architecture + camera perspective) and style dictionary (6 styles, JSON single source); NL instruction injects into replace-part only. Booth Design Studio gets a photo-mockup section with client 1024px downscale, style cards and Before/After slider (watermark enforced). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
795 lines
29 KiB
TypeScript
795 lines
29 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import { ApiRequestError } from '../../api/client';
|
||
import { designApi, renderApi } from '../../api/endpoints';
|
||
import { subscribeRenderJob } from '../../api/websocket';
|
||
import { Button } from '../../components/ui/Button';
|
||
import { AiLabel } from '../../components/ui/Badge';
|
||
import { AiImage } from '../../components/ui/AiImage';
|
||
import { CompareSlider } from '../../components/ui/CompareSlider';
|
||
import type { ComplianceReport, DesignSpec, RenderJobDto, RenderJobRequest } from '../../api/types';
|
||
import { AFTER_PLACEHOLDER, AFTER_SAMPLE, BEFORE_EMPTY_HALL, MOCK_SHOT_IMG } from './placeholders';
|
||
import {
|
||
REROOM_STYLES,
|
||
downscaleImage,
|
||
reroomApi,
|
||
MAX_UPLOAD_BYTES,
|
||
} from './reroomApi';
|
||
import './studio.css';
|
||
|
||
/*
|
||
* SCR-06 부스 설계 스튜디오 (M3 스펙 + M5 렌더). Stitch booth_design_studio 이식.
|
||
* - 좌: 스펙 아코디언 폼 → precheck(완성 API)로 사전검증. 중앙: 4샷 그리드(S1~S4) + Before/After(S5).
|
||
* - "예상 사진 생성" → render POST(완성 큐잉) → WebSocket /topic/render/{jobId} 로 타일 교체.
|
||
*/
|
||
|
||
const SHOTS = [
|
||
{ preset: 'S1', label: 'S1 부스 정면' },
|
||
{ preset: 'S2', label: 'S2 야간 점등' },
|
||
{ preset: 'S3', label: 'S3 통로 뷰' },
|
||
{ preset: 'S4', label: 'S4 부스 내부' },
|
||
] as const;
|
||
|
||
const DEFAULT_SPEC: DesignSpec = {
|
||
boothType: 'independent',
|
||
industry: '로봇/제조',
|
||
budget: 30000000,
|
||
zones: [
|
||
{ type: 'demo', ratioPercent: 40 },
|
||
{ type: 'consult', ratioPercent: 30 },
|
||
{ type: 'reception', ratioPercent: 20 },
|
||
{ type: 'storage', ratioPercent: 10 },
|
||
],
|
||
wallHeightM: 4.2,
|
||
signageText: '한빛로보틱스',
|
||
rigging: { use: true, heightM: 7.0 },
|
||
mezzanineAreaRatio: 0.3,
|
||
materials: [{ part: 'wall', finish: 'matte_white', fireRetardant: true }],
|
||
lightingMode: 'night',
|
||
usesDesignatedLightingOnly: true,
|
||
};
|
||
|
||
type Section = 'basic' | 'zones' | 'structure' | 'material' | 'lighting';
|
||
|
||
export function BoothDesignStudioPage() {
|
||
const { eventId = '', boothId = '' } = useParams();
|
||
const navigate = useNavigate();
|
||
|
||
const [spec, setSpec] = useState<DesignSpec>(DEFAULT_SPEC);
|
||
const [open, setOpen] = useState<Record<Section, boolean>>({
|
||
basic: true,
|
||
zones: false,
|
||
structure: true,
|
||
material: false,
|
||
lighting: false,
|
||
});
|
||
const [report, setReport] = useState<ComplianceReport | null>(null);
|
||
const [prechecking, setPrechecking] = useState(false);
|
||
const [jobs, setJobs] = useState<Record<string, RenderJobDto>>({}); // preset → job
|
||
const [generating, setGenerating] = useState(false);
|
||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||
const [confirmAgree, setConfirmAgree] = useState(false);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
|
||
// ── 사진 시안(ReRoom, image-to-image) — 소유자 지시 13.2 ──
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [refPreview, setRefPreview] = useState<string | null>(null); // 로컬 objectURL(Before)
|
||
const [refId, setRefId] = useState<string | null>(null); // 서버 참조 이미지 id
|
||
const [refUploading, setRefUploading] = useState(false);
|
||
const [reroomStyle, setReroomStyle] = useState<string>('modern');
|
||
const [reroomInstruction, setReroomInstruction] = useState('');
|
||
const [reroomJob, setReroomJob] = useState<RenderJobDto | null>(null);
|
||
const [reroomBusy, setReroomBusy] = useState(false);
|
||
const [reroomError, setReroomError] = useState<string | null>(null);
|
||
const [dragOver, setDragOver] = useState(false);
|
||
|
||
// 초기 설계안 로드(501이면 degraded 기본 스펙 유지).
|
||
useEffect(() => {
|
||
let alive = true;
|
||
designApi
|
||
.get(eventId, boothId)
|
||
.then((d) => alive && setSpec(d.spec))
|
||
.catch(() => {
|
||
/* 501/미존재 → 기본 스펙 시연 */
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [eventId, boothId]);
|
||
|
||
// 발행된 렌더잡 WebSocket 구독.
|
||
useEffect(() => {
|
||
const ids = Object.values(jobs)
|
||
.map((j) => j.jobId)
|
||
.filter(Boolean);
|
||
if (ids.length === 0) return;
|
||
const unsubs = ids.map((id) =>
|
||
subscribeRenderJob(id, (job) => {
|
||
setJobs((prev) => ({ ...prev, [job.shotPreset]: job }));
|
||
}),
|
||
);
|
||
return () => unsubs.forEach((u) => u());
|
||
}, [jobs]);
|
||
|
||
// 사진 시안 잡: WebSocket 구독 + 폴백 폴링(완료/실패까지).
|
||
useEffect(() => {
|
||
const jobId = reroomJob?.jobId;
|
||
if (!jobId || reroomJob?.status === 'DONE' || reroomJob?.status === 'FAILED') return;
|
||
const unsub = subscribeRenderJob(jobId, (job) => setReroomJob(job));
|
||
const timer = window.setInterval(async () => {
|
||
try {
|
||
setReroomJob(await reroomApi.status(eventId, jobId));
|
||
} catch {
|
||
/* 폴백 폴링 실패는 무시(WebSocket 우선) */
|
||
}
|
||
}, 4000);
|
||
return () => {
|
||
unsub();
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [reroomJob?.jobId, reroomJob?.status, eventId]);
|
||
|
||
// 언마운트 시 미리보기 objectURL 정리.
|
||
useEffect(() => {
|
||
return () => {
|
||
if (refPreview) URL.revokeObjectURL(refPreview);
|
||
};
|
||
}, [refPreview]);
|
||
|
||
// 참조 이미지 선택 → 1024px 다운스케일 → 즉시 업로드(referenceId 확보).
|
||
const handleRefFile = useCallback(
|
||
async (file: File | undefined | null) => {
|
||
if (!file) return;
|
||
setReroomError(null);
|
||
if (!file.type.startsWith('image/')) {
|
||
setReroomError('이미지 파일만 업로드할 수 있습니다.');
|
||
return;
|
||
}
|
||
if (file.size > MAX_UPLOAD_BYTES) {
|
||
setReroomError('참조 이미지는 10MB 이하만 업로드할 수 있습니다.');
|
||
return;
|
||
}
|
||
setRefUploading(true);
|
||
setReroomJob(null);
|
||
setRefId(null);
|
||
try {
|
||
const blob = await downscaleImage(file);
|
||
setRefPreview((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return URL.createObjectURL(blob);
|
||
});
|
||
const res = await reroomApi.uploadReference(eventId, boothId, blob);
|
||
setRefId(res.referenceId);
|
||
} catch (err) {
|
||
setReroomError(
|
||
err instanceof ApiRequestError ? err.message : '참조 이미지 업로드에 실패했습니다.',
|
||
);
|
||
} finally {
|
||
setRefUploading(false);
|
||
}
|
||
},
|
||
[eventId, boothId],
|
||
);
|
||
|
||
const generateReroom = useCallback(async () => {
|
||
if (!refId) {
|
||
setReroomError('먼저 빈 부스/공간 사진을 업로드하세요.');
|
||
return;
|
||
}
|
||
setReroomBusy(true);
|
||
setReroomError(null);
|
||
try {
|
||
const job = await reroomApi.render(eventId, boothId, {
|
||
referenceId: refId,
|
||
style: reroomStyle,
|
||
instruction: reroomInstruction.trim() || undefined,
|
||
});
|
||
setReroomJob(job);
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError && err.code === 'RENDER_QUOTA_EXCEEDED') {
|
||
setReroomError('행사 이미지 생성 쿼터가 소진되었습니다.');
|
||
} else {
|
||
setReroomError(err instanceof ApiRequestError ? err.message : '시안 생성 중 오류가 발생했습니다.');
|
||
}
|
||
} finally {
|
||
setReroomBusy(false);
|
||
}
|
||
}, [eventId, boothId, refId, reroomStyle, reroomInstruction]);
|
||
|
||
const runPrecheck = useCallback(async () => {
|
||
setPrechecking(true);
|
||
setNotice(null);
|
||
try {
|
||
const r = await designApi.precheck(eventId, boothId, spec);
|
||
setReport(r);
|
||
} catch (err) {
|
||
setNotice(
|
||
err instanceof ApiRequestError ? err.message : '사전 검증 중 오류가 발생했습니다.',
|
||
);
|
||
} finally {
|
||
setPrechecking(false);
|
||
}
|
||
}, [eventId, boothId, spec]);
|
||
|
||
const buildScene = useCallback(
|
||
(): RenderJobRequest['scene'] => ({
|
||
hall: { id: 'H7', dims_m: [126, 90], ceiling_m: 12 },
|
||
booth: { id: boothId || 'A-102', size_m: [6, 3], type: spec.boothType },
|
||
design: { signage: { text: spec.signageText } },
|
||
lighting: { mode: spec.lightingMode },
|
||
}),
|
||
[boothId, spec.boothType, spec.signageText, spec.lightingMode],
|
||
);
|
||
|
||
const generateShots = useCallback(async () => {
|
||
setGenerating(true);
|
||
setNotice(null);
|
||
const scene = buildScene();
|
||
// S1·S2를 우선 발행(S3·S4는 온디맨드 타일에서 개별 생성).
|
||
for (const preset of ['S1', 'S2'] as const) {
|
||
try {
|
||
const job = await renderApi.create(eventId, boothId, { shotPreset: preset, scene });
|
||
setJobs((prev) => ({ ...prev, [preset]: job }));
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError && err.code === 'RENDER_QUOTA_EXCEEDED') {
|
||
setNotice('행사 이미지 생성 쿼터가 소진되었습니다.');
|
||
break;
|
||
}
|
||
if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') {
|
||
setNotice('렌더 이력 저장이 준비 중입니다(큐잉만 동작).');
|
||
} else {
|
||
setNotice(err instanceof ApiRequestError ? err.message : '예상 사진 생성 중 오류.');
|
||
}
|
||
}
|
||
}
|
||
setGenerating(false);
|
||
// 병행: precheck 갱신
|
||
void runPrecheck();
|
||
}, [eventId, boothId, buildScene, runPrecheck]);
|
||
|
||
const generateSingle = useCallback(
|
||
async (preset: string) => {
|
||
setNotice(null);
|
||
try {
|
||
const job = await renderApi.create(eventId, boothId, {
|
||
shotPreset: preset,
|
||
scene: buildScene(),
|
||
});
|
||
setJobs((prev) => ({ ...prev, [preset]: job }));
|
||
} catch (err) {
|
||
setNotice(err instanceof ApiRequestError ? err.message : '생성 중 오류가 발생했습니다.');
|
||
}
|
||
},
|
||
[eventId, boothId, buildScene],
|
||
);
|
||
|
||
const s1 = jobs.S1;
|
||
const afterReal = s1?.status === 'DONE' && s1.imageUrl ? s1.imageUrl : null;
|
||
const afterSrc = afterReal ?? AFTER_SAMPLE;
|
||
|
||
const passCount = report?.passCount ?? 0;
|
||
const warnCount = report?.warnCount ?? 0;
|
||
const blockCount = report?.blockCount ?? 0;
|
||
const submittable = report?.submittable ?? false;
|
||
|
||
return (
|
||
<div className="kx-studio">
|
||
{/* 상단 컨텍스트 바 */}
|
||
<header className="kx-studio__head">
|
||
<div className="kx-studio__ctx">
|
||
<button
|
||
type="button"
|
||
className="kx-studio__back"
|
||
aria-label="뒤로"
|
||
onClick={() => navigate(-1)}
|
||
>
|
||
←
|
||
</button>
|
||
<div>
|
||
<h2>부스 {boothId || 'A-102'} · 독립부스 6×3m</h2>
|
||
<span className="kx-studio__ctx-sub">높이 제한 5m</span>
|
||
</div>
|
||
<span className="kx-studio__ctx-div" aria-hidden="true" />
|
||
<select
|
||
className="kx-studio__ver-select"
|
||
aria-label="설계 버전"
|
||
defaultValue="v2"
|
||
>
|
||
<option value="v2">설계 v2</option>
|
||
<option value="v1">설계 v1</option>
|
||
</select>
|
||
</div>
|
||
<Button variant="ai" leadingIcon="✦" onClick={generateShots} disabled={generating}>
|
||
{generating ? '생성 요청 중…' : '예상 사진 생성'}
|
||
</Button>
|
||
</header>
|
||
|
||
<div className="kx-studio__body">
|
||
{/* 좌: 스펙 아코디언 */}
|
||
<aside className="kx-studio__spec" aria-label="설계 스펙">
|
||
<Accordion
|
||
title="기본 정보"
|
||
open={open.basic}
|
||
onToggle={() => setOpen((o) => ({ ...o, basic: !o.basic }))}
|
||
>
|
||
<Field label="업종">
|
||
<input
|
||
value={spec.industry}
|
||
onChange={(e) => setSpec((s) => ({ ...s, industry: e.target.value }))}
|
||
/>
|
||
</Field>
|
||
<Field label="예산 (₩)">
|
||
<input
|
||
type="number"
|
||
className="tnum"
|
||
value={spec.budget}
|
||
onChange={(e) => setSpec((s) => ({ ...s, budget: Number(e.target.value) }))}
|
||
/>
|
||
</Field>
|
||
</Accordion>
|
||
|
||
<Accordion
|
||
title="공간 구성"
|
||
open={open.zones}
|
||
onToggle={() => setOpen((o) => ({ ...o, zones: !o.zones }))}
|
||
>
|
||
{spec.zones.map((z, i) => (
|
||
<Field key={z.type} label={ZONE_LABEL[z.type] ?? z.type}>
|
||
<div className="kx-studio__ratio">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
value={z.ratioPercent}
|
||
aria-label={`${ZONE_LABEL[z.type] ?? z.type} 비율`}
|
||
onChange={(e) =>
|
||
setSpec((s) => ({
|
||
...s,
|
||
zones: s.zones.map((x, j) =>
|
||
j === i ? { ...x, ratioPercent: Number(e.target.value) } : x,
|
||
),
|
||
}))
|
||
}
|
||
/>
|
||
<span className="tnum">{z.ratioPercent}%</span>
|
||
</div>
|
||
</Field>
|
||
))}
|
||
</Accordion>
|
||
|
||
<Accordion
|
||
title="구조"
|
||
open={open.structure}
|
||
onToggle={() => setOpen((o) => ({ ...o, structure: !o.structure }))}
|
||
>
|
||
<Field label={`벽체 높이 (${spec.wallHeightM}m · 최대 5m)`}>
|
||
<input
|
||
type="range"
|
||
min={2}
|
||
max={6}
|
||
step={0.1}
|
||
value={spec.wallHeightM}
|
||
aria-label="벽체 높이"
|
||
onChange={(e) => setSpec((s) => ({ ...s, wallHeightM: Number(e.target.value) }))}
|
||
/>
|
||
{spec.wallHeightM > 5 && (
|
||
<p className="kx-studio__inline-warn">높이 5m 초과 — 규정 차단 대상</p>
|
||
)}
|
||
</Field>
|
||
<Field label="간판 문구">
|
||
<input
|
||
value={spec.signageText}
|
||
onChange={(e) => setSpec((s) => ({ ...s, signageText: e.target.value }))}
|
||
/>
|
||
</Field>
|
||
<label className="kx-studio__toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={spec.rigging.use}
|
||
onChange={(e) =>
|
||
setSpec((s) => ({ ...s, rigging: { ...s.rigging, use: e.target.checked } }))
|
||
}
|
||
/>
|
||
트러스/리깅 사용
|
||
</label>
|
||
{spec.rigging.use && (
|
||
<p className="kx-studio__inline-warn">
|
||
리깅 6.5~8.5m: 구조계산서 D-7 제출 필요
|
||
</p>
|
||
)}
|
||
</Accordion>
|
||
|
||
<Accordion
|
||
title="자재"
|
||
open={open.material}
|
||
onToggle={() => setOpen((o) => ({ ...o, material: !o.material }))}
|
||
>
|
||
<Field label="벽체 마감">
|
||
<select
|
||
value={spec.materials[0]?.finish}
|
||
onChange={(e) =>
|
||
setSpec((s) => ({
|
||
...s,
|
||
materials: [{ ...s.materials[0], finish: e.target.value }],
|
||
}))
|
||
}
|
||
>
|
||
<option value="matte_white">무광 화이트</option>
|
||
<option value="wood">우드</option>
|
||
<option value="metal">메탈</option>
|
||
</select>
|
||
</Field>
|
||
<label className="kx-studio__toggle kx-studio__toggle--req">
|
||
<input
|
||
type="checkbox"
|
||
checked={spec.materials[0]?.fireRetardant ?? false}
|
||
onChange={(e) =>
|
||
setSpec((s) => ({
|
||
...s,
|
||
materials: [{ ...s.materials[0], fireRetardant: e.target.checked }],
|
||
}))
|
||
}
|
||
/>
|
||
방염 자재 사용 확인 (필수)
|
||
</label>
|
||
</Accordion>
|
||
|
||
<Accordion
|
||
title="조명"
|
||
open={open.lighting}
|
||
onToggle={() => setOpen((o) => ({ ...o, lighting: !o.lighting }))}
|
||
>
|
||
<div className="kx-studio__seg" role="radiogroup" aria-label="조명 모드">
|
||
{(['day', 'night'] as const).map((m) => (
|
||
<button
|
||
key={m}
|
||
role="radio"
|
||
aria-checked={spec.lightingMode === m}
|
||
className={spec.lightingMode === m ? 'is-active' : ''}
|
||
onClick={() => setSpec((s) => ({ ...s, lightingMode: m }))}
|
||
>
|
||
{m === 'day' ? '주간' : '야간'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Accordion>
|
||
</aside>
|
||
|
||
{/* 중앙: 4샷 그리드 + Before/After */}
|
||
<section className="kx-studio__hero">
|
||
<div className="kx-studio__grid">
|
||
{SHOTS.map((shot) => {
|
||
const job = jobs[shot.preset];
|
||
return (
|
||
<div className="kx-studio__tile" key={shot.preset}>
|
||
{job ? (
|
||
<AiImage
|
||
imageUrl={job.imageUrl}
|
||
status={job.status}
|
||
watermarkText={job.watermarkText}
|
||
notice={job.notice}
|
||
shotLabel={shot.label}
|
||
versionTag="설계 v2"
|
||
onRetry={() => generateSingle(shot.preset)}
|
||
alt={`${shot.label} 예상 이미지`}
|
||
/>
|
||
) : MOCK_SHOT_IMG[shot.preset] ? (
|
||
<AiImage
|
||
imageUrl={MOCK_SHOT_IMG[shot.preset]}
|
||
status="DONE"
|
||
watermarkText="AI 생성 예상 이미지"
|
||
notice="AI 예시 이미지 — 실제 부스 렌더는 ‘이 뷰 생성’으로 요청하세요. 시공 기준은 도면입니다."
|
||
shotLabel={shot.label}
|
||
versionTag="설계 v2"
|
||
sample
|
||
onGenerate={() => generateSingle(shot.preset)}
|
||
alt={`${shot.label} 예시 이미지`}
|
||
/>
|
||
) : (
|
||
<div className="kx-studio__tile-empty">
|
||
<div className="kx-studio__tile-icon" aria-hidden="true">✦</div>
|
||
<span className="kx-studio__tile-label">{shot.label}</span>
|
||
<button onClick={() => generateSingle(shot.preset)}>✦ 클릭해 생성</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Before/After 슬라이더 (S5) */}
|
||
<div className="kx-studio__compare">
|
||
<div className="kx-studio__compare-head">
|
||
<AiLabel>S5 Before / After</AiLabel>
|
||
{!afterReal && <span className="kx-studio__compare-note">예시 · S1 생성 시 실 이미지로 교체</span>}
|
||
</div>
|
||
<CompareSlider
|
||
beforeSrc={BEFORE_EMPTY_HALL}
|
||
afterSrc={afterSrc || AFTER_PLACEHOLDER}
|
||
beforeLabel="시공 전"
|
||
afterLabel="시공 후"
|
||
afterOverlay={
|
||
<>
|
||
<div className="kx-studio__wm" aria-hidden="true">
|
||
{s1?.watermarkText ?? 'AI 생성 예상 이미지'}
|
||
</div>
|
||
<span className="kx-studio__after-ai">
|
||
<AiLabel>{afterReal ? 'AI 생성' : '예시'}</AiLabel>
|
||
</span>
|
||
</>
|
||
}
|
||
/>
|
||
<p className="kx-studio__compare-notice">
|
||
{s1?.notice ??
|
||
'AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다'}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{/* 사진 시안(AI) — ReRoomAI 방식 image-to-image (소유자 지시 13.2) */}
|
||
<section className="kx-reroom" aria-label="사진 시안 (AI)">
|
||
<div className="kx-reroom__head">
|
||
<AiLabel>사진 시안 (AI)</AiLabel>
|
||
<p className="kx-reroom__sub">
|
||
빈 부스·공간 사진을 올리면 골격·구도는 그대로 두고 스타일만 바꾼 “시공 후” 예상 사진을 생성합니다.
|
||
시공 기준은 도면이며, 생성 이미지는 참고용입니다.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="kx-reroom__body">
|
||
{/* 01 업로드 */}
|
||
<div className="kx-reroom__col">
|
||
<span className="kx-reroom__step">01 빈 부스 사진</span>
|
||
<div
|
||
className={`kx-reroom__drop${dragOver ? ' is-over' : ''}${
|
||
refPreview ? ' has-img' : ''
|
||
}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="참조 이미지 업로드"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
fileInputRef.current?.click();
|
||
}
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setDragOver(true);
|
||
}}
|
||
onDragLeave={() => setDragOver(false)}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
setDragOver(false);
|
||
void handleRefFile(e.dataTransfer.files?.[0]);
|
||
}}
|
||
>
|
||
{refPreview ? (
|
||
<img src={refPreview} alt="참조 이미지 미리보기" className="kx-reroom__preview" />
|
||
) : (
|
||
<span className="kx-reroom__drop-hint">
|
||
<span className="kx-reroom__drop-plus" aria-hidden="true">
|
||
+
|
||
</span>
|
||
사진을 끌어다 놓거나 클릭해 업로드
|
||
<small>JPG · PNG · WebP · 10MB 이하</small>
|
||
</span>
|
||
)}
|
||
{refUploading && (
|
||
<span className="kx-reroom__uploading" aria-live="polite">
|
||
업로드 중…
|
||
</span>
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
hidden
|
||
onChange={(e) => void handleRefFile(e.target.files?.[0])}
|
||
/>
|
||
</div>
|
||
|
||
{/* 02 스타일 + 03 지시 + 생성 */}
|
||
<div className="kx-reroom__col kx-reroom__col--wide">
|
||
<span className="kx-reroom__step">02 스타일</span>
|
||
<div className="kx-reroom__styles" role="radiogroup" aria-label="시안 스타일">
|
||
{REROOM_STYLES.map((s) => (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={reroomStyle === s.id}
|
||
className={`kx-reroom__style${reroomStyle === s.id ? ' is-active' : ''}`}
|
||
onClick={() => setReroomStyle(s.id)}
|
||
>
|
||
<span className="kx-reroom__swatch" aria-hidden="true">
|
||
{s.swatch.map((c, i) => (
|
||
<i key={i} style={{ background: c }} />
|
||
))}
|
||
</span>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<span className="kx-reroom__step">03 추가 지시 (선택)</span>
|
||
<textarea
|
||
className="kx-reroom__instruction"
|
||
rows={2}
|
||
maxLength={500}
|
||
placeholder="예: 파란색 브랜드 월과 로봇 데모 존을 강조해줘"
|
||
value={reroomInstruction}
|
||
onChange={(e) => setReroomInstruction(e.target.value)}
|
||
/>
|
||
|
||
<div className="kx-reroom__actions">
|
||
<Button
|
||
variant="ai"
|
||
leadingIcon="✦"
|
||
onClick={generateReroom}
|
||
disabled={!refId || refUploading || reroomBusy}
|
||
>
|
||
{reroomBusy ? '시안 생성 요청 중…' : '사진 시안 생성'}
|
||
</Button>
|
||
{reroomError && (
|
||
<p className="kx-reroom__err" role="status">
|
||
{reroomError}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 결과 Before/After */}
|
||
{reroomJob && (
|
||
<div className="kx-reroom__result">
|
||
{reroomJob.status === 'DONE' && reroomJob.imageUrl && refPreview ? (
|
||
<CompareSlider
|
||
beforeSrc={refPreview}
|
||
afterSrc={reroomJob.imageUrl}
|
||
beforeLabel="업로드(빈 공간)"
|
||
afterLabel="AI 시안"
|
||
afterOverlay={
|
||
<>
|
||
<div className="kx-studio__wm" aria-hidden="true">
|
||
{reroomJob.watermarkText}
|
||
</div>
|
||
<span className="kx-studio__after-ai">
|
||
<AiLabel>AI 생성</AiLabel>
|
||
</span>
|
||
</>
|
||
}
|
||
/>
|
||
) : (
|
||
<AiImage
|
||
imageUrl={reroomJob.imageUrl}
|
||
status={reroomJob.status}
|
||
watermarkText={reroomJob.watermarkText}
|
||
notice={reroomJob.notice}
|
||
shotLabel="사진 시안"
|
||
onRetry={generateReroom}
|
||
alt="사진 시안 예상 이미지"
|
||
/>
|
||
)}
|
||
<p className="kx-reroom__notice">{reroomJob.notice}</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */}
|
||
<footer className="kx-studio__footer">
|
||
<div className="kx-studio__precheck">
|
||
<Button variant="ghost" onClick={runPrecheck} disabled={prechecking}>
|
||
{prechecking ? '검증 중…' : '규정 사전검증'}
|
||
</Button>
|
||
{report ? (
|
||
<div className="kx-studio__chips tnum">
|
||
<span className="kx-chip kx-chip--pass">통과 {passCount}</span>
|
||
{warnCount > 0 && <span className="kx-chip kx-chip--warn">경고 {warnCount}</span>}
|
||
{blockCount > 0 && <span className="kx-chip kx-chip--block">차단 {blockCount}</span>}
|
||
{report.violations.some((v) => v.requiresDocument) && (
|
||
<span className="kx-chip kx-chip--doc">구조계산서 D-7 필요</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="kx-studio__precheck-hint">사전검증을 실행하세요</span>
|
||
)}
|
||
</div>
|
||
<Button disabled={!!report && !submittable} onClick={() => setConfirmOpen(true)}>
|
||
장치업체에 컨펌 요청
|
||
</Button>
|
||
</footer>
|
||
|
||
{notice && (
|
||
<p className="kx-studio__banner" role="status">
|
||
{notice}
|
||
</p>
|
||
)}
|
||
|
||
{/* 컨펌 모달 — "시공 기준은 도면입니다" 동의 필수 */}
|
||
{confirmOpen && (
|
||
<div className="kx-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||
<div className="kx-modal__backdrop" onClick={() => setConfirmOpen(false)} />
|
||
<div className="kx-studio__confirm">
|
||
<h3 id="confirm-title">컨펌 요청</h3>
|
||
<p>
|
||
장치업체에 이 설계안의 컨펌을 요청합니다. AI 예상 사진은 참고용이며 실제 시공 기준은
|
||
도면입니다.
|
||
</p>
|
||
<label className="kx-studio__toggle kx-studio__toggle--req">
|
||
<input
|
||
type="checkbox"
|
||
checked={confirmAgree}
|
||
onChange={(e) => setConfirmAgree(e.target.checked)}
|
||
/>
|
||
시공 기준은 도면임을 확인했습니다 (필수)
|
||
</label>
|
||
<div className="kx-modal__actions">
|
||
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
|
||
취소
|
||
</Button>
|
||
<Button
|
||
disabled={!confirmAgree}
|
||
onClick={() => {
|
||
setConfirmOpen(false);
|
||
setNotice('컨펌 요청이 전송되었습니다.');
|
||
}}
|
||
>
|
||
컨펌 요청 전송
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ZONE_LABEL: Record<string, string> = {
|
||
demo: '시연존',
|
||
consult: '상담존',
|
||
reception: '안내데스크',
|
||
storage: '창고',
|
||
};
|
||
|
||
function Accordion({
|
||
title,
|
||
open,
|
||
onToggle,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<section className="kx-acc">
|
||
<button
|
||
className="kx-acc__head"
|
||
aria-expanded={open}
|
||
onClick={onToggle}
|
||
type="button"
|
||
>
|
||
<span>{title}</span>
|
||
<span className="kx-acc__chevron" aria-hidden="true">
|
||
{open ? '▾' : '▸'}
|
||
</span>
|
||
</button>
|
||
{open && <div className="kx-acc__body">{children}</div>}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<label className="kx-field-block">
|
||
<span className="kx-field-block__label">{label}</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|