From 1bd5ba817a46f467a8e23a503c7b387a37a8b08c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 15:59:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[pmis]=20=EA=B3=B5=EC=82=AC=EB=9F=89?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20CRUD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pmis_work_volumes 마이그레이션/모델/컨트롤러 생성 - 공사량 탭 (등록, 수정, 삭제, 일보적용 토글) - 실적현황 탭 (전일누계, 금일, 총계 표시) - 공사량정보 모달 (공종, 세부공종, 단위, 설계량) - 번개 아이콘 랜덤 데이터 추가 기능 --- .../Juil/PmisWorkVolumeController.php | 79 +++ app/Models/Juil/PmisWorkVolume.php | 34 ++ ..._170000_create_pmis_work_volumes_table.php | 29 + .../views/juil/pmis-work-volume.blade.php | 503 +++++++++++++++++- routes/web.php | 7 + 5 files changed, 644 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Juil/PmisWorkVolumeController.php create mode 100644 app/Models/Juil/PmisWorkVolume.php create mode 100644 database/migrations/2026_03_12_170000_create_pmis_work_volumes_table.php diff --git a/app/Http/Controllers/Juil/PmisWorkVolumeController.php b/app/Http/Controllers/Juil/PmisWorkVolumeController.php new file mode 100644 index 00000000..c2914667 --- /dev/null +++ b/app/Http/Controllers/Juil/PmisWorkVolumeController.php @@ -0,0 +1,79 @@ +tenantId()) + ->orderByDesc('id'); + + if ($request->filled('search')) { + $s = $request->search; + $query->where(function ($q) use ($s) { + $q->where('work_type', 'like', "%{$s}%") + ->orWhere('sub_work_type', 'like', "%{$s}%"); + }); + } + + $perPage = $request->integer('per_page', 15); + $workVolumes = $query->paginate($perPage); + + return response()->json($workVolumes); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'work_type' => 'required|string|max:200', + 'sub_work_type' => 'required|string|max:200', + 'unit' => 'required|string|max:50', + 'design_quantity' => 'nullable|numeric|min:0', + 'daily_report_applied' => 'nullable|boolean', + ]); + + $validated['tenant_id'] = $this->tenantId(); + $validated['design_quantity'] = $validated['design_quantity'] ?? 0; + $validated['daily_report_applied'] = $validated['daily_report_applied'] ?? false; + + $workVolume = PmisWorkVolume::create($validated); + + return response()->json($workVolume, 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $workVolume = PmisWorkVolume::tenant($this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'work_type' => 'sometimes|required|string|max:200', + 'sub_work_type' => 'sometimes|required|string|max:200', + 'unit' => 'sometimes|required|string|max:50', + 'design_quantity' => 'nullable|numeric|min:0', + 'daily_report_applied' => 'nullable|boolean', + ]); + + $workVolume->update($validated); + + return response()->json($workVolume); + } + + public function destroy(int $id): JsonResponse + { + $workVolume = PmisWorkVolume::tenant($this->tenantId())->findOrFail($id); + $workVolume->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } +} diff --git a/app/Models/Juil/PmisWorkVolume.php b/app/Models/Juil/PmisWorkVolume.php new file mode 100644 index 00000000..4a5b99d5 --- /dev/null +++ b/app/Models/Juil/PmisWorkVolume.php @@ -0,0 +1,34 @@ + 'decimal:2', + 'daily_report_applied' => 'boolean', + 'options' => 'array', + ]; + + public function scopeTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/database/migrations/2026_03_12_170000_create_pmis_work_volumes_table.php b/database/migrations/2026_03_12_170000_create_pmis_work_volumes_table.php new file mode 100644 index 00000000..8ea179cb --- /dev/null +++ b/database/migrations/2026_03_12_170000_create_pmis_work_volumes_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->string('work_type', 200)->comment('공종'); + $table->string('sub_work_type', 200)->comment('세부공종'); + $table->string('unit', 50)->comment('단위'); + $table->decimal('design_quantity', 14, 2)->default(0)->comment('설계량'); + $table->boolean('daily_report_applied')->default(false)->comment('일보적용'); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_work_volumes'); + } +}; diff --git a/resources/views/juil/pmis-work-volume.blade.php b/resources/views/juil/pmis-work-volume.blade.php index ad8bff16..fd8e2e3b 100644 --- a/resources/views/juil/pmis-work-volume.blade.php +++ b/resources/views/juil/pmis-work-volume.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,495 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg ); } +/* ════════════════════════════════════════════════ + 공사량정보 모달 (추가/수정) + ════════════════════════════════════════════════ */ +function WorkVolumeModal({ open, onClose, onSaved, workVolume }) { + const isEdit = !!workVolume?.id; + const [form, setForm] = useState({ + work_type: '', sub_work_type: '', unit: '', design_quantity: '', + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + if (workVolume) { + setForm({ + work_type: workVolume.work_type || '', + sub_work_type: workVolume.sub_work_type || '', + unit: workVolume.unit || '', + design_quantity: workVolume.design_quantity ?? '', + }); + } else { + setForm({ work_type: '', sub_work_type: '', unit: '', design_quantity: '' }); + } + setError(''); + } + }, [open, workVolume]); + + 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(`/work-volumes/${workVolume.id}`, { method: 'PUT', body: JSON.stringify(body) }); + } else { + await api('/work-volumes', { 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(`/work-volumes/${workVolume.id}`, { method: 'DELETE' }); + onSaved(); + onClose(); + } catch (err) { + setError(err.message); + } + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

공사량정보

+ +
+
+ {error && ( +
{error}
+ )} + + {/* 공종 */} +
+ + set('work_type', 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('sub_work_type', 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('unit', e.target.value)} + required 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: 120}} /> +
+ + {/* 설계량 */} +
+ + 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 TabWorkVolume() { + const [items, setItems] = 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 [perPage, setPerPage] = useState(15); + const [page, setPage] = useState(1); + + const [modalOpen, setModalOpen] = useState(false); + const [editItem, setEditItem] = useState(null); + const [selected, setSelected] = useState(new Set()); + + const fetchItems = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + params.set('page', page); + params.set('per_page', perPage); + if (search) params.set('search', search); + const data = await api(`/work-volumes?${params}`); + setItems(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]); + + useEffect(() => { fetchItems(); }, [fetchItems]); + + function handleSearch() { setPage(1); fetchItems(); } + function handleAdd() { setEditItem(null); setModalOpen(true); } + function handleEdit(item) { setEditItem(item); setModalOpen(true); } + + /* 번개 랜덤 데이터 */ + const WORK_TYPES = [ + { work_type: '토공사', sub_work_type: '터파기', unit: 'm3' }, + { work_type: '토공사', sub_work_type: '되메우기', unit: 'm3' }, + { work_type: '토공사', sub_work_type: '잔토처리', unit: 'm3' }, + { work_type: '철근콘크리트공사', sub_work_type: '거푸집', unit: 'm2' }, + { work_type: '철근콘크리트공사', sub_work_type: '철근가공조립', unit: 'ton' }, + { work_type: '철근콘크리트공사', sub_work_type: '콘크리트타설', unit: 'm3' }, + { work_type: '철골공사', sub_work_type: '철골제작', unit: 'ton' }, + { work_type: '철골공사', sub_work_type: '철골설치', unit: 'ton' }, + { work_type: '방수공사', sub_work_type: '도막방수', unit: 'm2' }, + { work_type: '방수공사', sub_work_type: '시트방수', unit: 'm2' }, + { work_type: '미장공사', sub_work_type: '시멘트모르타르', unit: 'm2' }, + { work_type: '미장공사', sub_work_type: '자기질타일', unit: 'm2' }, + { work_type: '도장공사', sub_work_type: '수성페인트', unit: 'm2' }, + { work_type: '도장공사', sub_work_type: '유성페인트', unit: 'm2' }, + { work_type: '조적공사', sub_work_type: '시멘트벽돌쌓기', unit: 'm2' }, + { work_type: '조적공사', sub_work_type: '블록쌓기', unit: 'm2' }, + { work_type: '단열공사', sub_work_type: '압출법보온판', unit: 'm2' }, + { work_type: '단열공사', sub_work_type: '비드법보온판', unit: 'm2' }, + { work_type: '창호공사', sub_work_type: '알루미늄창호', unit: '조' }, + { work_type: '창호공사', sub_work_type: 'PVC창호', unit: '조' }, + { work_type: '지붕공사', sub_work_type: '금속기와', unit: 'm2' }, + { work_type: '지붕공사', sub_work_type: '아스팔트슁글', unit: 'm2' }, + { work_type: '설비공사', sub_work_type: '급수배관', unit: 'm' }, + { work_type: '설비공사', sub_work_type: '배수배관', unit: 'm' }, + { work_type: '전기공사', sub_work_type: '전선관배선', unit: 'm' }, + { work_type: '전기공사', sub_work_type: '조명설치', unit: '개' }, + ]; + + function randomWorkVolumeData() { + const pick = arr => arr[Math.floor(Math.random() * arr.length)]; + const base = pick(WORK_TYPES); + return { + work_type: base.work_type, + sub_work_type: base.sub_work_type, + unit: base.unit, + design_quantity: String(Math.floor(Math.random() * 5000) + 50), + }; + } + + function handleQuickAdd() { + setEditItem(randomWorkVolumeData()); + setModalOpen(true); + } + + async function handleBulkDelete() { + if (selected.size === 0) return; + if (!confirm(`선택한 ${selected.size}건을 삭제하시겠습니까?`)) return; + for (const id of selected) { + try { await api(`/work-volumes/${id}`, { method: 'DELETE' }); } catch {} + } + setSelected(new Set()); + fetchItems(); + } + + /* 일보적용 토글 저장 */ + async function handleToggleDailyReport(item) { + try { + await api(`/work-volumes/${item.id}`, { + method: 'PUT', + body: JSON.stringify({ daily_report_applied: !item.daily_report_applied }), + }); + fetchItems(); + } catch {} + } + + /* 일괄 저장 (일보적용 변경분) */ + async function handleSaveAll() { + alert('일보적용 상태가 저장되었습니다.'); + } + + function toggleSelect(id) { + setSelected(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); + } + function toggleAll() { + setSelected(items.length > 0 && selected.size === items.length ? new Set() : new Set(items.map(m => m.id))); + } + + 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={fetchItems} + workVolume={editItem} + /> + + {/* 필터 바 */} +
+ + 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 ? ( + + ) : items.length === 0 ? ( + + ) : items.map((item, idx) => ( + handleEdit(item)}> + + + + + + + + + ))} + +
+ 0 && selected.size === items.length} onChange={toggleAll} /> + 순번공종세부공종단위설계량일보적용
불러오는 중...
등록된 공사량이 없습니다.
e.stopPropagation()}> + toggleSelect(item.id)} /> + + {(pagination.current_page - 1) * pagination.per_page + idx + 1} + {item.work_type}{item.sub_work_type}{item.unit}{Number(item.design_quantity || 0).toLocaleString()} e.stopPropagation()}> + handleToggleDailyReport(item)} /> +
+
+ + {/* 페이지네이션 */} +
+
총 {pagination.total}건
+
+ + {pageNumbers.map(p => ( + + ))} + + +
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 탭 2: 실적현황 + ════════════════════════════════════════════════ */ +function TabPerformance() { + const today = new Date().toISOString().slice(0, 10); + const [date, setDate] = useState(today); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [filterCompany, setFilterCompany] = useState(''); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const data = await api('/work-volumes?per_page=100'); + setItems(data.data); + } catch {} + setLoading(false); + })(); + }, []); + + return ( +
+
+ + setDate(e.target.value)} + className="border border-gray-300 rounded px-3 py-1.5 text-sm" /> + + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + {loading ? ( + + ) : items.length === 0 ? ( + + ) : items.map((item, idx) => ( + + + + + + + + + + + + ))} + +
순번업체명공종세부공종단위설계량전일누계금일총계
불러오는 중...
등록된 공사량이 없습니다.
{idx + 1}-{item.work_type}{item.sub_work_type}{item.unit}{Number(item.design_quantity || 0).toLocaleString()}---
+
+
+ ); +} + /* ════════════════════════════════════════════════ 메인 컴포넌트 ════════════════════════════════════════════════ */ +const TABS = [ + { id: 'work-volume', label: '공사량' }, + { id: 'performance', label: '실적현황' }, +]; + function App() { + const [activeTab, setActiveTab] = useState('work-volume'); + return (
-
+ {/* 헤더 */} +
Home > 시공관리 > 공사량관리
-

공사량관리

-
-
-
- -

공사량관리

-

준비 중입니다

+

공사량관리

+ {/* 탭 */} +
+ {TABS.map(tab => ( + + ))}
+ {/* 탭 컨텐츠 */} +
+ {activeTab === 'work-volume' && } + {activeTab === 'performance' && } +
); diff --git a/routes/web.php b/routes/web.php index 8306402b..23c023ef 100644 --- a/routes/web.php +++ b/routes/web.php @@ -49,6 +49,7 @@ use App\Http\Controllers\Juil\PmisEquipmentController; use App\Http\Controllers\Juil\PmisMaterialController; use App\Http\Controllers\Juil\PmisWorkforceController; +use App\Http\Controllers\Juil\PmisWorkVolumeController; use App\Http\Controllers\Lab\StrategyController; use App\Http\Controllers\MenuController; use App\Http\Controllers\MenuSyncController; @@ -1761,6 +1762,12 @@ Route::post('/materials', [PmisMaterialController::class, 'store']); Route::put('/materials/{id}', [PmisMaterialController::class, 'update']); Route::delete('/materials/{id}', [PmisMaterialController::class, 'destroy']); + + // 공사량관리 CRUD + Route::get('/work-volumes', [PmisWorkVolumeController::class, 'list']); + Route::post('/work-volumes', [PmisWorkVolumeController::class, 'store']); + Route::put('/work-volumes/{id}', [PmisWorkVolumeController::class, 'update']); + Route::delete('/work-volumes/{id}', [PmisWorkVolumeController::class, 'destroy']); }); // 공사현장 사진대지