- AI 2단계 분석: 고객 인터뷰 → 요구사항 추출 → 견적 산출 - 모델 확장: AiQuotation(모드/견적번호), AiQuotationItem(규격/단가/금액) - AiQuotePriceTable 모델 신규 생성 - Create 페이지: 모듈/제조 모드 탭, 제품 카테고리, 고객 정보 입력 - Show 페이지: 제조 모드 분기 렌더링 (품목/금액/고객정보) - Edit 페이지: 품목 인라인 편집, 할인/부가세/조건 입력 - Document: 한국 표준 제조업 견적서 양식 템플릿 - Controller/Route: update 엔드포인트, edit 라우트 추가
404 lines
22 KiB
PHP
404 lines
22 KiB
PHP
@extends('layouts.app')
|
||
|
||
@section('title', '견적서 편집')
|
||
|
||
@section('content')
|
||
@php
|
||
$options = $quotation->options ?? [];
|
||
$client = $options['client'] ?? [];
|
||
$project = $options['project'] ?? [];
|
||
$pricing = $options['pricing'] ?? [];
|
||
$terms = $options['terms'] ?? [];
|
||
@endphp
|
||
|
||
<!-- 페이지 헤더 -->
|
||
<div class="flex justify-between items-center mb-6">
|
||
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||
<i class="ri-edit-line text-blue-600"></i>
|
||
견적서 편집
|
||
<span class="text-base font-normal text-gray-400">{{ $quotation->quote_number }}</span>
|
||
</h1>
|
||
<div class="flex gap-2">
|
||
<a href="{{ route('rd.ai-quotation.show', $quotation->id) }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
|
||
<i class="ri-arrow-left-line"></i> 상세보기
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<form id="editForm">
|
||
<!-- 고객 정보 -->
|
||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||
<div class="px-6 py-4 border-b border-gray-100">
|
||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||
<i class="ri-user-line text-blue-600"></i> 고객 정보
|
||
</h2>
|
||
</div>
|
||
<div class="p-6">
|
||
<div class="grid gap-4" style="grid-template-columns: repeat(2, 1fr);">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">회사명</label>
|
||
<input type="text" name="client_company" value="{{ $client['company'] ?? '' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
|
||
<input type="text" name="client_contact" value="{{ $client['contact'] ?? '' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">연락처</label>
|
||
<input type="text" name="client_phone" value="{{ $client['phone'] ?? '' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||
<input type="email" name="client_email" value="{{ $client['email'] ?? '' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
<div style="grid-column: span 2;">
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||
<input type="text" name="client_address" value="{{ $client['address'] ?? '' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 프로젝트 정보 -->
|
||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||
<div class="px-6 py-4 border-b border-gray-100">
|
||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||
<i class="ri-building-line text-green-600"></i> 프로젝트 정보
|
||
</h2>
|
||
</div>
|
||
<div class="p-6">
|
||
<div class="grid gap-4" style="grid-template-columns: repeat(2, 1fr);">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">현장명</label>
|
||
<input type="text" name="project_name" value="{{ $project['name'] ?? '' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">위치</label>
|
||
<input type="text" name="project_location" value="{{ $project['location'] ?? '' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 품목 편집 -->
|
||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
|
||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||
<i class="ri-list-check-2 text-purple-600"></i> 품목 편집
|
||
</h2>
|
||
<button type="button" onclick="addItemRow()" class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition">
|
||
<i class="ri-add-line"></i> 행 추가
|
||
</button>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm" id="itemsTable">
|
||
<thead class="bg-gray-50 border-b">
|
||
<tr>
|
||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500" style="width: 40px;">No</th>
|
||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500">분류</th>
|
||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500">위치</th>
|
||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500">품목명</th>
|
||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500">규격</th>
|
||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500" style="width: 60px;">단위</th>
|
||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500" style="width: 70px;">수량</th>
|
||
<th class="px-3 py-3 text-right text-xs font-medium text-gray-500" style="width: 120px;">단가</th>
|
||
<th class="px-3 py-3 text-right text-xs font-medium text-gray-500" style="width: 120px;">금액</th>
|
||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500" style="width: 40px;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="itemsBody">
|
||
@foreach($quotation->items as $index => $item)
|
||
<tr class="item-row border-b border-gray-100" data-index="{{ $index }}">
|
||
<td class="px-3 py-2 text-center text-gray-500 row-number">{{ $index + 1 }}</td>
|
||
<td class="px-3 py-2">
|
||
<select name="items[{{ $index }}][item_category]" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs">
|
||
<option value="material" {{ $item->item_category === 'material' ? 'selected' : '' }}>재료비</option>
|
||
<option value="labor" {{ $item->item_category === 'labor' ? 'selected' : '' }}>노무비</option>
|
||
<option value="install" {{ $item->item_category === 'install' ? 'selected' : '' }}>설치비</option>
|
||
</select>
|
||
</td>
|
||
<td class="px-3 py-2">
|
||
<input type="text" name="items[{{ $index }}][floor_code]" value="{{ $item->floor_code }}"
|
||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs" placeholder="B1-A01">
|
||
</td>
|
||
<td class="px-3 py-2">
|
||
<input type="text" name="items[{{ $index }}][item_name]" value="{{ $item->module_name }}"
|
||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs" required>
|
||
</td>
|
||
<td class="px-3 py-2">
|
||
<input type="text" name="items[{{ $index }}][specification]" value="{{ $item->specification }}"
|
||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs" placeholder="3000×2500">
|
||
</td>
|
||
<td class="px-3 py-2">
|
||
<input type="text" name="items[{{ $index }}][unit]" value="{{ $item->unit ?? 'SET' }}"
|
||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs">
|
||
</td>
|
||
<td class="px-3 py-2">
|
||
<input type="number" name="items[{{ $index }}][quantity]" value="{{ (float)$item->quantity }}"
|
||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs text-center item-qty"
|
||
min="0" step="1" onchange="calcRow(this)">
|
||
</td>
|
||
<td class="px-3 py-2">
|
||
<input type="text" name="items[{{ $index }}][unit_price]" value="{{ number_format((float)$item->unit_price) }}"
|
||
class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs text-right money-input item-price"
|
||
inputmode="numeric" onfocus="moneyFocus(this)" onblur="moneyBlur(this)" onchange="calcRow(this)">
|
||
</td>
|
||
<td class="px-3 py-2">
|
||
<span class="item-total font-medium text-gray-800">{{ number_format((float)$item->total_price) }}</span>
|
||
</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<button type="button" onclick="removeRow(this)" class="text-red-400 hover:text-red-600 transition">
|
||
<i class="ri-delete-bin-line"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
<tfoot class="bg-gray-50 border-t-2">
|
||
<tr>
|
||
<td colspan="8" class="px-3 py-3 text-right font-bold text-gray-700">소계</td>
|
||
<td class="px-3 py-3 text-right font-bold text-blue-700" id="subtotalDisplay">{{ number_format((int)($pricing['subtotal'] ?? 0)) }}</td>
|
||
<td></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 가격 조정 + 조건 -->
|
||
<div class="grid gap-6 mb-6" style="grid-template-columns: 1fr 1fr;">
|
||
<!-- 가격 조정 -->
|
||
<div class="bg-white rounded-lg shadow-sm">
|
||
<div class="px-6 py-4 border-b border-gray-100">
|
||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||
<i class="ri-calculator-line text-orange-600"></i> 가격 조정
|
||
</h2>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">할인율 (%)</label>
|
||
<input type="number" name="discount_rate" id="discountRate" value="{{ $pricing['discount_rate'] ?? 0 }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||
min="0" max="100" step="0.1" onchange="recalcTotal()">
|
||
</div>
|
||
<div class="space-y-2 text-sm border-t pt-4">
|
||
<div class="flex justify-between"><span class="text-gray-600">소계</span><span id="pricingSubtotal">{{ number_format((int)($pricing['subtotal'] ?? 0)) }}원</span></div>
|
||
<div class="flex justify-between text-red-600"><span>할인</span><span id="pricingDiscount">-{{ number_format((int)($pricing['discount_amount'] ?? 0)) }}원</span></div>
|
||
<div class="flex justify-between"><span class="text-gray-600">부가세 (10%)</span><span id="pricingVat">{{ number_format((int)($pricing['vat_amount'] ?? 0)) }}원</span></div>
|
||
<div class="flex justify-between text-lg border-t pt-2">
|
||
<span class="font-bold text-blue-700">최종 금액</span>
|
||
<span class="font-bold text-blue-700" id="pricingFinal">{{ number_format((int)($pricing['final_amount'] ?? 0)) }}원</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 조건 입력 -->
|
||
<div class="bg-white rounded-lg shadow-sm">
|
||
<div class="px-6 py-4 border-b border-gray-100">
|
||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||
<i class="ri-file-list-3-line text-teal-600"></i> 견적 조건
|
||
</h2>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">유효기간</label>
|
||
<input type="date" name="terms_valid_until" value="{{ $terms['valid_until'] ?? now()->addDays(30)->format('Y-m-d') }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">결제조건</label>
|
||
<input type="text" name="terms_payment" value="{{ $terms['payment'] ?? '계약 시 50%, 설치 완료 후 50%' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">납기조건</label>
|
||
<input type="text" name="terms_delivery" value="{{ $terms['delivery'] ?? '계약 후 4주 이내' }}"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 저장 버튼 -->
|
||
<div class="flex justify-end gap-3">
|
||
<a href="{{ route('rd.ai-quotation.show', $quotation->id) }}" class="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
|
||
취소
|
||
</a>
|
||
<button type="submit" id="saveBtn" class="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition flex items-center gap-2">
|
||
<i class="ri-save-line"></i> 저장
|
||
</button>
|
||
</div>
|
||
</form>
|
||
@endsection
|
||
|
||
@push('scripts')
|
||
<script>
|
||
let rowCounter = {{ $quotation->items->count() }};
|
||
|
||
function moneyFocus(el) { el.value = el.value.replace(/,/g, ''); }
|
||
function moneyBlur(el) {
|
||
const val = parseInt(el.value) || 0;
|
||
el.value = val.toLocaleString();
|
||
}
|
||
function parseMoneyValue(el) { return parseInt(String(el.value).replace(/,/g, '')) || 0; }
|
||
|
||
function calcRow(el) {
|
||
const row = el.closest('tr');
|
||
const qty = parseFloat(row.querySelector('.item-qty').value) || 0;
|
||
const priceEl = row.querySelector('.item-price');
|
||
const price = parseMoneyValue(priceEl);
|
||
const total = Math.round(qty * price);
|
||
row.querySelector('.item-total').textContent = total.toLocaleString();
|
||
recalcTotal();
|
||
}
|
||
|
||
function recalcTotal() {
|
||
let subtotal = 0;
|
||
document.querySelectorAll('.item-row').forEach(row => {
|
||
const text = row.querySelector('.item-total')?.textContent || '0';
|
||
subtotal += parseInt(text.replace(/,/g, '')) || 0;
|
||
});
|
||
|
||
const discountRate = parseFloat(document.getElementById('discountRate').value) || 0;
|
||
const discountAmount = Math.round(subtotal * discountRate / 100);
|
||
const afterDiscount = subtotal - discountAmount;
|
||
const vat = Math.round(afterDiscount * 0.1);
|
||
const final_amount = afterDiscount + vat;
|
||
|
||
document.getElementById('subtotalDisplay').textContent = subtotal.toLocaleString();
|
||
document.getElementById('pricingSubtotal').textContent = subtotal.toLocaleString() + '원';
|
||
document.getElementById('pricingDiscount').textContent = '-' + discountAmount.toLocaleString() + '원';
|
||
document.getElementById('pricingVat').textContent = vat.toLocaleString() + '원';
|
||
document.getElementById('pricingFinal').textContent = final_amount.toLocaleString() + '원';
|
||
}
|
||
|
||
function addItemRow() {
|
||
const idx = rowCounter++;
|
||
const tbody = document.getElementById('itemsBody');
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'item-row border-b border-gray-100';
|
||
tr.dataset.index = idx;
|
||
tr.innerHTML = `
|
||
<td class="px-3 py-2 text-center text-gray-500 row-number"></td>
|
||
<td class="px-3 py-2">
|
||
<select name="items[${idx}][item_category]" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs">
|
||
<option value="material">재료비</option>
|
||
<option value="labor">노무비</option>
|
||
<option value="install">설치비</option>
|
||
</select>
|
||
</td>
|
||
<td class="px-3 py-2"><input type="text" name="items[${idx}][floor_code]" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs" placeholder="B1-A01"></td>
|
||
<td class="px-3 py-2"><input type="text" name="items[${idx}][item_name]" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs" required></td>
|
||
<td class="px-3 py-2"><input type="text" name="items[${idx}][specification]" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs" placeholder="3000×2500"></td>
|
||
<td class="px-3 py-2"><input type="text" name="items[${idx}][unit]" value="SET" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs"></td>
|
||
<td class="px-3 py-2"><input type="number" name="items[${idx}][quantity]" value="1" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs text-center item-qty" min="0" step="1" onchange="calcRow(this)"></td>
|
||
<td class="px-3 py-2"><input type="text" name="items[${idx}][unit_price]" value="0" class="w-full px-2 py-1.5 border border-gray-300 rounded text-xs text-right money-input item-price" inputmode="numeric" onfocus="moneyFocus(this)" onblur="moneyBlur(this)" onchange="calcRow(this)"></td>
|
||
<td class="px-3 py-2"><span class="item-total font-medium text-gray-800">0</span></td>
|
||
<td class="px-3 py-2 text-center"><button type="button" onclick="removeRow(this)" class="text-red-400 hover:text-red-600 transition"><i class="ri-delete-bin-line"></i></button></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
renumberRows();
|
||
}
|
||
|
||
function removeRow(btn) {
|
||
if (document.querySelectorAll('.item-row').length <= 1) {
|
||
alert('최소 1개의 품목이 필요합니다.');
|
||
return;
|
||
}
|
||
btn.closest('tr').remove();
|
||
renumberRows();
|
||
recalcTotal();
|
||
}
|
||
|
||
function renumberRows() {
|
||
document.querySelectorAll('.item-row').forEach((row, idx) => {
|
||
row.querySelector('.row-number').textContent = idx + 1;
|
||
});
|
||
}
|
||
|
||
// 폼 제출
|
||
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
const btn = document.getElementById('saveBtn');
|
||
const original = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 저장중...';
|
||
|
||
// 데이터 수집
|
||
const items = [];
|
||
document.querySelectorAll('.item-row').forEach(row => {
|
||
const inputs = row.querySelectorAll('input, select');
|
||
const item = {};
|
||
inputs.forEach(input => {
|
||
const name = input.name;
|
||
if (!name) return;
|
||
const key = name.replace(/items\[\d+\]\[/, '').replace(']', '');
|
||
let val = input.value;
|
||
if (key === 'unit_price') val = String(val).replace(/,/g, '');
|
||
item[key] = val;
|
||
});
|
||
items.push(item);
|
||
});
|
||
|
||
const payload = {
|
||
client: {
|
||
company: document.querySelector('[name="client_company"]').value,
|
||
contact: document.querySelector('[name="client_contact"]').value,
|
||
phone: document.querySelector('[name="client_phone"]').value,
|
||
email: document.querySelector('[name="client_email"]').value,
|
||
address: document.querySelector('[name="client_address"]').value,
|
||
},
|
||
project: {
|
||
name: document.querySelector('[name="project_name"]').value,
|
||
location: document.querySelector('[name="project_location"]').value,
|
||
},
|
||
terms: {
|
||
valid_until: document.querySelector('[name="terms_valid_until"]').value,
|
||
payment: document.querySelector('[name="terms_payment"]').value,
|
||
delivery: document.querySelector('[name="terms_delivery"]').value,
|
||
},
|
||
discount_rate: parseFloat(document.getElementById('discountRate').value) || 0,
|
||
items: items,
|
||
};
|
||
|
||
try {
|
||
const token = document.querySelector('meta[name="api-token"]')?.content
|
||
|| sessionStorage.getItem('api_token') || '';
|
||
|
||
const response = await fetch('{{ url("/api/admin/rd/ai-quotation/{$quotation->id}") }}', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||
'Authorization': token ? `Bearer ${token}` : '',
|
||
},
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
window.location.href = '{{ route("rd.ai-quotation.show", $quotation->id) }}';
|
||
} else {
|
||
alert(result.message || '저장에 실패했습니다.');
|
||
}
|
||
} catch (err) {
|
||
console.error('저장 실패:', err);
|
||
alert('서버 통신 중 오류가 발생했습니다.');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = original;
|
||
}
|
||
});
|
||
</script>
|
||
@endpush
|