feat: 하이브로지스틱스 견적서 + 컨셉 시안 + 견적 UI 개선

- 하이브로지스틱스코리아 홈페이지 리뉴얼 견적서(docs) + 컨셉 시안(HTML)
- 관리자 견적항목: grid→flex 레이아웃, 수량/선택 축소, 설명 확대
- 고객용 견적서: table-layout fixed, 카테고리 줄바꿈 방지, WBS 너비 통일
- PUT API wbs 필드 허용 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:47:41 +09:00
parent 5515a6b48b
commit fae92940e5
5 changed files with 1409 additions and 34 deletions

View File

@@ -388,7 +388,7 @@ export default function QuoteEditorPage() {
{/* ── 견적항목 ── */}
{tab === '견적항목' && (
<div className="max-w-5xl space-y-3">
<div className="max-w-6xl space-y-3">
<div className="flex items-center justify-between mb-2">
<p className="text-slate-400 text-sm"> (optional) / </p>
<button onClick={addItem} className={addBtn}>+ </button>
@@ -396,39 +396,39 @@ export default function QuoteEditorPage() {
{/* 헤더 */}
{form.items.length > 0 && (
<div className="grid grid-cols-12 gap-3 px-4 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<div className="col-span-2"></div>
<div className="col-span-3"></div>
<div className="col-span-3"></div>
<div className="col-span-1 text-right"></div>
<div className="col-span-1 text-right"></div>
<div className="col-span-1 text-center"></div>
<div className="col-span-1" />
<div className="flex gap-3 px-4 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<div className="w-[100px] flex-shrink-0"></div>
<div className="w-[200px] flex-shrink-0"></div>
<div className="flex-1 min-w-[200px]"></div>
<div className="w-[60px] flex-shrink-0 text-right"></div>
<div className="w-[120px] flex-shrink-0 text-right"></div>
<div className="w-[50px] flex-shrink-0 text-center"></div>
<div className="w-[32px] flex-shrink-0" />
</div>
)}
{form.items.length === 0 && <EmptyState icon="💰" msg="항목을 추가해 견적을 구성해보세요" />}
{form.items.map((item) => (
<div key={item.id} className={`grid grid-cols-12 gap-3 px-4 py-3 rounded-xl border items-center transition-all ${item.optional ? 'bg-violet-900/10 border-violet-800/30' : 'bg-slate-900 border-slate-800'}`}>
<div className="col-span-2">
<div key={item.id} className={`flex gap-3 px-4 py-3 rounded-xl border items-center transition-all ${item.optional ? 'bg-violet-900/10 border-violet-800/30' : 'bg-slate-900 border-slate-800'}`}>
<div className="w-[100px] flex-shrink-0">
<select className={inpSm} value={item.category} onChange={(e) => updateItem(item.id, 'category', e.target.value)}>
{ITEM_CATEGORIES.map((c) => <option key={c}>{c}</option>)}
</select>
</div>
<div className="col-span-3">
<div className="w-[200px] flex-shrink-0">
<input className={inpSm} value={item.name} onChange={(e) => updateItem(item.id, 'name', e.target.value)} placeholder="항목명" />
</div>
<div className="col-span-3">
<div className="flex-1 min-w-[200px]">
<input className={inpSm} value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} placeholder="상세 설명" />
</div>
<div className="col-span-1">
<div className="w-[60px] flex-shrink-0">
<input className={`${inpSm} text-right`} type="number" min={1} value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', Number(e.target.value))} />
</div>
<div className="col-span-1">
<div className="w-[120px] flex-shrink-0">
<input className={`${inpSm} text-right`} type="number" min={0} step={10000} value={item.unitPrice} onChange={(e) => updateItem(item.id, 'unitPrice', Number(e.target.value))} />
</div>
<div className="col-span-1 flex justify-center">
<div className="w-[50px] flex-shrink-0 flex justify-center">
<button
onClick={() => updateItem(item.id, 'optional', !item.optional)}
title={item.optional ? '선택 항목 (클릭시 필수로)' : '필수 항목 (클릭시 선택으로)'}
@@ -436,7 +436,7 @@ export default function QuoteEditorPage() {
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${item.optional ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
<div className="col-span-1 flex justify-end">
<div className="w-[32px] flex-shrink-0 flex justify-end">
<button onClick={() => removeItem(item.id)} className="text-slate-600 hover:text-red-400 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>

View File

@@ -34,7 +34,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
// ── 허용 필드 화이트리스트 (시스템 필드 변조 방지) ───────────
const ALLOWED_FIELDS = [
'title', 'client_name', 'client_email', 'client_phone',
'items', 'maintenance', 'notes', 'status',
'wbs', 'items', 'maintenance', 'notes', 'status',
'valid_until', 'discount',
] as const;

View File

@@ -272,19 +272,24 @@ export default function QuotePage() {
<h3 style={{ fontSize: 18, fontWeight: 700, color: 'white' }}>{phase.phase}</h3>
</div>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '28%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '60%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th style={thStyle}></th>
<th style={{ ...thStyle, width: 100 }}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{phase.tasks.map((task) => (
<tr key={task.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<td style={tdStyle}>{task.name}</td>
<td style={{ ...tdStyle, color: '#818cf8', fontWeight: 600 }}>{task.duration}</td>
<td style={{ ...tdStyle, wordBreak: 'keep-all' }}>{task.name}</td>
<td style={{ ...tdStyle, color: '#818cf8', fontWeight: 600, whiteSpace: 'nowrap' }}>{task.duration}</td>
<td style={{ ...tdStyle, color: '#64748b' }}>{task.description || '—'}</td>
</tr>
))}
@@ -306,8 +311,16 @@ export default function QuotePage() {
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#60a5fa', display: 'inline-block' }} />
</h3>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
<colgroup>
<col style={{ width: '10%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '42%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th style={thStyle}></th>
@@ -322,15 +335,15 @@ export default function QuotePage() {
{requiredItems.map((item) => (
<tr key={item.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<td style={tdStyle}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100 }}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
{item.category}
</span>
</td>
<td style={{ ...tdStyle, fontWeight: 600, color: 'white' }}>{item.name}</td>
<td style={{ ...tdStyle, color: '#64748b' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', fontFamily: 'monospace' }}>{item.unitPrice.toLocaleString()}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'white', fontFamily: 'monospace' }}>{(item.unitPrice * item.quantity).toLocaleString()}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{item.unitPrice.toLocaleString()}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'white', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{(item.unitPrice * item.quantity).toLocaleString()}</td>
</tr>
))}
</tbody>
@@ -347,11 +360,19 @@ export default function QuotePage() {
</h3>
<p style={{ color: '#475569', fontSize: 13, marginBottom: 12 }}> </p>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(167,139,250,0.2)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(167,139,250,0.2)', overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
<colgroup>
<col style={{ width: '6%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '42%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th style={{ ...thStyle, width: 50 }}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
@@ -368,14 +389,14 @@ export default function QuotePage() {
<input type="checkbox" checked={!!checkedOptional[item.id]} onChange={() => {}} />
</td>
<td style={tdStyle}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100 }}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
{item.category}
</span>
</td>
<td style={{ ...tdStyle, fontWeight: 600, color: checkedOptional[item.id] ? 'white' : '#64748b' }}>{item.name}</td>
<td style={{ ...tdStyle, color: '#475569' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#64748b' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#a78bfa' : '#475569', fontFamily: 'monospace' }}>
<td style={{ ...tdStyle, textAlign: 'right', color: '#64748b', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#a78bfa' : '#475569', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
{(item.unitPrice * item.quantity).toLocaleString()}
</td>
</tr>