feat(users): organically link Laptop Assets and User Management with multi-asset assigned/temporary laptops support

This commit is contained in:
NAS-Admin 2026-06-01 09:22:26 +09:00
parent 304c5e87ab
commit d11dc0983f
2 changed files with 184 additions and 36 deletions

36
api.php
View file

@ -139,6 +139,7 @@ FROM departments d JOIN dept_path dp ON d.parent_id = dp.id
) )
SELECT u.*, dp.path as dept_name, SELECT u.*, dp.path as dept_name,
(SELECT asset_tag FROM laptop_assets WHERE current_user_id = u.id ORDER BY id DESC LIMIT 1) as laptop_tag, (SELECT asset_tag FROM laptop_assets WHERE current_user_id = u.id ORDER BY id DESC LIMIT 1) as laptop_tag,
(SELECT GROUP_CONCAT(asset_tag, ',') FROM laptop_assets WHERE current_user_id = u.id) as laptop_tags,
(SELECT purchase_date FROM laptop_assets WHERE current_user_id = u.id ORDER BY id DESC LIMIT 1) as laptop_purchase_date, (SELECT purchase_date FROM laptop_assets WHERE current_user_id = u.id ORDER BY id DESC LIMIT 1) as laptop_purchase_date,
(SELECT m.model_name FROM laptop_assets a JOIN laptop_models m ON a.model_id = m.id WHERE a.current_user_id = u.id ORDER (SELECT m.model_name FROM laptop_assets a JOIN laptop_models m ON a.model_id = m.id WHERE a.current_user_id = u.id ORDER
BY a.id DESC LIMIT 1) as laptop_model_name, BY a.id DESC LIMIT 1) as laptop_model_name,
@ -327,6 +328,41 @@ WHERE a.rental_user_id = ?";
echo json_encode($stmt->fetchAll()); echo json_encode($stmt->fetchAll());
break; break;
case 'get_user_laptops':
$user_id = (int) $_GET['user_id'];
$query = "SELECT a.*, m.model_name, m.manufacturer
FROM laptop_assets a
JOIN laptop_models m ON a.model_id = m.id
WHERE a.current_user_id = ?";
$stmt = $db->prepare($query);
$stmt->execute([$user_id]);
echo json_encode($stmt->fetchAll());
break;
case 'unassign_laptop':
$data = json_decode(file_get_contents('php://input'), true);
$asset_id = (int)$data['id'];
// Get asset details to log
$old_stmt = $db->prepare("SELECT assigned_user_name FROM laptop_assets WHERE id = ?");
$old_stmt->execute([$asset_id]);
$old_name = $old_stmt->fetchColumn() ?: '';
// Update to stock
$stmt = $db->prepare("UPDATE laptop_assets SET
current_user_id = NULL,
status = 'stock',
assigned_user_name = '업무용(TEMP_44666b)'
WHERE id = ?");
$stmt->execute([$asset_id]);
// Log history
$log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date, note) VALUES (?, 'assignment', '재고(반납)', ?, '직원 페이지에서 배정 해제됨')");
$log_stmt->execute([$asset_id, date('Y-m-d')]);
echo json_encode(['success' => true]);
break;
case 'update_asset_remarks': case 'update_asset_remarks':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("UPDATE laptop_assets SET remarks = ? WHERE id = ?"); $stmt = $db->prepare("UPDATE laptop_assets SET remarks = ? WHERE id = ?");

176
users.php
View file

