From babccc0f23aec289a8be1be71e83da6d4ac7e71b 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 14:02:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[pmis]=20=EC=9D=B8=EC=9B=90=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=A4=EC=A0=9C=20CRUD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PmisWorkforceController: 인원/직종 CRUD API - PmisConstructionWorker, PmisJobType 모델 추가 - 인원등록 탭: 실제 DB CRUD, 페이지네이션, 필터, 모달 - 직종 44개 시드 데이터 등록 - API 라우트 추가 (workers, job-types) --- .../Juil/PmisWorkforceController.php | 133 ++++ app/Models/Juil/PmisConstructionWorker.php | 44 ++ app/Models/Juil/PmisJobType.php | 31 + resources/views/juil/pmis-workforce.blade.php | 693 +++++++++++------- routes/web.php | 11 + 5 files changed, 654 insertions(+), 258 deletions(-) create mode 100644 app/Http/Controllers/Juil/PmisWorkforceController.php create mode 100644 app/Models/Juil/PmisConstructionWorker.php create mode 100644 app/Models/Juil/PmisJobType.php diff --git a/app/Http/Controllers/Juil/PmisWorkforceController.php b/app/Http/Controllers/Juil/PmisWorkforceController.php new file mode 100644 index 00000000..10f5b068 --- /dev/null +++ b/app/Http/Controllers/Juil/PmisWorkforceController.php @@ -0,0 +1,133 @@ +tenantId()) + ->with('jobType:id,name') + ->orderByDesc('id'); + + if ($request->filled('company')) { + $query->where('company_name', 'like', '%' . $request->company . '%'); + } + if ($request->filled('trade')) { + $query->where('trade_name', $request->trade); + } + if ($request->filled('job_type_id')) { + $query->where('job_type_id', $request->job_type_id); + } + if ($request->filled('search')) { + $s = $request->search; + $query->where(function ($q) use ($s) { + $q->where('name', 'like', "%{$s}%") + ->orWhere('phone', 'like', "%{$s}%"); + }); + } + + $perPage = $request->integer('per_page', 15); + $workers = $query->paginate($perPage); + + return response()->json($workers); + } + + public function workerStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'company_name' => 'required|string|max:200', + 'trade_name' => 'required|string|max:100', + 'job_type_id' => 'nullable|exists:pmis_job_types,id', + 'name' => 'required|string|max:50', + 'phone' => 'nullable|string|max:20', + 'birth_date' => 'nullable|string|max:6', + 'ssn_gender' => 'nullable|string|max:1', + 'wage' => 'nullable|integer|min:0', + 'blood_type' => 'nullable|string|max:5', + 'remark' => 'nullable|string|max:500', + ]); + + $validated['tenant_id'] = $this->tenantId(); + $validated['wage'] = $validated['wage'] ?? 0; + + $worker = PmisConstructionWorker::create($validated); + $worker->load('jobType:id,name'); + + return response()->json($worker, 201); + } + + public function workerUpdate(Request $request, int $id): JsonResponse + { + $worker = PmisConstructionWorker::tenant($this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'company_name' => 'sometimes|required|string|max:200', + 'trade_name' => 'sometimes|required|string|max:100', + 'job_type_id' => 'nullable|exists:pmis_job_types,id', + 'name' => 'sometimes|required|string|max:50', + 'phone' => 'nullable|string|max:20', + 'birth_date' => 'nullable|string|max:6', + 'ssn_gender' => 'nullable|string|max:1', + 'wage' => 'nullable|integer|min:0', + 'blood_type' => 'nullable|string|max:5', + 'remark' => 'nullable|string|max:500', + ]); + + $worker->update($validated); + $worker->load('jobType:id,name'); + + return response()->json($worker); + } + + public function workerDestroy(int $id): JsonResponse + { + $worker = PmisConstructionWorker::tenant($this->tenantId())->findOrFail($id); + $worker->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ── 직종 CRUD ── + + public function jobTypeList(): JsonResponse + { + $types = PmisJobType::tenant($this->tenantId()) + ->where('is_active', true) + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'name', 'sort_order']); + + return response()->json($types); + } + + public function jobTypeStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + ]); + + $maxSort = PmisJobType::tenant($this->tenantId())->max('sort_order') ?? 0; + + $type = PmisJobType::create([ + 'tenant_id' => $this->tenantId(), + 'name' => $validated['name'], + 'sort_order' => $maxSort + 1, + ]); + + return response()->json($type, 201); + } +} diff --git a/app/Models/Juil/PmisConstructionWorker.php b/app/Models/Juil/PmisConstructionWorker.php new file mode 100644 index 00000000..e9068f75 --- /dev/null +++ b/app/Models/Juil/PmisConstructionWorker.php @@ -0,0 +1,44 @@ + 'integer', + 'options' => 'array', + ]; + + public function jobType(): BelongsTo + { + return $this->belongsTo(PmisJobType::class, 'job_type_id'); + } + + public function scopeTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/app/Models/Juil/PmisJobType.php b/app/Models/Juil/PmisJobType.php new file mode 100644 index 00000000..3cd8d2fa --- /dev/null +++ b/app/Models/Juil/PmisJobType.php @@ -0,0 +1,31 @@ + 'boolean', + 'options' => 'array', + ]; + + public function scopeTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/resources/views/juil/pmis-workforce.blade.php b/resources/views/juil/pmis-workforce.blade.php index e2c338f9..f23c0b6e 100644 --- a/resources/views/juil/pmis-workforce.blade.php +++ b/resources/views/juil/pmis-workforce.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 사이드바 ════════════════════════════════════════════════ */ @@ -85,83 +105,369 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg } /* ════════════════════════════════════════════════ - 샘플 데이터 + 인원등록 모달 ════════════════════════════════════════════════ */ -const SAMPLE_WORKERS = [ - { id: 18, company: '(주)주일기업', trade: '방화셔터공사', jobType: '현장소장', name: '김철수', phone: '010-5292-3623', ssn: '630205-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 17, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김영식', phone: '010-6766-4600', ssn: '630212-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 16, company: '(주)주일기업', trade: '방화셔터공사', jobType: '철거/잡사', name: '김강사', phone: '010-5557-9522', ssn: '940713-1XXXXXX', blood: 'M', remark: 'Y', wage: 180000 }, - { id: 15, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '홍길동', phone: '010-4209-3618', ssn: '630320-1XXXXXX', blood: 'M', remark: 'N', wage: 170000 }, - { id: 14, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '박민수', phone: '010-6396-8603', ssn: '860902-2XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 13, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '이준호', phone: '010-6364-8466', ssn: '730426-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 }, - { id: 12, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '정대호', phone: '010-8559-0517', ssn: '720219-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 }, - { id: 11, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '오상호', phone: '010-8971-8806', ssn: '651111-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 10, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '한재영', phone: '010-6261-9738', ssn: '630522-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 9, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '윤성민', phone: '010-6261-8745', ssn: '681216-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 8, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '최동환', phone: '010-3630-1779', ssn: '721212-1XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 7, company: '(주)주일기업', trade: '방화셔터공사', jobType: '철거/잡사', name: '김상준', phone: '010-9272-2342', ssn: '700626-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 }, - { id: 6, company: '(주)주일기업', trade: '방화셔터공사', jobType: '취기검사시', name: '박현석', phone: '010-1321-1779', ssn: '010413-3XXXXXX', blood: 'M', remark: 'N', wage: 200000 }, - { id: 5, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '이종선', phone: '010-4560-5697', ssn: '681029-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 }, - { id: 4, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김상우', phone: '010-5330-0941', ssn: '680609-1XXXXXX', blood: 'M', remark: 'N', wage: 180000 }, -]; +function WorkerModal({ open, onClose, onSaved, worker, jobTypes }) { + const isEdit = !!worker?.id; + const [form, setForm] = useState({ + company_name: '', trade_name: '', job_type_id: '', name: '', + phone: '', birth_date: '', ssn_gender: '', wage: '', blood_type: '', remark: '', + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [showJobTypeInput, setShowJobTypeInput] = useState(false); + const [newJobTypeName, setNewJobTypeName] = useState(''); -const SAMPLE_ATTENDANCE = [ - { id: 1, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김철수', status: '현장소장', task: 'A동 1층 셔터시공', hours: '', wage: 180000 }, - { id: 2, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '김영식', status: '현장소장', task: 'A동 3층 셔터시공', hours: '', wage: 200000 }, - { id: 3, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '홍길동', status: '현장소장', task: '', hours: '', wage: 170000 }, - { id: 4, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '박민수', status: '현장소장', task: '취기검사시', hours: '', wage: 200000 }, - { id: 5, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '이준호', status: '현장소장', task: '', hours: '', wage: 170000 }, - { id: 6, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '정대호', status: '현장소장', task: '', hours: '', wage: 200000 }, - { id: 7, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '오상호', status: '현장소장', task: '', hours: '', wage: 170000 }, - { id: 8, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '한재영', status: '현장소장', task: '취기검사시', hours: '', wage: 200000 }, - { id: 9, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '윤성민', status: '현장소장', task: 'A동 1층', hours: '', wage: 175000 }, - { id: 10, company: '(주)주일기업', trade: '방화셔터공사', jobType: '방화셔터시공', name: '최동환', status: '현장소장', task: '', hours: '', wage: 180000 }, -]; + useEffect(() => { + if (open) { + if (worker) { + setForm({ + company_name: worker.company_name || '', + trade_name: worker.trade_name || '', + job_type_id: worker.job_type_id || '', + name: worker.name || '', + phone: worker.phone || '', + birth_date: worker.birth_date || '', + ssn_gender: worker.ssn_gender || '', + wage: worker.wage || '', + blood_type: worker.blood_type || '', + remark: worker.remark || '', + }); + } else { + setForm({ + company_name: '', trade_name: '', job_type_id: '', name: '', + phone: '', birth_date: '', ssn_gender: '', wage: '', blood_type: '', remark: '', + }); + } + setError(''); + setShowJobTypeInput(false); + setNewJobTypeName(''); + } + }, [open, worker]); + + const set = (k, v) => setForm(f => ({ ...f, [k]: v })); + + async function handleSubmit(e) { + e.preventDefault(); + setSaving(true); + setError(''); + try { + const body = { ...form, wage: form.wage ? parseInt(form.wage) : 0 }; + if (!body.job_type_id) delete body.job_type_id; + if (isEdit) { + await api(`/workers/${worker.id}`, { method: 'PUT', body: JSON.stringify(body) }); + } else { + await api('/workers', { method: 'POST', body: JSON.stringify(body) }); + } + onSaved(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function handleAddJobType() { + if (!newJobTypeName.trim()) return; + try { + const created = await api('/job-types', { + method: 'POST', + body: JSON.stringify({ name: newJobTypeName.trim() }), + }); + set('job_type_id', created.id); + setShowJobTypeInput(false); + setNewJobTypeName(''); + onSaved(); // refresh job types + } catch (err) { + setError(err.message); + } + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

+ {isEdit ? '인원 수정' : '인원 등록'} +

+ +
+
+ {error && ( +
{error}
+ )} +
+
+ + set('company_name', e.target.value)} + required className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ + set('trade_name', e.target.value)} + required className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+
+ +
+ + +
+ {showJobTypeInput && ( +
+ setNewJobTypeName(e.target.value)} + placeholder="새 직종명 입력" className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm" /> + + +
+ )} +
+
+
+ + set('name', e.target.value)} + required className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ + set('phone', e.target.value)} + placeholder="010-0000-0000" + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+
+
+ + set('birth_date', e.target.value)} + maxLength={6} placeholder="YYMMDD" + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ + +
+
+ + +
+
+
+ + set('wage', e.target.value)} + min="0" step="1000" placeholder="0" + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ +