feat: [pmis] 출면일보 CRUD 구현

- 일별 출면일보 마스터 + 인원/장비 3테이블 마이그레이션
- 캘린더 스트립 (1~31일) 날짜 선택 및 상태 닷 표시
- 인원/장비 탭 CRUD (추가/수정/삭제/번개 랜덤데이터)
- 검토자 확인 모달 (조직도 + 검색 + 검토라인)
- 양식보기 모달 (출면일보/장비일보 인쇄 양식)
- 날씨/특이사항/상태 업데이트 API
This commit is contained in:
김보곤
2026-03-12 16:43:36 +09:00
parent 28ca8d05d3
commit 6c968dbb6f
9 changed files with 1333 additions and 25 deletions

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Http\Controllers\Juil;
use App\Http\Controllers\Controller;
use App\Models\Juil\PmisAttendanceEquipment;
use App\Models\Juil\PmisAttendanceWorker;
use App\Models\Juil\PmisDailyAttendance;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PmisDailyAttendanceController extends Controller
{
private function tenantId(): int
{
return (int) session('current_tenant_id', 1);
}
/**
* 해당 날짜의 출면일보 조회 (없으면 생성)
*/
public function show(Request $request): JsonResponse
{
$date = $request->input('date', now()->toDateString());
$company = $request->input('company', '');
$attendance = PmisDailyAttendance::tenant($this->tenantId())
->where('date', $date)
->when($company, fn ($q) => $q->where('company_name', $company))
->first();
if (! $attendance) {
$attendance = PmisDailyAttendance::create([
'tenant_id' => $this->tenantId(),
'date' => $date,
'company_name' => $company,
'weather' => '맑음',
'status' => 'draft',
]);
}
$attendance->load(['workers', 'equipments']);
return response()->json($attendance);
}
/**
* 해당 월의 일별 상태 조회 (캘린더 닷 표시용)
*/
public function monthStatus(Request $request): JsonResponse
{
$year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month);
$company = $request->input('company', '');
$attendances = PmisDailyAttendance::tenant($this->tenantId())
->whereYear('date', $year)
->whereMonth('date', $month)
->when($company, fn ($q) => $q->where('company_name', $company))
->withCount(['workers', 'equipments'])
->get();
$result = [];
foreach ($attendances as $att) {
$day = (int) $att->date->format('d');
if ($att->workers_count > 0 || $att->equipments_count > 0) {
$result[$day] = $att->status;
}
}
return response()->json($result);
}
/**
* 출면일보 메타 업데이트 (날씨, 특이사항, 상태)
*/
public function update(Request $request, int $id): JsonResponse
{
$attendance = PmisDailyAttendance::tenant($this->tenantId())->findOrFail($id);
$validated = $request->validate([
'weather' => 'sometimes|string|max:50',
'notes' => 'sometimes|nullable|string',
'status' => 'sometimes|in:draft,review,approved',
'options' => 'sometimes|nullable|array',
]);
$attendance->update($validated);
return response()->json($attendance);
}
/**
* 검토자 저장
*/
public function saveReviewers(Request $request, int $id): JsonResponse
{
$attendance = PmisDailyAttendance::tenant($this->tenantId())->findOrFail($id);
$reviewers = $request->input('reviewers', []);
$options = $attendance->options ?? [];
$options['reviewers'] = $reviewers;
$attendance->update(['options' => $options]);
return response()->json(['message' => '검토자가 저장되었습니다.']);
}
// ─── 인원(Worker) CRUD ───
public function workerStore(Request $request): JsonResponse
{
$validated = $request->validate([
'attendance_id' => 'required|integer|exists:pmis_daily_attendances,id',
'work_type' => 'required|string|max:200',
'job_type' => 'required|string|max:200',
'name' => 'required|string|max:100',
'man_days' => 'nullable|numeric|min:0',
'amount' => 'nullable|numeric|min:0',
'work_content' => 'nullable|string|max:500',
]);
$validated['tenant_id'] = $this->tenantId();
$validated['man_days'] = $validated['man_days'] ?? 1.0;
$validated['amount'] = $validated['amount'] ?? 0;
$validated['work_content'] = $validated['work_content'] ?? '';
$maxSort = PmisAttendanceWorker::where('attendance_id', $validated['attendance_id'])->max('sort_order') ?? 0;
$validated['sort_order'] = $maxSort + 1;
$worker = PmisAttendanceWorker::create($validated);
return response()->json($worker, 201);
}
public function workerUpdate(Request $request, int $id): JsonResponse
{
$worker = PmisAttendanceWorker::where('tenant_id', $this->tenantId())->findOrFail($id);
$validated = $request->validate([
'work_type' => 'sometimes|string|max:200',
'job_type' => 'sometimes|string|max:200',
'name' => 'sometimes|string|max:100',
'man_days' => 'nullable|numeric|min:0',
'amount' => 'nullable|numeric|min:0',
'work_content' => 'nullable|string|max:500',
]);
$worker->update($validated);
return response()->json($worker);
}
public function workerDestroy(int $id): JsonResponse
{
$worker = PmisAttendanceWorker::where('tenant_id', $this->tenantId())->findOrFail($id);
$worker->delete();
return response()->json(['message' => '삭제되었습니다.']);
}
// ─── 장비(Equipment) CRUD ───
public function equipmentStore(Request $request): JsonResponse
{
$validated = $request->validate([
'attendance_id' => 'required|integer|exists:pmis_daily_attendances,id',
'equipment_name' => 'required|string|max:200',
'specification' => 'nullable|string|max:300',
'equipment_number' => 'nullable|string|max:100',
'operator' => 'nullable|string|max:100',
'man_days' => 'nullable|numeric|min:0',
'work_content' => 'nullable|string|max:500',
]);
$validated['tenant_id'] = $this->tenantId();
$validated['man_days'] = $validated['man_days'] ?? 1.0;
$validated['work_content'] = $validated['work_content'] ?? '';
$maxSort = PmisAttendanceEquipment::where('attendance_id', $validated['attendance_id'])->max('sort_order') ?? 0;
$validated['sort_order'] = $maxSort + 1;
$equipment = PmisAttendanceEquipment::create($validated);
return response()->json($equipment, 201);
}
public function equipmentUpdate(Request $request, int $id): JsonResponse
{
$equipment = PmisAttendanceEquipment::where('tenant_id', $this->tenantId())->findOrFail($id);
$validated = $request->validate([
'equipment_name' => 'sometimes|string|max:200',
'specification' => 'nullable|string|max:300',
'equipment_number' => 'nullable|string|max:100',
'operator' => 'nullable|string|max:100',
'man_days' => 'nullable|numeric|min:0',
'work_content' => 'nullable|string|max:500',
]);
$equipment->update($validated);
return response()->json($equipment);
}
public function equipmentDestroy(int $id): JsonResponse
{
$equipment = PmisAttendanceEquipment::where('tenant_id', $this->tenantId())->findOrFail($id);
$equipment->delete();
return response()->json(['message' => '삭제되었습니다.']);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models\Juil;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class PmisAttendanceEquipment extends Model
{
use SoftDeletes;
protected $table = 'pmis_attendance_equipments';
protected $fillable = [
'tenant_id',
'attendance_id',
'equipment_name',
'specification',
'equipment_number',
'operator',
'man_days',
'work_content',
'sort_order',
'options',
];
protected $casts = [
'man_days' => 'decimal:1',
'options' => 'array',
];
public function attendance(): BelongsTo
{
return $this->belongsTo(PmisDailyAttendance::class, 'attendance_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models\Juil;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class PmisAttendanceWorker extends Model
{
use SoftDeletes;
protected $table = 'pmis_attendance_workers';
protected $fillable = [
'tenant_id',
'attendance_id',
'work_type',
'job_type',
'name',
'man_days',
'amount',
'work_content',
'sort_order',
'options',
];
protected $casts = [
'man_days' => 'decimal:1',
'amount' => 'integer',
'options' => 'array',
];
public function attendance(): BelongsTo
{
return $this->belongsTo(PmisDailyAttendance::class, 'attendance_id');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models\Juil;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class PmisDailyAttendance extends Model
{
use SoftDeletes;
protected $table = 'pmis_daily_attendances';
protected $fillable = [
'tenant_id',
'date',
'company_name',
'weather',
'status',
'notes',
'options',
];
protected $casts = [
'date' => 'date',
'options' => 'array',
];
public function scopeTenant($query, $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
public function workers(): HasMany
{
return $this->hasMany(PmisAttendanceWorker::class, 'attendance_id')->orderBy('sort_order');
}
public function equipments(): HasMany
{
return $this->hasMany(PmisAttendanceEquipment::class, 'attendance_id')->orderBy('sort_order');
}
}

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pmis_daily_attendances', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->index();
$table->date('date')->comment('일자');
$table->string('company_name', 200)->default('')->comment('업체명');
$table->string('weather', 50)->default('맑음')->comment('날씨');
$table->enum('status', ['draft', 'review', 'approved'])->default('draft')->comment('상태');
$table->text('notes')->nullable()->comment('특이사항');
$table->json('options')->nullable()->comment('검토자 등 부가정보');
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'date', 'company_name'], 'pmis_attendance_unique');
});
}
public function down(): void
{
Schema::dropIfExists('pmis_daily_attendances');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pmis_attendance_workers', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->index();
$table->unsignedBigInteger('attendance_id')->index();
$table->string('work_type', 200)->comment('공종');
$table->string('job_type', 200)->comment('직종');
$table->string('name', 100)->comment('성명');
$table->decimal('man_days', 5, 1)->default(1.0)->comment('공수');
$table->decimal('amount', 14, 0)->default(0)->comment('금액');
$table->string('work_content', 500)->default('')->comment('작업내용');
$table->integer('sort_order')->default(0);
$table->json('options')->nullable();
$table->timestamps();
$table->softDeletes();
$table->foreign('attendance_id')->references('id')->on('pmis_daily_attendances')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('pmis_attendance_workers');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pmis_attendance_equipments', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->index();
$table->unsignedBigInteger('attendance_id')->index();
$table->string('equipment_name', 200)->comment('장비명');
$table->string('specification', 300)->default('')->comment('규격');
$table->string('equipment_number', 100)->default('')->comment('장비번호');
$table->string('operator', 100)->default('')->comment('운전원');
$table->decimal('man_days', 5, 1)->default(1.0)->comment('공수');
$table->string('work_content', 500)->default('')->comment('작업내용');
$table->integer('sort_order')->default(0);
$table->json('options')->nullable();
$table->timestamps();
$table->softDeletes();
$table->foreign('attendance_id')->references('id')->on('pmis_daily_attendances')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('pmis_attendance_equipments');
}
};

View File

@@ -14,6 +14,25 @@
@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();
}
const DAY_NAMES = ['일','월','화','수','목','금','토'];
const WEATHERS = ['맑음','흐림','비','눈','안개','구름많음'];
function getDaysInMonth(y, m) { return new Date(y, m, 0).getDate(); }
function getDayOfWeek(y, m, d) { return new Date(y, m - 1, d).getDay(); }
function formatDate(y, m, d) { return `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`; }
/* ════════════════════════════════════════════════
PMIS 사이드바
════════════════════════════════════════════════ */
@@ -37,17 +56,13 @@
function PmisSidebar({ activePage }) {
const [profile, setProfile] = useState(null);
const [expanded, setExpanded] = useState(() => {
for (const m of PMIS_MENUS) {
if (m.children?.some(c => c.id === activePage)) return m.id;
}
for (const m of PMIS_MENUS) { if (m.children?.some(c => c.id === activePage)) return m.id; }
return null;
});
useEffect(() => {
fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } })
.then(r => r.json()).then(d => setProfile(d.worker)).catch(() => {});
}, []);
return (
<div className="bg-white border-r border-gray-200 shadow-sm flex flex-col shrink-0" style={{ width: 200 }}>
<a href="/juil/construction-pmis" className="flex items-center gap-2 px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 border-b border-gray-100 transition">
@@ -55,9 +70,7 @@ function PmisSidebar({ activePage }) {
</a>
<div className="p-3 border-b border-gray-100 text-center">
<div className="w-12 h-12 mx-auto mb-1 rounded-full bg-gray-100 border-2 border-gray-200 flex items-center justify-center">
{profile?.profile_photo_path
? <img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover" />
: <i className="ri-user-3-line text-xl text-gray-300"></i>}
{profile?.profile_photo_path ? <img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover" /> : <i className="ri-user-3-line text-xl text-gray-300"></i>}
</div>
<div className="text-sm font-bold text-gray-800">{profile?.name || '...'}</div>
<div className="text-xs text-gray-500 mt-0.5">{profile?.department || ''}</div>
@@ -65,17 +78,13 @@ function PmisSidebar({ activePage }) {
<div className="flex-1 overflow-auto py-1">
{PMIS_MENUS.map(m => (
<div key={m.id}>
<div
onClick={() => setExpanded(expanded === m.id ? null : m.id)}
<div onClick={() => setExpanded(expanded === m.id ? null : m.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer transition ${expanded === m.id ? 'bg-blue-50 text-blue-700 font-semibold' : 'text-gray-600 hover:bg-gray-50'}`}>
<i className={`${m.icon} text-base`}></i> {m.label}
<i className={`ml-auto ${expanded === m.id ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'} text-gray-400 text-xs`}></i>
</div>
{expanded === m.id && m.children?.map(c => (
<a key={c.id} href={c.url}
className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600' : 'text-gray-500 hover:text-blue-600 hover:bg-gray-50'}`}>
{c.label}
</a>
<a key={c.id} href={c.url} className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600' : 'text-gray-500 hover:text-blue-600 hover:bg-gray-50'}`}>{c.label}</a>
))}
</div>
))}
@@ -84,27 +93,883 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg
);
}
/* ════════════════════════════════════════════════
캘린더 스트립 (월간 일자 선택)
════════════════════════════════════════════════ */
function CalendarStrip({ year, month, selectedDay, onSelectDay, dayStatus }) {
const days = getDaysInMonth(year, month);
const STATUS_COLORS = { draft: '#22c55e', review: '#ef4444', approved: '#3b82f6' };
return (
<div className="flex items-center gap-0.5 py-2 overflow-x-auto">
<button onClick={() => onSelectDay(Math.max(1, selectedDay - 1))} className="px-2 py-1 text-gray-500 hover:text-gray-800 text-lg shrink-0">&lt;</button>
{Array.from({ length: days }, (_, i) => {
const d = i + 1;
const dow = getDayOfWeek(year, month, d);
const isWeekend = dow === 0 || dow === 6;
const isSelected = d === selectedDay;
const status = dayStatus[d];
const dotColor = STATUS_COLORS[status];
return (
<div key={d} onClick={() => onSelectDay(d)}
className={`flex flex-col items-center cursor-pointer shrink-0 transition rounded ${isSelected ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'}`}
style={{ width: 32, padding: '2px 0' }}>
<div className="h-2 flex items-center justify-center">
{dotColor && <div className="rounded-full" style={{ width: 6, height: 6, backgroundColor: dotColor }}></div>}
</div>
<div className={`text-sm font-medium ${isSelected ? 'text-white' : isWeekend ? 'text-red-500' : 'text-gray-700'}`}>{d}</div>
</div>
);
})}
<button onClick={() => onSelectDay(Math.min(days, selectedDay + 1))} className="px-2 py-1 text-gray-500 hover:text-gray-800 text-lg shrink-0">&gt;</button>
</div>
);
}
/* ════════════════════════════════════════════════
인원 추가/수정 모달
════════════════════════════════════════════════ */
function WorkerModal({ open, onClose, onSaved, worker, attendanceId }) {
const isEdit = !!worker?.id;
const [form, setForm] = useState({ work_type: '', job_type: '', name: '', man_days: '1.0', amount: '', work_content: '' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
if (worker) {
setForm({
work_type: worker.work_type || '', job_type: worker.job_type || '',
name: worker.name || '', man_days: worker.man_days ?? '1.0',
amount: worker.amount ?? '', work_content: worker.work_content || '',
});
} else {
setForm({ work_type: '', job_type: '', name: '', man_days: '1.0', amount: '', work_content: '' });
}
setError('');
}
}, [open, worker]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
async function handleSubmit(e) {
e.preventDefault(); setSaving(true); setError('');
try {
const body = { ...form, man_days: parseFloat(form.man_days) || 1.0, amount: parseInt(form.amount) || 0 };
if (isEdit) {
await api(`/attendance-workers/${worker.id}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
body.attendance_id = attendanceId;
await api('/attendance-workers', { method: 'POST', body: JSON.stringify(body) });
}
onSaved(); onClose();
} catch (err) { setError(err.message); } finally { setSaving(false); }
}
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">인원 정보</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="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>공종 <span className="text-red-500">*</span></label>
<input type="text" value={form.work_type} onChange={e => 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" />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>직종 <span className="text-red-500">*</span></label>
<input type="text" value={form.job_type} onChange={e => set('job_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" />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>성명 <span className="text-red-500">*</span></label>
<input type="text" value={form.name} onChange={e => set('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" />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>공수</label>
<input type="number" step="0.5" min="0" value={form.man_days} onChange={e => set('man_days', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:100}} />
<label className="shrink-0 text-sm font-medium text-gray-700">금액</label>
<input type="number" min="0" value={form.amount} onChange={e => set('amount', 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" />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>작업내용</label>
<input type="text" value={form.work_content} onChange={e => set('work_content', 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" />
</div>
<div className="flex justify-end gap-2 pt-3">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 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 ? '저장 중...' : '저장'}</button>
</div>
</form>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
장비 추가/수정 모달
════════════════════════════════════════════════ */
function EquipmentModal({ open, onClose, onSaved, equipment, attendanceId }) {
const isEdit = !!equipment?.id;
const [form, setForm] = useState({ equipment_name: '', specification: '', equipment_number: '', operator: '', man_days: '1.0', work_content: '' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
if (equipment) {
setForm({
equipment_name: equipment.equipment_name || '', specification: equipment.specification || '',
equipment_number: equipment.equipment_number || '', operator: equipment.operator || '',
man_days: equipment.man_days ?? '1.0', work_content: equipment.work_content || '',
});
} else {
setForm({ equipment_name: '', specification: '', equipment_number: '', operator: '', man_days: '1.0', work_content: '' });
}
setError('');
}
}, [open, equipment]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
async function handleSubmit(e) {
e.preventDefault(); setSaving(true); setError('');
try {
const body = { ...form, man_days: parseFloat(form.man_days) || 1.0 };
if (isEdit) {
await api(`/attendance-equipments/${equipment.id}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
body.attendance_id = attendanceId;
await api('/attendance-equipments', { method: 'POST', body: JSON.stringify(body) });
}
onSaved(); onClose();
} catch (err) { setError(err.message); } finally { setSaving(false); }
}
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">장비 정보</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="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>장비명 <span className="text-red-500">*</span></label>
<input type="text" value={form.equipment_name} onChange={e => set('equipment_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" />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>규격</label>
<input type="text" value={form.specification} onChange={e => 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" />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>장비번호</label>
<input type="text" value={form.equipment_number} onChange={e => set('equipment_number', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:140}} />
<label className="shrink-0 text-sm font-medium text-gray-700">운전원</label>
<input type="text" value={form.operator} onChange={e => set('operator', 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" />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>공수</label>
<input type="number" step="0.5" min="0" value={form.man_days} onChange={e => set('man_days', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:100}} />
</div>
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>작업내용</label>
<input type="text" value={form.work_content} onChange={e => set('work_content', 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" />
</div>
<div className="flex justify-end gap-2 pt-3">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 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 ? '저장 중...' : '저장'}</button>
</div>
</form>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
검토자 지정 모달
════════════════════════════════════════════════ */
const DEFAULT_REVIEWERS = [
{ line: 1, name: '신승표', position: '부장', confirmed_at: null },
{ line: 2, name: '이성태', position: '대리', confirmed_at: null },
{ line: 3, name: '안성현', position: '과장', confirmed_at: null },
{ line: 4, name: '정영진', position: '과장', confirmed_at: null },
{ line: 5, name: '심준수', position: '부장', confirmed_at: null },
{ line: 6, name: '김정훈', position: '부장', confirmed_at: null },
];
const ORG_TREE = [
{ name: '안성 당목리 물류센터', children: [
{ name: '협력업체', members: [
{ dept: '(주)주일기업', name: '신승표', position: '부장' },
{ dept: '(주)주일기업', name: '이성태', position: '대리' },
{ dept: '(주)주일기업', name: '김국민', position: '반장' },
]},
{ name: 'KCC건설', members: [
{ dept: 'KCC건설', name: '안성현', position: '과장' },
{ dept: 'KCC건설', name: '정영진', position: '과장' },
{ dept: 'KCC건설', name: '심준수', position: '부장' },
{ dept: 'KCC건설', name: '김정훈', position: '부장' },
]},
]},
];
function ReviewerModal({ open, onClose, attendance, onSaved }) {
const [reviewers, setReviewers] = useState([]);
const [selectedOrg, setSelectedOrg] = useState(null);
const [search, setSearch] = useState('');
useEffect(() => {
if (open && attendance) {
const saved = attendance.options?.reviewers;
setReviewers(saved?.length ? saved : DEFAULT_REVIEWERS.map(r => ({...r})));
setSelectedOrg(null);
setSearch('');
}
}, [open, attendance]);
const orgMembers = useMemo(() => {
if (!selectedOrg) return [];
for (const root of ORG_TREE) {
for (const child of root.children || []) {
if (child.name === selectedOrg) return child.members || [];
}
}
return [];
}, [selectedOrg]);
const filteredMembers = useMemo(() => {
if (!search) return orgMembers;
const s = search.toLowerCase();
return orgMembers.filter(m => m.name.includes(s) || m.dept.includes(s) || m.position.includes(s));
}, [orgMembers, search]);
async function handleSave() {
if (!attendance?.id) return;
try {
await api(`/daily-attendances/${attendance.id}/reviewers`, {
method: 'PUT', body: JSON.stringify({ reviewers }),
});
onSaved();
onClose();
} catch {}
}
function addReviewer(member) {
if (reviewers.length >= 10) return;
setReviewers(prev => [...prev, { line: prev.length + 1, name: member.name, position: member.position, confirmed_at: null }]);
}
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 mx-4" style={{maxWidth: 900, maxHeight: '80vh'}} 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">검토자 지정</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><i className="ri-close-line text-xl"></i></button>
</div>
<div className="flex p-4 gap-4" style={{height: 420}}>
{/* 왼쪽: 조직 트리 */}
<div className="border border-gray-200 rounded-lg overflow-auto" style={{width: 200}}>
{ORG_TREE.map(root => (
<div key={root.name}>
<div className="px-3 py-2 text-sm font-semibold text-blue-700 bg-blue-50">{root.name}</div>
{root.children?.map(child => (
<div key={child.name} onClick={() => setSelectedOrg(child.name)}
className={`px-5 py-2 text-sm cursor-pointer transition ${selectedOrg === child.name ? 'bg-blue-100 text-blue-800 font-semibold' : 'text-gray-600 hover:bg-gray-50'}`}>
{child.name}
</div>
))}
</div>
))}
</div>
{/* 가운데: 인원 목록 */}
<div className="flex-1 flex flex-col border border-gray-200 rounded-lg overflow-hidden">
<div className="p-2 border-b border-gray-200 flex items-center gap-2">
<input type="text" placeholder="검색" value={search} onChange={e => setSearch(e.target.value)}
className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm" />
<button className="text-gray-400"><i className="ri-search-line"></i></button>
</div>
<div className="overflow-auto flex-1">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold text-gray-600">소속</th>
<th className="px-3 py-2 text-left font-semibold text-gray-600">성명</th>
<th className="px-3 py-2 text-left font-semibold text-gray-600">직책</th>
<th className="px-3 py-2 text-center font-semibold text-gray-600">추가</th>
</tr>
</thead>
<tbody>
{filteredMembers.map((m, i) => (
<tr key={i} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
<td className="px-3 py-2 text-gray-600">{m.dept}</td>
<td className="px-3 py-2 font-medium text-gray-800">{m.name}</td>
<td className="px-3 py-2 text-gray-600">{m.position}</td>
<td className="px-3 py-2 text-center">
<button onClick={() => addReviewer(m)} className="text-blue-600 hover:text-blue-800"><i className="ri-add-line"></i></button>
</td>
</tr>
))}
{filteredMembers.length === 0 && <tr><td colSpan={4} className="text-center py-6 text-gray-400">{selectedOrg ? '조직을 선택하세요' : '좌측 조직을 선택하세요'}</td></tr>}
</tbody>
</table>
</div>
</div>
{/* 오른쪽: 검토자 라인 */}
<div className="border border-gray-200 rounded-lg overflow-auto flex flex-col" style={{width: 260}}>
<div className="p-2 border-b border-gray-200 flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">라인 선택</span>
<div className="ml-auto flex gap-1">
<button onClick={handleSave} className="bg-blue-600 text-white px-3 py-1 rounded text-xs font-semibold hover:bg-blue-700">저장</button>
<button onClick={() => setReviewers([])} className="bg-gray-200 text-gray-700 px-3 py-1 rounded text-xs hover:bg-gray-300">삭제</button>
</div>
</div>
<div className="flex-1 overflow-auto p-2">
{reviewers.map((r, i) => (
<div key={i} className={`flex items-center justify-between px-3 py-2 border-b border-gray-100 text-sm ${r.confirmed_at ? 'text-blue-600' : 'text-gray-700'}`}>
<span>{String(i+1).padStart(2,'0')} : {r.name} {r.position}</span>
{r.confirmed_at && <span className="text-xs text-blue-500 ml-2">{r.confirmed_at}</span>}
<button onClick={() => setReviewers(prev => prev.filter((_, j) => j !== i))} className="text-red-400 hover:text-red-600 ml-1"><i className="ri-close-line text-xs"></i></button>
</div>
))}
</div>
</div>
</div>
<div className="flex justify-end px-6 py-3 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg">닫기</button>
</div>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
양식보기 모달 (출면일보 + 장비일보)
════════════════════════════════════════════════ */
function PrintPreviewModal({ open, onClose, attendance, dateStr }) {
if (!open || !attendance) return null;
const workers = attendance.workers || [];
const equipments = attendance.equipments || [];
const reviewers = attendance.options?.reviewers || DEFAULT_REVIEWERS;
const totalManDays = workers.reduce((s, w) => s + parseFloat(w.man_days || 0), 0);
const totalAmount = workers.reduce((s, w) => s + parseInt(w.amount || 0), 0);
const eqTotalManDays = equipments.reduce((s, e) => s + parseFloat(e.man_days || 0), 0);
const tdStyle = { border: '1px solid #333', padding: '4px 6px', fontSize: '12px' };
const thStyle = { ...tdStyle, backgroundColor: '#f0f0f0', fontWeight: 'bold', textAlign: 'center' };
function renderApprovalSection() {
const r = (i) => reviewers[i] || {};
return (
<table style={{width:'100%', borderCollapse:'collapse', marginTop: 10}}>
<tbody>
<tr><td style={{...tdStyle, width: 40, textAlign:'center', border:'none'}}></td><td style={{...tdStyle, border:'none'}}></td>
<td style={thStyle}>공사과장</td><td style={thStyle}>공무과장</td><td style={thStyle}>현장소장</td></tr>
<tr><td style={{...tdStyle, textAlign:'center', writingMode:'vertical-lr'}} rowSpan={2}></td>
<td style={{...tdStyle, textAlign:'center'}}>{r(1).name || ''}</td>
<td style={{...tdStyle, textAlign:'center'}}>{r(2).name || ''}</td>
<td style={{...tdStyle, textAlign:'center'}}>{r(3).name || ''}</td>
<td style={{...tdStyle, textAlign:'center'}}></td></tr>
<tr><td style={thStyle}>관리부장</td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td></tr>
<tr><td style={{...tdStyle, textAlign:'center'}}></td>
<td style={{...tdStyle, textAlign:'center'}}>{r(4).name || ''}</td>
<td style={tdStyle}></td><td style={tdStyle}></td>
<td style={{...tdStyle, textAlign:'center'}}>{r(5).name || ''}</td></tr>
</tbody>
</table>
);
}
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 mx-4 overflow-auto" style={{maxWidth: 800, maxHeight: '90vh'}} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 sticky top-0 bg-white z-10">
<h2 className="text-lg font-bold text-gray-800">양식보기</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><i className="ri-close-line text-xl"></i></button>
</div>
{/* 출면일보 */}
<div className="p-6" style={{fontFamily: 'serif'}}>
<div style={{border:'2px solid #333', padding:20, marginBottom: 30}}>
<div style={{display:'flex', justifyContent:'space-between', marginBottom:10}}>
<h3 style={{fontSize:22, fontWeight:'bold', letterSpacing:8}}> </h3>
<div style={{fontSize:12, textAlign:'right', lineHeight:1.8}}>
<div> : 안성 당목리 물류센터</div>
<div> : ()주일기업</div>
<div> &nbsp;&nbsp;&nbsp; : {dateStr}</div>
</div>
</div>
<table style={{width:'100%', borderCollapse:'collapse'}}>
<thead>
<tr>
<th style={thStyle}>번호</th><th style={thStyle}>직종</th><th style={thStyle}>성명</th>
<th style={thStyle}>공수</th><th style={thStyle}>금액</th><th style={thStyle}>작업내용</th>
</tr>
</thead>
<tbody>
{workers.map((w, i) => (
<tr key={i}>
<td style={{...tdStyle, textAlign:'center'}}>{i+1}</td>
<td style={tdStyle}>{w.job_type}</td>
<td style={{...tdStyle, textAlign:'center'}}>{w.name}</td>
<td style={{...tdStyle, textAlign:'center'}}>{w.man_days}</td>
<td style={{...tdStyle, textAlign:'right'}}>{parseInt(w.amount||0).toLocaleString()}</td>
<td style={tdStyle}>{w.work_content}</td>
</tr>
))}
{Array.from({length: Math.max(0, 15 - workers.length)}).map((_, i) => (
<tr key={`e${i}`}><td style={tdStyle}>&nbsp;</td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td></tr>
))}
<tr style={{fontWeight:'bold'}}>
<td colSpan={3} style={{...tdStyle, textAlign:'center'}}></td>
<td style={{...tdStyle, textAlign:'center'}}>{totalManDays}</td>
<td style={{...tdStyle, textAlign:'right'}}>{totalAmount.toLocaleString()}</td>
<td style={tdStyle}></td>
</tr>
</tbody>
</table>
<div style={{marginTop:10, fontSize:12}}>
<div style={{fontWeight:'bold'}}>특이사항 :</div>
<div style={{minHeight:30, padding:4}}>{attendance.notes || ''}</div>
</div>
<div style={{marginTop:10, fontSize:12}}>작업책임자 : ()주일기업 {reviewers[0]?.name || ''}</div>
{renderApprovalSection()}
</div>
{/* 장비일보 */}
<div style={{border:'2px solid #333', padding:20}}>
<div style={{display:'flex', justifyContent:'space-between', marginBottom:10}}>
<h3 style={{fontSize:22, fontWeight:'bold', letterSpacing:8}}> </h3>
<div style={{fontSize:12, textAlign:'right', lineHeight:1.8}}>
<div> : 안성 당목리 물류센터</div>
<div> : ()주일기업</div>
<div> &nbsp;&nbsp;&nbsp; : {dateStr}</div>
</div>
</div>
<table style={{width:'100%', borderCollapse:'collapse'}}>
<thead>
<tr>
<th style={thStyle}>번호</th><th style={thStyle}>장비명</th><th style={thStyle}>규격</th>
<th style={thStyle}>단위</th><th style={thStyle}>투입량</th><th style={thStyle}>금액</th>
<th style={thStyle}>운전원</th><th style={thStyle}>작업내용</th>
</tr>
</thead>
<tbody>
{equipments.map((eq, i) => (
<tr key={i}>
<td style={{...tdStyle, textAlign:'center'}}>{i+1}</td>
<td style={tdStyle}>{eq.equipment_name}</td>
<td style={tdStyle}>{eq.specification}</td>
<td style={{...tdStyle, textAlign:'center'}}></td>
<td style={{...tdStyle, textAlign:'center'}}>{eq.man_days}</td>
<td style={{...tdStyle, textAlign:'right'}}></td>
<td style={{...tdStyle, textAlign:'center'}}>{eq.operator}</td>
<td style={tdStyle}>{eq.work_content}</td>
</tr>
))}
{Array.from({length: Math.max(0, 10 - equipments.length)}).map((_, i) => (
<tr key={`ee${i}`}><td style={tdStyle}>&nbsp;</td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td></tr>
))}
<tr style={{fontWeight:'bold'}}>
<td colSpan={4} style={{...tdStyle, textAlign:'center'}}></td>
<td style={{...tdStyle, textAlign:'center'}}>{eqTotalManDays}</td>
<td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td>
</tr>
</tbody>
</table>
<div style={{marginTop:10, fontSize:12}}>
<div style={{fontWeight:'bold'}}>특이사항 :</div>
<div style={{minHeight:30, padding:4}}>{attendance.notes || ''}</div>
</div>
<div style={{marginTop:10, fontSize:12}}>작업책임자 : ()주일기업 {reviewers[0]?.name || ''}</div>
{renderApprovalSection()}
</div>
</div>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
랜덤 데이터 생성
════════════════════════════════════════════════ */
const RANDOM_WORKERS = [
{ work_type: '방화셔터공사', job_type: '방화셔터', names: ['김국민','김상수','김성운','김세호','김송민','임용준','조명석'], amounts: [180000,200000] },
{ work_type: '방화셔터공사', job_type: '현장소장', names: ['신승표'], amounts: [0] },
{ work_type: '방화셔터공사', job_type: '화기감시자', names: ['임민서','최흥숙','박동현','이재훈'], amounts: [170000] },
{ work_type: '철근콘크리트공사', job_type: '철근공', names: ['박철수','이강호','최영수','김민준'], amounts: [200000,220000] },
{ work_type: '철근콘크리트공사', job_type: '형틀목공', names: ['한상우','정대호','오진혁'], amounts: [190000,210000] },
{ work_type: '전기공사', job_type: '전기기사', names: ['송재민','윤호진','박성훈'], amounts: [200000,230000] },
{ work_type: '설비공사', job_type: '배관공', names: ['최태호','강민석','이동현'], amounts: [190000,210000] },
];
const RANDOM_WORK_CONTENTS = ['B동 1층 셔터설치','A동 1층 셔터설치','A동 3층 셔터설치','B동 2층 배관작업','A동 지하1층 전기배선','B동 3층 철근배근','A동 2층 거푸집설치','지하주차장 방수작업'];
const RANDOM_EQUIPS = [
{ equipment_name: '타워크레인', specification: 'T-50', operators: ['박기사','이기사'] },
{ equipment_name: '굴삭기', specification: '0.7m3', operators: ['김기사','최기사'] },
{ equipment_name: '덤프트럭', specification: '15톤', operators: ['정기사','한기사'] },
{ equipment_name: '레미콘', specification: '6m3', operators: ['윤기사'] },
{ equipment_name: '펌프카', specification: '36m', operators: ['임기사','송기사'] },
{ equipment_name: '지게차', specification: '3톤', operators: ['오기사'] },
];
function randomWorker() {
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const grp = pick(RANDOM_WORKERS);
return {
work_type: grp.work_type, job_type: grp.job_type,
name: pick(grp.names), man_days: '1.0', amount: String(pick(grp.amounts)),
work_content: pick(RANDOM_WORK_CONTENTS),
};
}
function randomEquip() {
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const eq = pick(RANDOM_EQUIPS);
return {
equipment_name: eq.equipment_name, specification: eq.specification,
equipment_number: `${String.fromCharCode(65 + Math.floor(Math.random()*5))}-${String(Math.floor(Math.random()*900)+100)}`,
operator: pick(eq.operators), man_days: '1.0', work_content: pick(RANDOM_WORK_CONTENTS),
};
}
/* ════════════════════════════════════════════════
메인 컴포넌트
════════════════════════════════════════════════ */
function App() {
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth() + 1);
const [selectedDay, setSelectedDay] = useState(today.getDate());
const [company, setCompany] = useState('(주)주일기업·방화셔터공사');
const [dayStatus, setDayStatus] = useState({});
const [tab, setTab] = useState('workers');
const [attendance, setAttendance] = useState(null);
const [loading, setLoading] = useState(false);
const [workerModalOpen, setWorkerModalOpen] = useState(false);
const [editWorker, setEditWorker] = useState(null);
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [editEquip, setEditEquip] = useState(null);
const [reviewerModalOpen, setReviewerModalOpen] = useState(false);
const [printModalOpen, setPrintModalOpen] = useState(false);
const [selectedWorkers, setSelectedWorkers] = useState(new Set());
const [selectedEquips, setSelectedEquips] = useState(new Set());
const [notes, setNotes] = useState('');
const dateStr = formatDate(year, month, selectedDay);
const dow = getDayOfWeek(year, month, selectedDay);
const dateTitle = `${year}년 ${String(month).padStart(2,'0')}월 ${String(selectedDay).padStart(2,'0')}일 (${DAY_NAMES[dow]}) ${attendance?.weather || '맑음'}`;
// 월별 상태 조회
const fetchMonthStatus = useCallback(async () => {
try {
const data = await api(`/daily-attendances/month-status?year=${year}&month=${month}`);
setDayStatus(data);
} catch {}
}, [year, month]);
// 해당 날짜 출면일보 조회
const fetchAttendance = useCallback(async () => {
setLoading(true);
try {
const data = await api(`/daily-attendances?date=${dateStr}`);
setAttendance(data);
setNotes(data.notes || '');
} catch {}
setLoading(false);
}, [dateStr]);
useEffect(() => { fetchMonthStatus(); }, [fetchMonthStatus]);
useEffect(() => { fetchAttendance(); }, [fetchAttendance]);
function handleRefresh() { fetchAttendance(); fetchMonthStatus(); }
// 특이사항 저장
async function handleSaveNotes() {
if (!attendance?.id) return;
try {
await api(`/daily-attendances/${attendance.id}`, { method: 'PUT', body: JSON.stringify({ notes }) });
alert('특이사항이 저장되었습니다.');
} catch {}
}
// 인원 삭제
async function handleDeleteWorkers() {
if (selectedWorkers.size === 0) return;
if (!confirm(`선택한 ${selectedWorkers.size}건을 삭제하시겠습니까?`)) return;
for (const id of selectedWorkers) {
try { await api(`/attendance-workers/${id}`, { method: 'DELETE' }); } catch {}
}
setSelectedWorkers(new Set());
handleRefresh();
}
// 장비 삭제
async function handleDeleteEquips() {
if (selectedEquips.size === 0) return;
if (!confirm(`선택한 ${selectedEquips.size}건을 삭제하시겠습니까?`)) return;
for (const id of selectedEquips) {
try { await api(`/attendance-equipments/${id}`, { method: 'DELETE' }); } catch {}
}
setSelectedEquips(new Set());
handleRefresh();
}
// 번개 추가 (인원)
async function handleQuickWorker() {
if (!attendance?.id) return;
const data = randomWorker();
data.attendance_id = attendance.id;
data.man_days = parseFloat(data.man_days);
data.amount = parseInt(data.amount);
try {
await api('/attendance-workers', { method: 'POST', body: JSON.stringify(data) });
handleRefresh();
} catch {}
}
// 번개 추가 (장비)
async function handleQuickEquip() {
if (!attendance?.id) return;
const data = randomEquip();
data.attendance_id = attendance.id;
data.man_days = parseFloat(data.man_days);
try {
await api('/attendance-equipments', { method: 'POST', body: JSON.stringify(data) });
handleRefresh();
} catch {}
}
const workers = attendance?.workers || [];
const equipments = attendance?.equipments || [];
const totalManDays = workers.reduce((s, w) => s + parseFloat(w.man_days || 0), 0);
const totalAmount = workers.reduce((s, w) => s + parseInt(w.amount || 0), 0);
const eqTotalManDays = equipments.reduce((s, e) => s + parseFloat(e.man_days || 0), 0);
// 상태 범례 색상
const statusLegend = [
{ label: '작성중', color: '#22c55e' }, { label: '검토중', color: '#ef4444' },
{ label: '승인', color: '#3b82f6' }, { label: '미작성', color: '#d1d5db' },
];
return (
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
<PmisSidebar activePage="daily-attendance" />
<WorkerModal open={workerModalOpen} onClose={() => setWorkerModalOpen(false)} onSaved={handleRefresh} worker={editWorker} attendanceId={attendance?.id} />
<EquipmentModal open={equipModalOpen} onClose={() => setEquipModalOpen(false)} onSaved={handleRefresh} equipment={editEquip} attendanceId={attendance?.id} />
<ReviewerModal open={reviewerModalOpen} onClose={() => setReviewerModalOpen(false)} attendance={attendance} onSaved={handleRefresh} />
<PrintPreviewModal open={printModalOpen} onClose={() => setPrintModalOpen(false)} attendance={attendance} dateStr={dateStr} />
<div className="flex-1 flex flex-col overflow-hidden">
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex items-center gap-2 text-xs text-gray-400 mb-2">
<i className="ri-home-4-line"></i>
<span>Home</span> &gt; <span>시공관리</span> &gt; <span className="text-gray-600">출면일보</span>
{/* 헤더 */}
<div className="bg-white border-b border-gray-200 px-6 pt-3 pb-0">
<div className="flex items-center gap-2 text-xs text-gray-400 mb-1">
<i className="ri-home-4-line"></i> <span>Home</span> &gt; <span>시공관리</span> &gt; <span className="text-gray-600">출면일보</span>
</div>
{/* 필터 바 */}
<div className="flex items-center gap-2 mb-2">
<input type="date" value={dateStr}
onChange={e => { const d = new Date(e.target.value); setYear(d.getFullYear()); setMonth(d.getMonth()+1); setSelectedDay(d.getDate()); }}
className="border border-gray-300 rounded px-3 py-1.5 text-sm" />
<select value={company} onChange={e => setCompany(e.target.value)} className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
<option>()주일기업·방화셔터공사</option>
<option>KCC건설</option>
</select>
<button onClick={handleRefresh} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
<button onClick={() => { setCompany(''); setNotes(''); }} className="bg-gray-200 text-gray-700 px-4 py-1.5 rounded text-sm hover:bg-gray-300">검색초기화</button>
<div className="ml-auto flex items-center gap-3">
{statusLegend.map(s => (
<div key={s.label} className="flex items-center gap-1">
<div className="rounded-full" style={{width:8, height:8, backgroundColor: s.color}}></div>
<span className="text-xs text-gray-500">{s.label}</span>
</div>
))}
</div>
</div>
{/* 날짜 제목 */}
<h1 className="text-lg font-bold text-gray-800 text-center mb-1">{dateTitle}</h1>
{/* 캘린더 스트립 */}
<CalendarStrip year={year} month={month} selectedDay={selectedDay} onSelectDay={setSelectedDay} dayStatus={dayStatus} />
{/* 기능 버튼들 + 탭 */}
<div className="flex items-center justify-between mb-0">
<div className="flex items-center gap-2">
<button className="border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">일괄출력</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">선택일자 출면가져오기</span>
<button className="text-gray-400 hover:text-gray-600"><i className="ri-calendar-line"></i></button>
<button onClick={() => setReviewerModalOpen(true)} className="border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">검토자 확인</button>
<button onClick={() => setPrintModalOpen(true)} className="border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">양식보기</button>
</div>
</div>
{/* 탭 */}
<div className="flex gap-0 border-b-0 mt-1">
<button onClick={() => setTab('workers')}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === 'workers' ? 'border-blue-600 text-blue-700 bg-blue-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>인원</button>
<button onClick={() => setTab('equipment')}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === 'equipment' ? 'border-blue-600 text-blue-700 bg-blue-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>장비</button>
</div>
<h1 className="text-lg font-bold text-gray-800">출면일보</h1>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400">
<i className="ri-tools-line text-5xl block mb-3"></i>
<p className="text-lg font-semibold">출면일보</p>
<p className="text-sm mt-1">준비 중입니다</p>
</div>
{/* 탭 컨텐츠 */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="text-center py-12 text-gray-400">불러오는 ...</div>
) : tab === 'workers' ? (
/* ─── 인원 탭 ─── */
<div>
{/* 추가/삭제 버튼 */}
<div className="flex items-center gap-2 mb-3">
<button onClick={() => { setEditWorker(null); setWorkerModalOpen(true); }} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">추가</button>
<button onClick={handleQuickWorker} className="bg-amber-500 text-white px-3 py-1.5 rounded text-sm font-semibold hover:bg-amber-600" title="랜덤 데이터로 추가">
<i className="ri-flashlight-line"></i>
</button>
{selectedWorkers.size > 0 && (
<button onClick={handleDeleteWorkers} className="bg-red-500 text-white px-3 py-1.5 rounded text-sm hover:bg-red-600">삭제 ({selectedWorkers.size})</button>
)}
</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">
<input type="checkbox" checked={workers.length > 0 && selectedWorkers.size === workers.length}
onChange={() => setSelectedWorkers(workers.length > 0 && selectedWorkers.size === workers.length ? new Set() : new Set(workers.map(w=>w.id)))} />
</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-right font-semibold text-gray-600">공수</th>
<th className="px-3 py-2.5 text-right font-semibold text-gray-600" style={{backgroundColor:'#FFFDE7'}}>금액</th>
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">작업내용</th>
</tr>
</thead>
<tbody>
{workers.map(w => (
<tr key={w.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition cursor-pointer"
onClick={() => { setEditWorker(w); setWorkerModalOpen(true); }}>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedWorkers.has(w.id)}
onChange={() => setSelectedWorkers(prev => { const s = new Set(prev); s.has(w.id) ? s.delete(w.id) : s.add(w.id); return s; })} />
</td>
<td className="px-3 py-2 text-gray-700">{w.work_type}</td>
<td className="px-3 py-2 text-gray-700">{w.job_type}</td>
<td className="px-3 py-2 font-medium text-gray-800">{w.name}</td>
<td className="px-3 py-2 text-right text-gray-700">{w.man_days}</td>
<td className="px-3 py-2 text-right text-gray-700" style={{backgroundColor:'#FFFDE7'}}>{parseInt(w.amount||0).toLocaleString()}</td>
<td className="px-3 py-2 text-gray-700">{w.work_content}</td>
</tr>
))}
{workers.length === 0 && (
<tr><td colSpan={7} className="text-center py-8 text-gray-400">등록된 인원이 없습니다.</td></tr>
)}
{/* 합계 */}
<tr className="bg-gray-50 font-semibold border-t border-gray-300">
<td colSpan={4} className="px-3 py-2 text-center text-gray-700">합계</td>
<td className="px-3 py-2 text-right text-gray-800">{totalManDays}</td>
<td className="px-3 py-2 text-right text-gray-800" style={{backgroundColor:'#FFFDE7'}}>{totalAmount.toLocaleString()}</td>
<td className="px-3 py-2"></td>
</tr>
</tbody>
</table>
</div>
<div className="text-xs text-gray-400 mt-2"> 합계는 저장 후에 반영됩니다.</div>
{/* 특이사항 */}
<div className="mt-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-gray-700">특이사항</span>
<button onClick={handleSaveNotes} className="text-xs text-blue-600 hover:text-blue-800">저장</button>
</div>
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={3}
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" placeholder="특이사항을 입력하세요"></textarea>
</div>
</div>
) : (
/* ─── 장비 탭 ─── */
<div>
<div className="flex items-center gap-2 mb-3">
<button onClick={() => { setEditEquip(null); setEquipModalOpen(true); }} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">추가</button>
<button onClick={handleQuickEquip} className="bg-amber-500 text-white px-3 py-1.5 rounded text-sm font-semibold hover:bg-amber-600" title="랜덤 데이터로 추가">
<i className="ri-flashlight-line"></i>
</button>
{selectedEquips.size > 0 && (
<button onClick={handleDeleteEquips} className="bg-red-500 text-white px-3 py-1.5 rounded text-sm hover:bg-red-600">삭제 ({selectedEquips.size})</button>
)}
</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">
<input type="checkbox" checked={equipments.length > 0 && selectedEquips.size === equipments.length}
onChange={() => setSelectedEquips(equipments.length > 0 && selectedEquips.size === equipments.length ? new Set() : new Set(equipments.map(e=>e.id)))} />
</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-right font-semibold text-gray-600">공수</th>
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">작업내용</th>
</tr>
</thead>
<tbody>
{equipments.map(eq => (
<tr key={eq.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition cursor-pointer"
onClick={() => { setEditEquip(eq); setEquipModalOpen(true); }}>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selectedEquips.has(eq.id)}
onChange={() => setSelectedEquips(prev => { const s = new Set(prev); s.has(eq.id) ? s.delete(eq.id) : s.add(eq.id); return s; })} />
</td>
<td className="px-3 py-2 font-medium text-gray-800">{eq.equipment_name}</td>
<td className="px-3 py-2 text-gray-700">{eq.specification}</td>
<td className="px-3 py-2 text-gray-600">{eq.equipment_number}</td>
<td className="px-3 py-2 text-gray-700">{eq.operator}</td>
<td className="px-3 py-2 text-right text-gray-700">{eq.man_days}</td>
<td className="px-3 py-2 text-gray-700">{eq.work_content}</td>
</tr>
))}
{equipments.length === 0 && (
<tr><td colSpan={7} className="text-center py-8 text-gray-400">등록된 장비가 없습니다.</td></tr>
)}
<tr className="bg-gray-50 font-semibold border-t border-gray-300">
<td colSpan={5} className="px-3 py-2 text-center text-gray-700">합계</td>
<td className="px-3 py-2 text-right text-gray-800">{eqTotalManDays}</td>
<td className="px-3 py-2"></td>
</tr>
</tbody>
</table>
</div>
<div className="text-xs text-gray-400 mt-2"> 합계는 저장 후에 반영됩니다.</div>
{/* 특이사항 */}
<div className="mt-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-gray-700">특이사항</span>
<button onClick={handleSaveNotes} className="text-xs text-blue-600 hover:text-blue-800">저장</button>
</div>
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={3}
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" placeholder="특이사항을 입력하세요"></textarea>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -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\PmisDailyAttendanceController;
use App\Http\Controllers\Juil\PmisEquipmentController;
use App\Http\Controllers\Juil\PmisMaterialController;
use App\Http\Controllers\Juil\PmisWorkforceController;
@@ -1768,6 +1769,18 @@
Route::post('/work-volumes', [PmisWorkVolumeController::class, 'store']);
Route::put('/work-volumes/{id}', [PmisWorkVolumeController::class, 'update']);
Route::delete('/work-volumes/{id}', [PmisWorkVolumeController::class, 'destroy']);
// 출면일보 CRUD
Route::get('/daily-attendances', [PmisDailyAttendanceController::class, 'show']);
Route::get('/daily-attendances/month-status', [PmisDailyAttendanceController::class, 'monthStatus']);
Route::put('/daily-attendances/{id}', [PmisDailyAttendanceController::class, 'update']);
Route::put('/daily-attendances/{id}/reviewers', [PmisDailyAttendanceController::class, 'saveReviewers']);
Route::post('/attendance-workers', [PmisDailyAttendanceController::class, 'workerStore']);
Route::put('/attendance-workers/{id}', [PmisDailyAttendanceController::class, 'workerUpdate']);
Route::delete('/attendance-workers/{id}', [PmisDailyAttendanceController::class, 'workerDestroy']);
Route::post('/attendance-equipments', [PmisDailyAttendanceController::class, 'equipmentStore']);
Route::put('/attendance-equipments/{id}', [PmisDailyAttendanceController::class, 'equipmentUpdate']);
Route::delete('/attendance-equipments/{id}', [PmisDailyAttendanceController::class, 'equipmentDestroy']);
});
// 공사현장 사진대지