feat: [pmis] 인원관리 실제 CRUD 구현
- PmisWorkforceController: 인원/직종 CRUD API - PmisConstructionWorker, PmisJobType 모델 추가 - 인원등록 탭: 실제 DB CRUD, 페이지네이션, 필터, 모달 - 직종 44개 시드 데이터 등록 - API 라우트 추가 (workers, job-types)
This commit is contained in:
133
app/Http/Controllers/Juil/PmisWorkforceController.php
Normal file
133
app/Http/Controllers/Juil/PmisWorkforceController.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Juil;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Juil\PmisConstructionWorker;
|
||||
use App\Models\Juil\PmisJobType;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PmisWorkforceController extends Controller
|
||||
{
|
||||
private function tenantId(): int
|
||||
{
|
||||
return (int) session('current_tenant_id', 1);
|
||||
}
|
||||
|
||||
// ── 인원 CRUD ──
|
||||
|
||||
public function workerList(Request $request): JsonResponse
|
||||
{
|
||||
$query = PmisConstructionWorker::tenant($this->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);
|
||||
}
|
||||
}
|
||||
44
app/Models/Juil/PmisConstructionWorker.php
Normal file
44
app/Models/Juil/PmisConstructionWorker.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Juil;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PmisConstructionWorker extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'pmis_construction_workers';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'company_name',
|
||||
'trade_name',
|
||||
'job_type_id',
|
||||
'name',
|
||||
'phone',
|
||||
'birth_date',
|
||||
'ssn_gender',
|
||||
'wage',
|
||||
'blood_type',
|
||||
'remark',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'wage' => '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);
|
||||
}
|
||||
}
|
||||
31
app/Models/Juil/PmisJobType.php
Normal file
31
app/Models/Juil/PmisJobType.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Juil;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PmisJobType extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'pmis_job_types';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function scopeTenant($query, $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-bold text-gray-800">
|
||||
{isEdit ? '인원 수정' : '인원 등록'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<i className="ri-close-line text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">{error}</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">업체명 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.company_name} onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">공종 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.trade_name} onChange={e => 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">직종 <span className="text-red-500">*</span></label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={form.job_type_id} onChange={e => set('job_type_id', 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">
|
||||
<option value="">직종 선택</option>
|
||||
{jobTypes.map(jt => (
|
||||
<option key={jt.id} value={jt.id}>{jt.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => setShowJobTypeInput(!showJobTypeInput)}
|
||||
className="shrink-0 bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-lg text-sm border border-gray-300">
|
||||
<i className="ri-add-line"></i> 직종추가
|
||||
</button>
|
||||
</div>
|
||||
{showJobTypeInput && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input type="text" value={newJobTypeName} onChange={e => setNewJobTypeName(e.target.value)}
|
||||
placeholder="새 직종명 입력" className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
<button type="button" onClick={handleAddJobType}
|
||||
className="bg-blue-600 text-white px-3 py-2 rounded-lg text-sm hover:bg-blue-700">추가</button>
|
||||
<button type="button" onClick={() => { setShowJobTypeInput(false); setNewJobTypeName(''); }}
|
||||
className="text-gray-500 hover:text-gray-700 text-sm">취소</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">성명 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.name} onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">전화번호</label>
|
||||
<input type="text" value={form.phone} onChange={e => 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">생년월일 (YYMMDD)</label>
|
||||
<input type="text" value={form.birth_date} onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">성별구분</label>
|
||||
<select value={form.ssn_gender} onChange={e => set('ssn_gender', e.target.value)}
|
||||
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">
|
||||
<option value="">선택</option>
|
||||
<option value="1">1 (남, ~1999)</option>
|
||||
<option value="2">2 (여, ~1999)</option>
|
||||
<option value="3">3 (남, 2000~)</option>
|
||||
<option value="4">4 (여, 2000~)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">혈액형</label>
|
||||
<select value={form.blood_type} onChange={e => set('blood_type', e.target.value)}
|
||||
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">
|
||||
<option value="">선택</option>
|
||||
<option value="A">A</option>
|
||||
<option value="B">B</option>
|
||||
<option value="O">O</option>
|
||||
<option value="AB">AB</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">노임단가 (원)</label>
|
||||
<input type="number" value={form.wage} onChange={e => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
||||
<textarea value={form.remark} onChange={e => set('remark', e.target.value)} rows={2}
|
||||
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 resize-none" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg">취소</button>
|
||||
<button type="submit" disabled={saving}
|
||||
className="px-6 py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50">
|
||||
{saving ? '저장 중...' : (isEdit ? '수정' : '등록')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
탭 1: 인원등록
|
||||
탭 1: 인원등록 (실제 CRUD)
|
||||
════════════════════════════════════════════════ */
|
||||
function TabRegister() {
|
||||
const [workers, setWorkers] = useState([]);
|
||||
const [jobTypes, setJobTypes] = 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 [company, setCompany] = useState('');
|
||||
const [trade, setTrade] = useState('');
|
||||
const [jobType, setJobType] = useState('');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const [filterJobType, setFilterJobType] = useState('');
|
||||
const [perPage, setPerPage] = useState(15);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return SAMPLE_WORKERS.filter(w => {
|
||||
if (search && !w.name.includes(search) && !w.jobType.includes(search)) return false;
|
||||
if (company && w.company !== company) return false;
|
||||
if (trade && w.trade !== trade) return false;
|
||||
if (jobType && w.jobType !== jobType) return false;
|
||||
return true;
|
||||
// 모달
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editWorker, setEditWorker] = useState(null);
|
||||
|
||||
// 선택
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
|
||||
const fetchJobTypes = useCallback(async () => {
|
||||
try {
|
||||
const data = await api('/job-types');
|
||||
setJobTypes(data);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const fetchWorkers = 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 (filterJobType) params.set('job_type_id', filterJobType);
|
||||
const data = await api(`/workers?${params}`);
|
||||
setWorkers(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, filterJobType]);
|
||||
|
||||
useEffect(() => { fetchJobTypes(); }, []);
|
||||
useEffect(() => { fetchWorkers(); }, [fetchWorkers]);
|
||||
|
||||
function handleSearch() {
|
||||
setPage(1);
|
||||
fetchWorkers();
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
setEditWorker(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleEdit(w) {
|
||||
setEditWorker(w);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api(`/workers/${id}`, { method: 'DELETE' });
|
||||
fetchWorkers();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkDelete() {
|
||||
if (selected.size === 0) return;
|
||||
if (!confirm(`선택한 ${selected.size}명을 삭제하시겠습니까?`)) return;
|
||||
for (const id of selected) {
|
||||
try { await api(`/workers/${id}`, { method: 'DELETE' }); } catch {}
|
||||
}
|
||||
setSelected(new Set());
|
||||
fetchWorkers();
|
||||
}
|
||||
|
||||
function handleSaved() {
|
||||
fetchWorkers();
|
||||
fetchJobTypes();
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
setSelected(prev => {
|
||||
const s = new Set(prev);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
return s;
|
||||
});
|
||||
}, [search, company, trade, jobType]);
|
||||
}
|
||||
|
||||
const companies = [...new Set(SAMPLE_WORKERS.map(w => w.company))];
|
||||
const trades = [...new Set(SAMPLE_WORKERS.map(w => w.trade))];
|
||||
const jobTypes = [...new Set(SAMPLE_WORKERS.map(w => w.jobType))];
|
||||
function toggleAll() {
|
||||
if (selected.size === workers.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(workers.map(w => w.id)));
|
||||
}
|
||||
}
|
||||
|
||||
// 업체 목록 (현재 페이지 근로자 기준 + 빈 값)
|
||||
const companies = useMemo(() => [...new Set(workers.map(w => w.company_name).filter(Boolean))], [workers]);
|
||||
|
||||
function formatSsn(birthDate, gender) {
|
||||
if (!birthDate) return '-';
|
||||
return `${birthDate}-${gender || 'X'}XXXXXX`;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<WorkerModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={handleSaved}
|
||||
worker={editWorker}
|
||||
jobTypes={jobTypes}
|
||||
/>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<select value={company} onChange={e => setCompany(e.target.value)}
|
||||
<select value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option value="">(주)주일/기업 · 방화셔터공사 ▾</option>
|
||||
<option value="">전체 업체</option>
|
||||
{companies.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select value={trade} onChange={e => setTrade(e.target.value)}
|
||||
<select value={filterJobType} onChange={e => { setFilterJobType(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option value="">직종선택</option>
|
||||
{trades.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
{jobTypes.map(jt => <option key={jt.id} value={jt.id}>{jt.name}</option>)}
|
||||
</select>
|
||||
<input type="text" placeholder="근로자 또는 현장소" value={search} onChange={e => setSearch(e.target.value)}
|
||||
<input type="text" placeholder="이름 또는 연락처 검색" value={search}
|
||||
onChange={e => 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: 180}} />
|
||||
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<button className="bg-green-600 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700">부재자기입</button>
|
||||
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">출력</button>
|
||||
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">자재등</button>
|
||||
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">다운로드</button>
|
||||
<button onClick={handleSearch} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<button onClick={handleAdd} className="bg-green-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-green-700">
|
||||
<i className="ri-add-line mr-1"></i>추가
|
||||
</button>
|
||||
{selected.size > 0 && (
|
||||
<button onClick={handleBulkDelete} className="bg-red-500 text-white px-3 py-1.5 rounded text-sm hover:bg-red-600">
|
||||
<i className="ri-delete-bin-line mr-1"></i>선택 삭제 ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
@@ -169,7 +475,9 @@ className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 18
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap"><input type="checkbox" /></th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">
|
||||
<input type="checkbox" checked={workers.length > 0 && selected.size === workers.length} onChange={toggleAll} />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">순번</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">업체</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">공종</th>
|
||||
@@ -178,40 +486,64 @@ className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 18
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">연락처</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">주민등록번호</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold text-gray-600 whitespace-nowrap">혈액형</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold text-gray-600 whitespace-nowrap">특이사항</th>
|
||||
<th className="px-3 py-2.5 text-right font-semibold text-gray-600 whitespace-nowrap">노임단가</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold text-gray-600 whitespace-nowrap">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(w => (
|
||||
{loading ? (
|
||||
<tr><td colSpan={11} className="text-center py-8 text-gray-400">불러오는 중...</td></tr>
|
||||
) : workers.length === 0 ? (
|
||||
<tr><td colSpan={11} className="text-center py-8 text-gray-400">등록된 인원이 없습니다.</td></tr>
|
||||
) : workers.map((w, idx) => (
|
||||
<tr key={w.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
|
||||
<td className="px-3 py-2"><input type="checkbox" /></td>
|
||||
<td className="px-3 py-2 text-gray-500">{w.id}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.company}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.trade}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.jobType}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{w.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{w.phone}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{w.ssn}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-600">{w.blood}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${w.remark === 'Y' ? 'bg-red-100 text-red-700' : 'text-gray-400'}`}>{w.remark}</span>
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={selected.has(w.id)} onChange={() => toggleSelect(w.id)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-500">
|
||||
{(pagination.current_page - 1) * pagination.per_page + idx + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.company_name}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.trade_name}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.job_type?.name || '-'}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800 cursor-pointer hover:text-blue-600" onClick={() => handleEdit(w)}>
|
||||
{w.name}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">{w.phone || '-'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{formatSsn(w.birth_date, w.ssn_gender)}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-600">{w.blood_type || '-'}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-gray-800">{(w.wage || 0).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button onClick={() => handleEdit(w)} className="text-blue-600 hover:text-blue-800 mr-2" title="수정">
|
||||
<i className="ri-edit-line"></i>
|
||||
</button>
|
||||
<button onClick={() => handleDelete(w.id)} className="text-red-500 hover:text-red-700" title="삭제">
|
||||
<i className="ri-delete-bin-line"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-gray-800">{w.wage.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between mt-3 text-sm text-gray-500">
|
||||
<div>총 {filtered.length}명</div>
|
||||
<div>총 {pagination.total}명</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="px-2 py-1 rounded hover:bg-gray-100"><</button>
|
||||
<button className="px-2 py-1 rounded bg-blue-600 text-white text-xs">1</button>
|
||||
<button className="px-2 py-1 rounded hover:bg-gray-100">2</button>
|
||||
<button className="px-2 py-1 rounded hover:bg-gray-100">></button>
|
||||
<select className="border border-gray-300 rounded px-2 py-1 text-xs ml-2">
|
||||
<option>15</option><option>30</option><option>50</option>
|
||||
<button onClick={() => setPage(Math.max(1, page-1))} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded hover:bg-gray-100 disabled:opacity-30"><</button>
|
||||
{pageNumbers.map(p => (
|
||||
<button key={p} onClick={() => setPage(p)}
|
||||
className={`px-2 py-1 rounded text-xs ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'}`}>{p}</button>
|
||||
))}
|
||||
<button onClick={() => setPage(Math.min(pagination.last_page, page+1))} disabled={page >= pagination.last_page}
|
||||
className="px-2 py-1 rounded hover:bg-gray-100 disabled:opacity-30">></button>
|
||||
<select value={perPage} onChange={e => { setPerPage(+e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs ml-2">
|
||||
<option value={15}>15</option>
|
||||
<option value={30}>30</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +552,7 @@ className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 18
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
탭 2: 출역현황
|
||||
탭 2: 출역현황 (준비 중)
|
||||
════════════════════════════════════════════════ */
|
||||
function TabAttendance() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -230,7 +562,7 @@ function TabAttendance() {
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option>(주)주일/기업 · 방화셔터공사 ▾</option>
|
||||
<option>전체 업체</option>
|
||||
</select>
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm" />
|
||||
@@ -246,44 +578,19 @@ className="border border-gray-300 rounded px-3 py-1.5 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">순번</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">업체명</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">공종</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">직종</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">이름</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">출역구분</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">작업내용</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold text-gray-600">수우</th>
|
||||
<th className="px-3 py-2.5 text-right font-semibold text-gray-600">노임단가</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SAMPLE_ATTENDANCE.map(a => (
|
||||
<tr key={a.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
|
||||
<td className="px-3 py-2 text-gray-500">{a.id}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{a.company}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{a.trade}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{a.jobType}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{a.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{a.status}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{a.task || '-'}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-600">{a.hours || '-'}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-gray-800">{a.wage.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||
<div className="text-center">
|
||||
<i className="ri-calendar-check-line text-4xl mb-2 block"></i>
|
||||
<p className="text-sm">출역 데이터 테이블 구현 예정</p>
|
||||
<p className="text-xs mt-1 text-gray-300">인원등록 후 출역 기록을 관리할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
탭 3: 투입현황(업체별)
|
||||
탭 3: 투입현황(업체별) (준비 중)
|
||||
════════════════════════════════════════════════ */
|
||||
function TabDeployCompany() {
|
||||
const [viewBy, setViewBy] = useState('day');
|
||||
@@ -291,21 +598,11 @@ function TabDeployCompany() {
|
||||
const [startDate, setStartDate] = useState('2026-03-05');
|
||||
const [endDate, setEndDate] = useState('2026-03-12');
|
||||
|
||||
const dates = [];
|
||||
const s = new Date(startDate), e = new Date(endDate);
|
||||
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
|
||||
dates.push(new Date(d));
|
||||
}
|
||||
|
||||
const data = [
|
||||
{ trade: '방화셔터공사', company: '(주)주일기업', firstDate: '2025-12-22', lastDate: '2026-03-12', daily: [10, 7, 10, 0, 12, 10, 10, 10] },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option>(주)주일기업 · 방화셔터공사</option>
|
||||
<option>전체 업체</option>
|
||||
</select>
|
||||
<div className="ml-auto">
|
||||
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
@@ -317,14 +614,14 @@ function TabDeployCompany() {
|
||||
<span className="text-gray-600 font-semibold">보기기준</span>
|
||||
{[['day','일'],['week','주'],['month','월']].map(([v,l]) => (
|
||||
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="viewBy" checked={viewBy===v} onChange={()=>setViewBy(v)} className="text-red-500" />
|
||||
<input type="radio" name="viewBy" checked={viewBy===v} onChange={()=>setViewBy(v)} />
|
||||
<span className={viewBy===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
||||
</label>
|
||||
))}
|
||||
<span className="text-gray-600 font-semibold ml-4">조회기간</span>
|
||||
{[['1w','1주'],['1m','1개월'],['3m','3개월']].map(([v,l]) => (
|
||||
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="period" checked={period===v} onChange={()=>setPeriod(v)} className="text-red-500" />
|
||||
<input type="radio" name="period" checked={period===v} onChange={()=>setPeriod(v)} />
|
||||
<span className={period===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -333,66 +630,21 @@ function TabDeployCompany() {
|
||||
<span className="text-gray-400">~</span>
|
||||
<input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
||||
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">검색초기화</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-bold text-gray-800 mb-2">
|
||||
{startDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')} ~ {endDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>공종</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>업체명</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-gray-600" rowSpan={2}>최초투입</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-gray-600" rowSpan={2}>최종투입</th>
|
||||
<th className="px-2 py-1 text-center font-semibold text-blue-600 border-b border-gray-200" colSpan={dates.length}>
|
||||
{startDate.slice(0,7)}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-gray-600" rowSpan={2}>합계</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{dates.map(d => (
|
||||
<th key={d.toISOString()} className="px-2 py-1.5 text-center font-medium text-gray-500 text-xs min-w-[36px]">
|
||||
{d.getDate()}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i} className="border-b border-gray-100 hover:bg-blue-50/30">
|
||||
<td className="px-3 py-2 text-gray-700">{row.trade}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{row.company}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-600">{row.firstDate}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-600">{row.lastDate}</td>
|
||||
{row.daily.map((v, j) => (
|
||||
<td key={j} className={`px-2 py-2 text-center font-mono ${v > 0 ? 'text-gray-800' : 'text-gray-300'}`}>{v || ''}</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-center font-mono font-bold text-blue-700">{row.daily.reduce((a,b)=>a+b,0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-gray-50 font-bold border-t border-gray-300">
|
||||
<td className="px-3 py-2" colSpan={4}>합계</td>
|
||||
{data[0].daily.map((_, j) => {
|
||||
const sum = data.reduce((s, r) => s + (r.daily[j]||0), 0);
|
||||
return <td key={j} className={`px-2 py-2 text-center font-mono ${sum > 0 ? 'text-gray-800' : 'text-gray-300'}`}>{sum || ''}</td>;
|
||||
})}
|
||||
<td className="px-3 py-2 text-center font-mono text-blue-700">
|
||||
{data.reduce((s, r) => s + r.daily.reduce((a,b)=>a+b,0), 0)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||
<div className="text-center">
|
||||
<i className="ri-team-line text-4xl mb-2 block"></i>
|
||||
<p className="text-sm">업체별 투입현황 구현 예정</p>
|
||||
<p className="text-xs mt-1 text-gray-300">출역 데이터 기반으로 업체별 투입 현황을 집계합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
탭 4: 투입현황(근로자별)
|
||||
탭 4: 투입현황(근로자별) (준비 중)
|
||||
════════════════════════════════════════════════ */
|
||||
function TabDeployWorker() {
|
||||
const [viewBy, setViewBy] = useState('day');
|
||||
@@ -400,31 +652,11 @@ function TabDeployWorker() {
|
||||
const [startDate, setStartDate] = useState('2026-03-05');
|
||||
const [endDate, setEndDate] = useState('2026-03-12');
|
||||
|
||||
const dates = [];
|
||||
const s = new Date(startDate), e = new Date(endDate);
|
||||
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
|
||||
dates.push(new Date(d));
|
||||
}
|
||||
|
||||
const workers = [
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'현장소장', name:'김철수', daily:[1,1,1,0,1,1,1,1], wage:170000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'김영식', daily:[1,1,1,0,1,1,1,1], wage:200000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'홍길동', daily:[1,0,1,0,1,1,1,1], wage:170000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'취기검사시', name:'박현석', daily:[1,1,1,0,1,1,1,1], wage:200000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'이준호', daily:[1,1,1,0,1,1,1,1], wage:200000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'정대호', daily:[1,0,0,0,1,1,1,1], wage:180000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'철거/잡사', name:'김강사', daily:[0,1,1,0,1,1,1,1], wage:180000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'오상호', daily:[1,0,1,0,1,0,1,1], wage:180000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'한재영', daily:[1,1,0,0,1,1,0,1], wage:180000, remark:'작업정상' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'윤성민', daily:[1,0,1,0,1,0,1,0], wage:170000, remark:'' },
|
||||
{ company:'(주)주일기업', trade:'방화셔터공사', jobType:'방화셔터시공', name:'최동환', daily:[0,1,1,0,0,1,0,1], wage:170000, remark:'' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option>(주)주일기업 · 방화셔터공사</option>
|
||||
<option>전체 업체</option>
|
||||
</select>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
@@ -452,69 +684,14 @@ function TabDeployWorker() {
|
||||
<span className="text-gray-400">~</span>
|
||||
<input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
||||
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<span className="ml-2 text-gray-500">근로자별</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-bold text-gray-800 mb-2">
|
||||
{startDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')} ~ {endDate.replace(/-/g,'년 ').replace(/년 (\d+)$/,'월 $1일')}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>업체</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>공종</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>직종</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>이름</th>
|
||||
<th className="px-2 py-1 text-center font-semibold text-blue-600 border-b border-gray-200" colSpan={dates.length}>
|
||||
{startDate.slice(0,7)}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-semibold text-gray-600" rowSpan={2}>노임단가</th>
|
||||
<th className="px-3 py-2 text-right font-semibold text-gray-600" rowSpan={2}>합계</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>비고</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{dates.map(d => (
|
||||
<th key={d.toISOString()} className="px-2 py-1.5 text-center font-medium text-gray-500 text-xs min-w-[36px]">
|
||||
{d.getDate()}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.map((w, i) => {
|
||||
const total = w.daily.reduce((a,b) => a+b, 0);
|
||||
const totalWage = total * w.wage;
|
||||
return (
|
||||
<tr key={i} className="border-b border-gray-100 hover:bg-blue-50/30">
|
||||
<td className="px-3 py-2 text-gray-700">{w.company}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.trade}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.jobType}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{w.name}</td>
|
||||
{w.daily.map((v, j) => (
|
||||
<td key={j} className={`px-2 py-2 text-center font-mono ${v ? 'text-gray-800' : 'text-gray-300'}`}>{v || ''}</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-right font-mono text-gray-700">{w.wage.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right font-mono font-bold text-blue-700">{totalWage.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs">{w.remark}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-gray-50 font-bold border-t border-gray-300">
|
||||
<td className="px-3 py-2" colSpan={4}>합계</td>
|
||||
{dates.map((_, j) => {
|
||||
const sum = workers.reduce((s, w) => s + (w.daily[j]||0), 0);
|
||||
return <td key={j} className={`px-2 py-2 text-center font-mono ${sum ? 'text-gray-800' : 'text-gray-300'}`}>{sum || ''}</td>;
|
||||
})}
|
||||
<td className="px-3 py-2"></td>
|
||||
<td className="px-3 py-2 text-right font-mono text-blue-700">
|
||||
{workers.reduce((s, w) => s + w.daily.reduce((a,b)=>a+b,0) * w.wage, 0).toLocaleString()}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||
<div className="text-center">
|
||||
<i className="ri-user-follow-line text-4xl mb-2 block"></i>
|
||||
<p className="text-sm">근로자별 투입현황 구현 예정</p>
|
||||
<p className="text-xs mt-1 text-gray-300">출역 데이터 기반으로 근로자별 투입 현황을 집계합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
use App\Http\Controllers\Juil\ConstructionSitePhotoController;
|
||||
use App\Http\Controllers\Juil\MeetingMinuteController;
|
||||
use App\Http\Controllers\Juil\PlanningController;
|
||||
use App\Http\Controllers\Juil\PmisWorkforceController;
|
||||
use App\Http\Controllers\Lab\StrategyController;
|
||||
use App\Http\Controllers\MenuController;
|
||||
use App\Http\Controllers\MenuSyncController;
|
||||
@@ -1738,6 +1739,16 @@
|
||||
Route::get('/construction-pmis/daily-attendance', [PlanningController::class, 'pmisDailyAttendance'])->name('construction-pmis.daily-attendance');
|
||||
Route::get('/construction-pmis/daily-report', [PlanningController::class, 'pmisDailyReport'])->name('construction-pmis.daily-report');
|
||||
|
||||
// 시공관리 API (인원관리 CRUD)
|
||||
Route::prefix('construction-pmis/api')->group(function () {
|
||||
Route::get('/workers', [PmisWorkforceController::class, 'workerList']);
|
||||
Route::post('/workers', [PmisWorkforceController::class, 'workerStore']);
|
||||
Route::put('/workers/{id}', [PmisWorkforceController::class, 'workerUpdate']);
|
||||
Route::delete('/workers/{id}', [PmisWorkforceController::class, 'workerDestroy']);
|
||||
Route::get('/job-types', [PmisWorkforceController::class, 'jobTypeList']);
|
||||
Route::post('/job-types', [PmisWorkforceController::class, 'jobTypeStore']);
|
||||
});
|
||||
|
||||
// 공사현장 사진대지
|
||||
Route::prefix('construction-photos')->name('construction-photos.')->group(function () {
|
||||
Route::get('/', [ConstructionSitePhotoController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user