Files
sam-manage/resources/views/rd/ai-quotation/edit.blade.php
김보곤 25795f8612 feat: [ai-quotation] 제조 견적서 자동 생성 기능 추가
- AI 2단계 분석: 고객 인터뷰 → 요구사항 추출 → 견적 산출
- 모델 확장: AiQuotation(모드/견적번호), AiQuotationItem(규격/단가/금액)
- AiQuotePriceTable 모델 신규 생성
- Create 페이지: 모듈/제조 모드 탭, 제품 카테고리, 고객 정보 입력
- Show 페이지: 제조 모드 분기 렌더링 (품목/금액/고객정보)
- Edit 페이지: 품목 인라인 편집, 할인/부가세/조건 입력
- Document: 한국 표준 제조업 견적서 양식 템플릿
- Controller/Route: update 엔드포인트, edit 라우트 추가
2026-03-03 15:58:16 +09:00

404 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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