@ -298,22 +298,23 @@
<span x-text="user.position || '-'"></span> <span x-text="user.position || '-'"></span>
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap cursor-pointer relative z-10" <td class="px-6 py-4 cursor-pointer relative z-10"
@click="editUser(user)"> @click="editUser(user)">
<template x-if="user.laptop_tag"> <template x-if="user.laptop_tags">
<div> <div class="flex flex-col gap-1.5">
<div class="text-[11px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded inline-block mb-1" <div class="flex flex-wrap gap-1 max-w-[200px]">
x-text="user.laptop_tag"></div> <template x-for="tag in user.laptop_tags.split(',')">
<div class="text-[10px] text-slate-500 font-bold"> <span class="text-[10px] font-black text-blue-600 bg-blue-50/80 px-2 py-0.5 rounded-lg border border-blue-100 shadow-sm"
<span x-text="tag"></span>
x-text="user.laptop_purchase_date ? user.laptop_purchase_date.substring(0,4) : ''"></span> </template>
<span x-show="user.laptop_purchase_date && user.laptop_model_name"> / </div>
</span> <div class="text-[10px] text-slate-400 font-medium truncate max-w-[200px]"
<span x-text="user.laptop_model_name || ''"></span> :title="user.laptop_model_name"
x-text="user.laptop_model_name ? (user.laptop_purchase_date ? user.laptop_purchase_date.substring(0,4) + ' / ' + user.laptop_model_name : user.laptop_model_name) : ''">
</div> </div>
</div> </div>
</template> </template>
<template x-if="!user.laptop_tag"> <template x-if="!user.laptop_tags">
<span class="text-[10px] text-slate-300 italic">미배정</span> <span class="text-[10px] text-slate-300 italic">미배정</span>
</template> </template>
</td> </td>
@ -530,33 +531,86 @@
</div> </div>
</div> </div>
<!-- Laptop Info Section (Read-only) --> <!-- Laptop Info Section (Dynamic & Interactive) -->
<template x-if="isEdit && formData.laptop_tag"> <template x-if="isEdit">
<div class="bg-blue-50/50 p-6 rounded-[2rem] border border-blue-100/50 space-y-4 mt-6"> <div class="mt-6 border-t border-slate-100 pt-6 space-y-6">
<div class="flex items-center gap-2 mb-2"> <!-- 정식 배정 노트북 -->
<div class="w-1.5 h-1.5 rounded-full bg-blue-500"></div> <div class="bg-blue-50/40 p-6 rounded-[2rem] border border-blue-100/40 space-y-4">
<h4 class="text-xs font-black text-blue-600 uppercase tracking-widest">사용 중인 노트북 정보</h4> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></div>
<h4 class="text-xs font-black text-blue-600 uppercase tracking-widest">정식 배정 노트북 정보</h4>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> </div>
<template x-if="assignedLaptops.length === 0">
<div class="text-center py-4 bg-white/50 rounded-2xl border border-dashed border-slate-200">
<p class="text-xs text-slate-400 italic">현재 배정된 정식 노트북이 없습니다.</p>
</div>
</template>
<template x-if="assignedLaptops.length > 0">
<div class="grid grid-cols-1 gap-3">
<template x-for="laptop in assignedLaptops" :key="laptop.id">
<div class="px-5 py-4 bg-white border border-slate-200/60 rounded-2xl flex items-center justify-between shadow-sm">
<div> <div>
<label <div class="flex items-center gap-2 mb-1">
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">자산번호</label> <span class="text-xs font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded-md border border-blue-100" x-text="laptop.asset_tag"></span>
<div class="px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-blue-600" <span class="text-sm font-bold text-slate-800" x-text="laptop.model_name"></span>
x-text="formData.laptop_tag"></div>
</div> </div>
<div class="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
<span x-text="laptop.manufacturer"></span>
<span x-show="laptop.purchase_date"> </span>
<span x-text="laptop.purchase_date"></span>
</div>
</div>
<button type="button" @click="unassignPermanentLaptop(laptop)"
class="px-3.5 py-1.5 bg-rose-50 hover:bg-rose-100 text-rose-600 text-xs font-bold rounded-xl transition-all border border-rose-100 active:scale-95">
배정 해제
</button>
</div>
</template>
</div>
</template>
</div>
<!-- 단기 임대 노트북 -->
<div class="bg-emerald-50/40 p-6 rounded-[2rem] border border-emerald-100/40 space-y-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div>
<h4 class="text-xs font-black text-emerald-600 uppercase tracking-widest">단기 임대 노트북 정보</h4>
</div>
</div>
<template x-if="temporaryLaptops.length === 0">
<div class="text-center py-4 bg-white/50 rounded-2xl border border-dashed border-slate-200">
<p class="text-xs text-slate-400 italic">현재 임대 중인 단기 노트북이 없습니다.</p>
</div>
</template>
<template x-if="temporaryLaptops.length > 0">
<div class="grid grid-cols-1 gap-3">
<template x-for="laptop in temporaryLaptops" :key="laptop.id">
<div class="px-5 py-4 bg-white border border-slate-200/60 rounded-2xl flex items-center justify-between shadow-sm">
<div> <div>
<label <div class="flex items-center gap-2 mb-1">
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">모델명</label> <span class="text-xs font-black text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-md border border-emerald-100" x-text="laptop.asset_tag"></span>
<div class="px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700" <span class="text-sm font-bold text-slate-800" x-text="laptop.model_name"></span>
x-text="formData.laptop_model_name || '-'"></div>
</div> </div>
<div> <div class="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
<label <span x-text="laptop.manufacturer"></span>
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">취득일</label> <span x-show="laptop.rental_start_date"> 시작일: </span>
<div class="px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700" <span x-text="laptop.rental_start_date"></span>
x-text="formData.laptop_purchase_date || '-'"></div> <span x-show="laptop.rental_end_scheduled"> 반납예정일: </span>
<span x-text="laptop.rental_end_scheduled"></span>
</div> </div>
</div> </div>
<button type="button" @click="returnTemporaryLaptop(laptop)"
class="px-3.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 text-xs font-bold rounded-xl transition-all border border-amber-100 active:scale-95">
반납 처리
</button>
</div>
</template>
</div>
</template>
</div>
</div> </div>
</template> </template>
<div class="pt-6 flex gap-4"> <div class="pt-6 flex gap-4">
@ -719,6 +773,8 @@
showRestoreModal: false, showRestoreModal: false,
showExportModal: false, showExportModal: false,
userRentals: [], userRentals: [],
assignedLaptops: [],
temporaryLaptops: [],
archivedUsers: [], archivedUsers: [],
selectedColumns: ['name', 'emp_id', 'dept_name', 'position', 'email', 'mobile', 'status', 'laptop_tag'], selectedColumns: ['name', 'emp_id', 'dept_name', 'position', 'email', 'mobile', 'status', 'laptop_tag'],
columnOptions: { columnOptions: {
@ -892,6 +948,62 @@
this.isEdit = true; this.isEdit = true;
this.formData = { ...user }; this.formData = { ...user };
this.showModal = true; this.showModal = true;
this.assignedLaptops = [];
this.temporaryLaptops = [];
this.fetchAssignedLaptops(user.id);
this.fetchTemporaryLaptops(user.id);
},
fetchAssignedLaptops(userId) {
fetch(`api.php?action=get_user_laptops&user_id=${userId}`)
.then(res => res.json())
.then(data => {
this.assignedLaptops = data;
});
},
fetchTemporaryLaptops(userId) {
fetch(`api.php?action=get_user_rentals&user_id=${userId}`)
.then(res => res.json())
.then(data => {
this.temporaryLaptops = data;
});
},
unassignPermanentLaptop(laptop) {
window.showConfirm(`[${laptop.asset_tag}] 정식 배정 노트북을 배정 해제하시겠습니까?`, () => {
fetch('api.php?action=unassign_laptop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: laptop.id })
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('배정이 해제되었습니다.', '성공', 'success');
this.fetchAssignedLaptops(this.formData.id);
this.fetchUsers(); // Refresh the main user list table
} else {
window.showAlert(data.error || '오류가 발생했습니다.', '실패', 'error');
}
});
}, '배정 해제 확인');
},
returnTemporaryLaptop(laptop) {
window.showConfirm(`[${laptop.asset_tag}] 단기 임대 노트북을 반납 처리하시겠습니까?`, () => {
fetch('api.php?action=return_rental', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: laptop.id })
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('반납 처리가 완료되었습니다.', '성공', 'success');
this.fetchTemporaryLaptops(this.formData.id);
this.fetchUsers(); // Refresh the main user list table
} else {
window.showAlert(data.error || '오류가 발생했습니다.', '실패', 'error');
}
});
}, '단기 임대 반납 확인');
}, },
submitUser() { submitUser() {