diff --git a/app/Http/Controllers/Juil/PmisMaterialController.php b/app/Http/Controllers/Juil/PmisMaterialController.php new file mode 100644 index 00000000..0abeb439 --- /dev/null +++ b/app/Http/Controllers/Juil/PmisMaterialController.php @@ -0,0 +1,87 @@ +tenantId()) + ->orderByDesc('id'); + + if ($request->filled('company')) { + $query->where('company_name', 'like', '%' . $request->company . '%'); + } + if ($request->filled('material_name')) { + $query->where('material_name', 'like', '%' . $request->material_name . '%'); + } + if ($request->filled('search')) { + $s = $request->search; + $query->where(function ($q) use ($s) { + $q->where('material_name', 'like', "%{$s}%") + ->orWhere('material_code', 'like', "%{$s}%") + ->orWhere('specification', 'like', "%{$s}%"); + }); + } + + $perPage = $request->integer('per_page', 15); + $materials = $query->paginate($perPage); + + return response()->json($materials); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'company_name' => 'required|string|max:200', + 'material_code' => 'nullable|string|max:50', + 'material_name' => 'required|string|max:200', + 'specification' => 'nullable|string|max:300', + 'unit' => 'nullable|string|max:50', + 'design_quantity' => 'nullable|numeric|min:0', + ]); + + $validated['tenant_id'] = $this->tenantId(); + $validated['design_quantity'] = $validated['design_quantity'] ?? 0; + + $material = PmisMaterial::create($validated); + + return response()->json($material, 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $material = PmisMaterial::tenant($this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'company_name' => 'sometimes|required|string|max:200', + 'material_code' => 'nullable|string|max:50', + 'material_name' => 'sometimes|required|string|max:200', + 'specification' => 'nullable|string|max:300', + 'unit' => 'nullable|string|max:50', + 'design_quantity' => 'nullable|numeric|min:0', + ]); + + $material->update($validated); + + return response()->json($material); + } + + public function destroy(int $id): JsonResponse + { + $material = PmisMaterial::tenant($this->tenantId())->findOrFail($id); + $material->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } +} diff --git a/app/Models/Juil/PmisMaterial.php b/app/Models/Juil/PmisMaterial.php new file mode 100644 index 00000000..ad52a966 --- /dev/null +++ b/app/Models/Juil/PmisMaterial.php @@ -0,0 +1,34 @@ + 'decimal:2', + 'options' => 'array', + ]; + + public function scopeTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/resources/views/juil/pmis-materials.blade.php b/resources/views/juil/pmis-materials.blade.php index ef7e2185..e509589a 100644 --- a/resources/views/juil/pmis-materials.blade.php +++ b/resources/views/juil/pmis-materials.blade.php @@ -14,6 +14,26 @@ @verbatim const { useState, useEffect, useRef, useCallback, useMemo } = React; +const API_BASE = '/juil/construction-pmis/api'; +const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || ''; + +async function api(path, opts = {}) { + const res = await fetch(`${API_BASE}${path}`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF, + ...opts.headers, + }, + ...opts, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || `HTTP ${res.status}`); + } + return res.json(); +} + /* ════════════════════════════════════════════════ PMIS 사이드바 ════════════════════════════════════════════════ */ @@ -84,28 +104,667 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg ); } +/* ════════════════════════════════════════════════ + 기준자재정보 목록 (선택용) + ════════════════════════════════════════════════ */ +const BASE_MATERIALS = [ + { code: 'M001', name: '레미콘', spec: '25-24-15', unit: 'm3' }, + { code: 'M002', name: '레미콘', spec: '25-21-15', unit: 'm3' }, + { code: 'M003', name: '레미콘', spec: '25-18-15', unit: 'm3' }, + { code: 'M004', name: '철근', spec: 'HD10', unit: 'ton' }, + { code: 'M005', name: '철근', spec: 'HD13', unit: 'ton' }, + { code: 'M006', name: '철근', spec: 'HD16', unit: 'ton' }, + { code: 'M007', name: '철근', spec: 'HD19', unit: 'ton' }, + { code: 'M008', name: '철근', spec: 'HD22', unit: 'ton' }, + { code: 'M009', name: '철근', spec: 'HD25', unit: 'ton' }, + { code: 'M010', name: '시멘트', spec: '보통포틀랜드', unit: 'ton' }, + { code: 'M011', name: '모래', spec: '세척사', unit: 'm3' }, + { code: 'M012', name: '자갈', spec: '25mm', unit: 'm3' }, + { code: 'M013', name: '거푸집', spec: '합판 12T', unit: 'm2' }, + { code: 'M014', name: '거푸집', spec: '유로폼', unit: 'm2' }, + { code: 'M015', name: '방수재', spec: '도막방수', unit: 'm2' }, + { code: 'M016', name: '방수재', spec: '시트방수', unit: 'm2' }, + { code: 'M017', name: '단열재', spec: '압출법보온판 50T', unit: 'm2' }, + { code: 'M018', name: '단열재', spec: '비드법보온판 100T', unit: 'm2' }, + { code: 'M019', name: '벽돌', spec: '시멘트벽돌 190x90x57', unit: '매' }, + { code: 'M020', name: '타일', spec: '300x300 바닥타일', unit: 'm2' }, + { code: 'M021', name: '타일', spec: '200x200 벽타일', unit: 'm2' }, + { code: 'M022', name: '페인트', spec: '수성페인트', unit: 'L' }, + { code: 'M023', name: '페인트', spec: '유성페인트', unit: 'L' }, + { code: 'M024', name: '석고보드', spec: '9.5T', unit: 'm2' }, + { code: 'M025', name: '석고보드', spec: '12.5T', unit: 'm2' }, + { code: 'M026', name: 'PVC관', spec: 'VG1 100A', unit: 'm' }, + { code: 'M027', name: 'PVC관', spec: 'VG1 150A', unit: 'm' }, + { code: 'M028', name: '전선', spec: 'HIV 2.5sq', unit: 'm' }, + { code: 'M029', name: '전선', spec: 'HIV 4.0sq', unit: 'm' }, + { code: 'M030', name: '철골', spec: 'H형강 300x150', unit: 'ton' }, +]; + +/* ════════════════════════════════════════════════ + 기준자재정보 모달 + ════════════════════════════════════════════════ */ +function BaseMaterialModal({ open, onClose, onSelect }) { + const [search, setSearch] = useState(''); + const [filterName, setFilterName] = useState(''); + const [selected, setSelected] = useState(new Set()); + + useEffect(() => { + if (open) { + setSearch(''); + setFilterName(''); + setSelected(new Set()); + } + }, [open]); + + const materialNames = useMemo(() => [...new Set(BASE_MATERIALS.map(m => m.name))], []); + + const filtered = useMemo(() => { + return BASE_MATERIALS.filter(m => { + if (filterName && m.name !== filterName) return false; + if (search) { + const s = search.toLowerCase(); + return m.code.toLowerCase().includes(s) || m.name.toLowerCase().includes(s) || m.spec.toLowerCase().includes(s); + } + return true; + }); + }, [search, filterName]); + + function toggleSelect(code) { + setSelected(prev => { const s = new Set(prev); s.has(code) ? s.delete(code) : s.add(code); return s; }); + } + function toggleAll() { + setSelected(filtered.length > 0 && selected.size === filtered.length ? new Set() : new Set(filtered.map(m => m.code))); + } + + function handleSelect() { + const items = BASE_MATERIALS.filter(m => selected.has(m.code)); + onSelect(items); + onClose(); + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

기준자재정보

+ +
+
+ {/* 필터 */} +
+ + setSearch(e.target.value)} + className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 160}} /> + +
+ + {/* 테이블 */} +
+ + + + + + + + + + + + {filtered.map(m => ( + toggleSelect(m.code)}> + + + + + + + ))} + {filtered.length === 0 && ( + + )} + +
+ 0 && selected.size === filtered.length} onChange={toggleAll} /> + 자재코드자재명규격단위
+ toggleSelect(m.code)} /> + {m.code}{m.name}{m.spec}{m.unit}
검색 결과가 없습니다.
+
+ + {/* 버튼 */} +
+ + +
+
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 자재 등록/수정 모달 + ════════════════════════════════════════════════ */ +function MaterialModal({ open, onClose, onSaved, material }) { + const isEdit = !!material?.id; + const [form, setForm] = useState({ + company_name: '', material_code: '', material_name: '', + specification: '', unit: '', design_quantity: '', + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + if (material) { + setForm({ + company_name: material.company_name || '', + material_code: material.material_code || '', + material_name: material.material_name || '', + specification: material.specification || '', + unit: material.unit || '', + design_quantity: material.design_quantity ?? '', + }); + } else { + setForm({ + company_name: '', material_code: '', material_name: '', + specification: '', unit: '', design_quantity: '', + }); + } + setError(''); + } + }, [open, material]); + + const set = (k, v) => setForm(f => ({ ...f, [k]: v })); + + async function handleSubmit(e) { + e.preventDefault(); + setSaving(true); + setError(''); + try { + const body = { ...form }; + body.design_quantity = body.design_quantity ? parseFloat(body.design_quantity) : 0; + + if (isEdit) { + await api(`/materials/${material.id}`, { method: 'PUT', body: JSON.stringify(body) }); + } else { + await api('/materials', { method: 'POST', body: JSON.stringify(body) }); + } + onSaved(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!isEdit) return; + if (!confirm('정말 삭제하시겠습니까?')) return; + try { + await api(`/materials/${material.id}`, { method: 'DELETE' }); + onSaved(); + onClose(); + } catch (err) { + setError(err.message); + } + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

자재정보

+ +
+
+ {error && ( +
{error}
+ )} + + {/* 업체명 */} +
+ + set('company_name', e.target.value)} + required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+ + {/* 자재코드 + 자재명 */} +
+ + set('material_code', e.target.value)} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" style={{width: 140}} /> + + set('material_name', e.target.value)} + required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+ + {/* 규격 + 단위 */} +
+ + set('specification', e.target.value)} + className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> + + set('unit', e.target.value)} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" style={{width: 100}} /> +
+ + {/* 설계량 */} +
+ + set('design_quantity', e.target.value)} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" style={{width: 160}} /> +
+ + {/* 버튼 */} +
+ {isEdit && ( + + )} + + +
+
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 탭 1: 자재등록 + ════════════════════════════════════════════════ */ +function TabRegister() { + const [materials, setMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [pagination, setPagination] = useState({ current_page: 1, last_page: 1, total: 0, per_page: 15 }); + + const [search, setSearch] = useState(''); + const [filterCompany, setFilterCompany] = useState(''); + const [filterName, setFilterName] = useState(''); + const [perPage, setPerPage] = useState(15); + const [page, setPage] = useState(1); + + const [modalOpen, setModalOpen] = useState(false); + const [editMaterial, setEditMaterial] = useState(null); + const [selected, setSelected] = useState(new Set()); + const [baseModalOpen, setBaseModalOpen] = useState(false); + + const fetchMaterials = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + params.set('page', page); + params.set('per_page', perPage); + if (search) params.set('search', search); + if (filterCompany) params.set('company', filterCompany); + if (filterName) params.set('material_name', filterName); + const data = await api(`/materials?${params}`); + setMaterials(data.data); + setPagination({ + current_page: data.current_page, + last_page: data.last_page, + total: data.total, + per_page: data.per_page, + }); + } catch {} + setLoading(false); + }, [page, perPage, search, filterCompany, filterName]); + + useEffect(() => { fetchMaterials(); }, [fetchMaterials]); + + function handleSearch() { setPage(1); fetchMaterials(); } + function handleAdd() { setEditMaterial(null); setModalOpen(true); } + function handleEdit(mat) { setEditMaterial(mat); setModalOpen(true); } + + function randomMaterialData() { + const companies = ['(주)대림건설','(주)현대건설','(주)삼성물산','(주)GS건설','(주)포스코건설','(주)대우건설']; + const pick = arr => arr[Math.floor(Math.random() * arr.length)]; + const baseMat = pick(BASE_MATERIALS); + return { + company_name: pick(companies), + material_code: baseMat.code, + material_name: baseMat.name, + specification: baseMat.spec, + unit: baseMat.unit, + design_quantity: String(Math.floor(Math.random() * 500) + 10), + }; + } + + function handleQuickAdd() { + setEditMaterial(randomMaterialData()); + setModalOpen(true); + } + + async function handleBaseMaterialSelect(items) { + for (const item of items) { + try { + await api('/materials', { + method: 'POST', + body: JSON.stringify({ + company_name: '(미지정)', + material_code: item.code, + material_name: item.name, + specification: item.spec, + unit: item.unit, + design_quantity: 0, + }), + }); + } catch {} + } + fetchMaterials(); + } + + async function handleBulkDelete() { + if (selected.size === 0) return; + if (!confirm(`선택한 ${selected.size}건을 삭제하시겠습니까?`)) return; + for (const id of selected) { + try { await api(`/materials/${id}`, { method: 'DELETE' }); } catch {} + } + setSelected(new Set()); + fetchMaterials(); + } + + function toggleSelect(id) { + setSelected(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); + } + function toggleAll() { + setSelected(materials.length > 0 && selected.size === materials.length ? new Set() : new Set(materials.map(m => m.id))); + } + + const companies = useMemo(() => [...new Set(materials.map(m => m.company_name).filter(Boolean))], [materials]); + const matNames = useMemo(() => [...new Set(materials.map(m => m.material_name).filter(Boolean))], [materials]); + + const pageNumbers = useMemo(() => { + const pages = []; + const start = Math.max(1, pagination.current_page - 2); + const end = Math.min(pagination.last_page, pagination.current_page + 2); + for (let i = start; i <= end; i++) pages.push(i); + return pages; + }, [pagination]); + + return ( +
+ setModalOpen(false)} + onSaved={fetchMaterials} + material={editMaterial} + /> + setBaseModalOpen(false)} + onSelect={handleBaseMaterialSelect} + /> + + {/* 필터 바 */} +
+ + setSearch(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 160}} /> + +
+ + + {selected.size > 0 && ( + + )} + + + +
+
+ + {/* 자재명 서브필터 */} +
+ +
+ + {/* 테이블 */} +
+ + + + + + + + + + + + + + + {loading ? ( + + ) : materials.length === 0 ? ( + + ) : materials.map((mat, idx) => ( + handleEdit(mat)}> + + + + + + + + + + ))} + +
+ 0 && selected.size === materials.length} onChange={toggleAll} /> + 순번업체명자재코드자재명규격단위설계량
불러오는 중...
등록된 자재가 없습니다.
e.stopPropagation()}> + toggleSelect(mat.id)} /> + + {(pagination.current_page - 1) * pagination.per_page + idx + 1} + {mat.company_name}{mat.material_code || '-'}{mat.material_name}{mat.specification || '-'}{mat.unit || '-'}{Number(mat.design_quantity || 0).toLocaleString()}
+
+ + {/* 페이지네이션 */} +
+
총 {pagination.total}건
+
+ + {pageNumbers.map(p => ( + + ))} + + +
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 탭 2: 입고현황 + ════════════════════════════════════════════════ */ +function TabReceiving() { + const today = new Date().toISOString().slice(0, 10); + const [date, setDate] = useState(today); + const [materials, setMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [filterCompany, setFilterCompany] = useState(''); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const data = await api('/materials?per_page=100'); + setMaterials(data.data); + } catch {} + setLoading(false); + })(); + }, []); + + const companies = useMemo(() => [...new Set(materials.map(m => m.company_name).filter(Boolean))], [materials]); + + const filtered = useMemo(() => { + if (!filterCompany) return materials; + return materials.filter(m => m.company_name === filterCompany); + }, [materials, filterCompany]); + + return ( +
+
+ + setDate(e.target.value)} + className="border border-gray-300 rounded px-3 py-1.5 text-sm" /> + + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : filtered.map((mat, idx) => ( + + + + + + + + + + + + + ))} + +
순번업체명자재코드자재명규격단위설계량전일누계금일총계
불러오는 중...
등록된 자재가 없습니다.
{idx + 1}{mat.company_name}{mat.material_code || '-'}{mat.material_name}{mat.specification || '-'}{mat.unit || '-'}{Number(mat.design_quantity || 0).toLocaleString()}---
+
+
+ ); +} + /* ════════════════════════════════════════════════ 메인 컴포넌트 ════════════════════════════════════════════════ */ +const TABS = [ + { id: 'register', label: '자재등록' }, + { id: 'receiving', label: '입고현황' }, +]; + function App() { + const [activeTab, setActiveTab] = useState('register'); + return (
-
+ {/* 헤더 */} +
Home > 시공관리 > 자재관리
-

자재관리

-
-
-
- -

자재관리

-

준비 중입니다

+

자재관리

+ {/* 탭 */} +
+ {TABS.map(tab => ( + + ))}
+ {/* 탭 컨텐츠 */} +
+ {activeTab === 'register' && } + {activeTab === 'receiving' && } +
); diff --git a/routes/web.php b/routes/web.php index c95b1971..8306402b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,7 @@ use App\Http\Controllers\Juil\MeetingMinuteController; use App\Http\Controllers\Juil\PlanningController; use App\Http\Controllers\Juil\PmisEquipmentController; +use App\Http\Controllers\Juil\PmisMaterialController; use App\Http\Controllers\Juil\PmisWorkforceController; use App\Http\Controllers\Lab\StrategyController; use App\Http\Controllers\MenuController; @@ -1754,6 +1755,12 @@ Route::post('/equipments', [PmisEquipmentController::class, 'store']); Route::put('/equipments/{id}', [PmisEquipmentController::class, 'update']); Route::delete('/equipments/{id}', [PmisEquipmentController::class, 'destroy']); + + // 자재관리 CRUD + Route::get('/materials', [PmisMaterialController::class, 'list']); + Route::post('/materials', [PmisMaterialController::class, 'store']); + Route::put('/materials/{id}', [PmisMaterialController::class, 'update']); + Route::delete('/materials/{id}', [PmisMaterialController::class, 'destroy']); }); // 공사현장 사진대지