feat: 관리자 인증 시스템을 도입하고 사용자 관리 페이지에 분류, 노트북, 단기 임대 정보를 추가하며 일반 대여 기능을 구현했습니다.

This commit is contained in:
NAS-Admin 2026-02-09 00:40:16 +09:00
parent 3f789c81ac
commit 5e7a53e731
15 changed files with 1971 additions and 229 deletions

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
# [업로드 방지] 로컬 테스트 파일들이 GitLab으로 올라가지 않게 함 # [업로드 방지] 로컬 테스트 파일들이 GitLab으로 올라가지 않게 함
dda/, ex/ dda/, ex/
*.sqlite # *.sqlite
# *.db # *.db

231
admins.php Normal file
View file

@ -0,0 +1,231 @@
<?php
require_once 'auth_check.php';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 관리 | ASSET</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
<style>
body {
font-family: 'Pretendard', sans-serif;
}
[x-cloak] {
display: none !important;
}
.modal-bg {
background-color: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(4px);
}
</style>
</head>
<body class="bg-[#f8fafc] text-slate-800" x-data="adminManagement()">
<?php include 'nav.php'; ?>
<main class="max-w-[1200px] mx-auto p-8 pt-10">
<header class="mb-10 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 class="text-3xl font-extrabold text-slate-900 tracking-tight">System Administrators</h2>
<p class="text-slate-500 mt-1">시스템 관리자 계정 생성, 삭제 비밀번호 관리</p>
</div>
<button @click="openAddModal()"
class="px-6 py-3 bg-slate-900 text-white rounded-2xl font-bold shadow-lg shadow-slate-200 hover:bg-slate-800 transition-all flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
관리자 추가
</button>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="admin in admins" :key="admin.id">
<div
class="bg-white rounded-[2rem] p-8 shadow-sm border border-slate-100 hover:shadow-xl transition-all relative overflow-hidden group">
<div
class="absolute top-0 right-0 w-32 h-32 bg-slate-50 rounded-full -translate-y-16 translate-x-16 group-hover:scale-150 transition-transform duration-500">
</div>
<div class="relative z-10">
<div
class="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mb-6 shadow-inner text-slate-400 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="mb-6">
<h3 class="text-xl font-black text-slate-900" x-text="admin.admin_name"></h3>
<p class="text-[10px] font-bold text-blue-500 uppercase tracking-[0.2em] mt-1"
x-text="admin.login_id"></p>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between py-3 border-t border-slate-50">
<span
class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Created</span>
<span class="text-xs font-bold text-slate-600"
x-text="admin.created_at.split(' ')[0]"></span>
</div>
<div class="flex gap-2 pt-2">
<button @click="openPasswordModal(admin)"
class="flex-1 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold text-xs hover:bg-slate-200 transition-all">PW
변경</button>
<button @click="deleteAdmin(admin)"
class="flex-1 py-3 bg-rose-50 text-rose-500 rounded-xl font-bold text-xs hover:bg-rose-500 hover:text-white transition-all border border-rose-100">계정
삭제</button>
</div>
</div>
</div>
</div>
</template>
</div>
</main>
<!-- Add Admin Modal -->
<div x-show="showAddModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showAddModal = false"></div>
<div class="bg-white rounded-[2.5rem] p-10 max-w-sm w-full relative z-[111] shadow-2xl">
<h3 class="text-2xl font-black text-slate-900 mb-8">관리자 계정 생성</h3>
<form @submit.prevent="addAdmin" class="space-y-6">
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">이름</label>
<input type="text" x-model="formData.admin_name" required placeholder="User Name"
class="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">아이디</label>
<input type="text" x-model="formData.login_id" required placeholder="Login ID"
class="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">초기 비밀번호</label>
<input type="password" x-model="formData.password" required placeholder="Password"
class="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
</div>
<div class="flex gap-3 pt-4">
<button type="button" @click="showAddModal = false"
class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold hover:bg-slate-200 transition-all">취소</button>
<button type="submit"
class="flex-1 py-4 bg-blue-600 text-white rounded-2xl font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">생성하기</button>
</div>
</form>
</div>
</div>
<!-- Password Management Modal -->
<div x-show="showPwModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showPwModal = false"></div>
<div class="bg-white rounded-[2.5rem] p-10 max-w-sm w-full relative z-[111] shadow-2xl">
<h3 class="text-2xl font-black text-slate-900 mb-2">비밀번호 변경</h3>
<p class="text-blue-500 font-bold text-xs mb-8"
x-text="selectedAdmin?.admin_name + ' (' + selectedAdmin?.login_id + ')'"></p>
<form @submit.prevent="updatePassword" class="space-y-6">
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1"> 비밀번호</label>
<input type="password" x-model="newPassword" required placeholder="New Password"
class="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
</div>
<div class="flex gap-3 pt-4">
<button type="button" @click="showPwModal = false"
class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold hover:bg-slate-200 transition-all">취소</button>
<button type="submit"
class="flex-1 py-4 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-800 transition-all shadow-lg">변경하기</button>
</div>
</form>
</div>
</div>
<script>
function adminManagement() {
return {
admins: [],
showAddModal: false,
showPwModal: false,
selectedAdmin: null,
newPassword: '',
formData: { admin_name: '', login_id: '', password: '' },
init() {
this.fetchAdmins();
},
fetchAdmins() {
fetch('api.php?action=get_admins').then(res => res.json()).then(data => {
this.admins = data;
});
},
openAddModal() {
this.formData = { admin_name: '', login_id: '', password: '' };
this.showAddModal = true;
},
addAdmin() {
fetch('api.php?action=add_admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('신규 관리자가 등록되었습니다.', '성공', 'success');
this.showAddModal = false;
this.fetchAdmins();
}
});
},
openPasswordModal(admin) {
this.selectedAdmin = admin;
this.newPassword = '';
this.showPwModal = true;
},
updatePassword() {
fetch('api.php?action=update_admin_password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: this.selectedAdmin.id, password: this.newPassword })
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('비밀번호가 성공적으로 변경되었습니다.', '성공', 'success');
this.showPwModal = false;
}
});
},
deleteAdmin(admin) {
window.showConfirm(`${admin.admin_name} 계정을 정말로 삭제하시겠습니까?`, () => {
fetch('api.php?action=delete_admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: admin.id })
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('계정이 삭제되었습니다.', '완료', 'success');
this.fetchAdmins();
} else {
window.showAlert(data.error, '실패', 'error');
}
});
}, '계정 삭제 확인');
}
}
}
</script>
</body>
</html>

423
api.php
View file

@ -1,17 +1,81 @@
<?php <?php
/**
* api.php
* FKI Asset System RESTful API
*/
require_once 'config.php'; require_once 'config.php';
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
session_start();
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
// 로그인 세션 체크 (login 액션 제외)
$public_actions = ['login'];
if (!isset($_SESSION['admin_id']) && !in_array($action, $public_actions)) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
try { try {
switch ($action) { switch ($action) {
case 'login':
$data = json_decode(file_get_contents('php://input'), true);
$login_id = $data['login_id'] ?? '';
$password = $data['password'] ?? '';
$stmt = $db->prepare("SELECT * FROM administrators WHERE login_id = ?");
$stmt->execute([$login_id]);
$admin = $stmt->fetch();
if ($admin && password_verify($password, $admin['password_hash'])) {
$_SESSION['admin_id'] = $admin['id'];
$_SESSION['admin_login_id'] = $admin['login_id'];
$_SESSION['admin_name'] = $admin['admin_name'];
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => '아이디 또는 비밀번호가 일치하지 않습니다.']);
}
break;
case 'logout':
session_destroy();
echo json_encode(['success' => true]);
break;
case 'get_admins':
$stmt = $db->query("SELECT id, login_id, admin_name, created_at FROM administrators ORDER BY id ASC");
echo json_encode($stmt->fetchAll());
break;
case 'add_admin':
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("INSERT INTO administrators (login_id, password_hash, admin_name) VALUES (?, ?, ?)");
$stmt->execute([
$data['login_id'],
password_hash($data['password'], PASSWORD_DEFAULT),
$data['admin_name']
]);
echo json_encode(['success' => true]);
break;
case 'update_admin_password':
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("UPDATE administrators SET password_hash = ? WHERE id = ?");
$stmt->execute([
password_hash($data['password'], PASSWORD_DEFAULT),
$data['id']
]);
echo json_encode(['success' => true]);
break;
case 'delete_admin':
$data = json_decode(file_get_contents('php://input'), true);
if ($data['id'] == $_SESSION['admin_id']) {
echo json_encode(['success' => false, 'error' => '자기 자신은 삭제할 수 없습니다.']);
break;
}
$stmt = $db->prepare("DELETE FROM administrators WHERE id = ?");
$stmt->execute([$data['id']]);
echo json_encode(['success' => true]);
break;
case 'get_dashboard_stats': case 'get_dashboard_stats':
// Laptops // Laptops
$laptop_total = $db->query("SELECT COUNT(*) FROM laptop_assets")->fetchColumn(); $laptop_total = $db->query("SELECT COUNT(*) FROM laptop_assets")->fetchColumn();
@ -39,14 +103,19 @@ try {
case 'get_users': case 'get_users':
$query = "WITH RECURSIVE dept_path(id, path) AS ( $query = "WITH RECURSIVE dept_path(id, path) AS (
SELECT id, name FROM departments WHERE parent_id IS NULL SELECT id, name FROM departments WHERE parent_id IS NULL
UNION ALL UNION ALL
SELECT d.id, dp.path || ' > ' || d.name SELECT d.id, dp.path || ' > ' || d.name
FROM departments d JOIN dept_path dp ON d.parent_id = dp.id 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,
FROM users u (SELECT asset_tag FROM laptop_assets WHERE current_user_id = u.id ORDER BY id DESC LIMIT 1) as laptop_tag,
LEFT JOIN dept_path dp ON u.department_id = dp.id"; (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
BY a.id DESC LIMIT 1) as laptop_model_name,
(SELECT COUNT(*) FROM laptop_assets WHERE rental_user_id = u.id) as rental_count
FROM users u
LEFT JOIN dept_path dp ON u.department_id = dp.id";
$search = $_GET['search'] ?? ''; $search = $_GET['search'] ?? '';
if ($search) { if ($search) {
$query .= " WHERE u.name LIKE :search OR u.emp_id LIKE :search OR u.email LIKE :search"; $query .= " WHERE u.name LIKE :search OR u.emp_id LIKE :search OR u.email LIKE :search";
@ -61,7 +130,16 @@ try {
case 'add_user': case 'add_user':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$status = $data['status'] ?? 'active'; $status = $data['status'] ?? 'active';
$department_id = $data['department_id']; $department_id = $data['department_id'] ?: null;
$emp_id = $data['emp_id'];
// Duplicate emp_id check
$check = $db->prepare("SELECT id FROM users WHERE emp_id = ?");
$check->execute([$emp_id]);
if ($check->fetch()) {
echo json_encode(['success' => false, 'error' => "이미 존재하는 사번(${emp_id})입니다."]);
break;
}
// Auto-relocate based on status // Auto-relocate based on status
if ($status === 'on_leave' || $status === 'retired') { if ($status === 'on_leave' || $status === 'retired') {
@ -71,25 +149,40 @@ try {
$department_id = $target_dept; $department_id = $target_dept;
} }
$stmt = $db->prepare("INSERT INTO users (emp_id, name, department_id, position, email, phone, mobile, accounting_type, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); try {
$stmt->execute([ $stmt = $db->prepare("INSERT INTO users (emp_id, name, department_id, position, email, phone, mobile, accounting_type,
$data['emp_id'], status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$data['name'], $stmt->execute([
$department_id, $emp_id,
$data['position'], $data['name'],
$data['email'], $department_id,
$data['phone'] ?? '', $data['position'],
$data['mobile'], $data['email'],
$data['accounting_type'], $data['phone'] ?? '',
$status $data['mobile'],
]); $data['accounting_type'],
echo json_encode(['success' => true]); $status
]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
break; break;
case 'update_user': case 'update_user':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$status = $data['status']; $status = $data['status'];
$department_id = $data['department_id']; $department_id = $data['department_id'] ?: null;
$emp_id = $data['emp_id'];
$user_id = $data['id'];
// Duplicate emp_id check (excluding current user)
$check = $db->prepare("SELECT id FROM users WHERE emp_id = ? AND id != ?");
$check->execute([$emp_id, $user_id]);
if ($check->fetch()) {
echo json_encode(['success' => false, 'error' => "이미 존재하는 사번(${emp_id})입니다."]);
break;
}
// Auto-relocate based on status // Auto-relocate based on status
if ($status === 'on_leave' || $status === 'retired') { if ($status === 'on_leave' || $status === 'retired') {
@ -99,55 +192,125 @@ try {
$department_id = $target_dept; $department_id = $target_dept;
} }
$stmt = $db->prepare("UPDATE users SET emp_id = ?, name = ?, department_id = ?, position = ?, email = ?, phone = ?, mobile = ?, accounting_type = ?, status = ? WHERE id = ?"); try {
$stmt->execute([ $stmt = $db->prepare("UPDATE users SET emp_id = ?, name = ?, department_id = ?, position = ?, email = ?, phone = ?,
$data['emp_id'], mobile = ?, accounting_type = ?, status = ? WHERE id = ?");
$data['name'], $stmt->execute([
$department_id, $emp_id,
$data['position'], $data['name'],
$data['email'], $department_id,
$data['phone'] ?? '', $data['position'],
$data['mobile'], $data['email'],
$data['accounting_type'], $data['phone'] ?? '',
$status, $data['mobile'],
$data['id'] $data['accounting_type'],
]); $status,
echo json_encode(['success' => true]); $user_id
]);
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
break; break;
case 'get_laptops': case 'get_laptops':
$query = "SELECT a.*, m.model_name, m.manufacturer, m.specs, m.processor, m.ram, m.storage, u.name as user_name $query = "SELECT a.*, m.model_name, m.manufacturer, m.specs, m.processor, m.ram, m.storage, u.name as user_name
FROM laptop_assets a FROM laptop_assets a
LEFT JOIN laptop_models m ON a.model_id = m.id LEFT JOIN laptop_models m ON a.model_id = m.id
LEFT JOIN users u ON a.current_user_id = u.id"; LEFT JOIN users u ON a.current_user_id = u.id";
echo json_encode($db->query($query)->fetchAll()); echo json_encode($db->query($query)->fetchAll());
break; break;
case 'get_rental_laptops': case 'get_rental_laptops':
$query = "SELECT a.*, m.model_name, m.manufacturer, m.product_name $query = "SELECT a.*, m.model_name, m.manufacturer, m.product_name
FROM laptop_assets a FROM laptop_assets a
LEFT JOIN laptop_models m ON a.model_id = m.id LEFT JOIN laptop_models m ON a.model_id = m.id
WHERE a.assigned_user_name LIKE '%업무용%'"; WHERE (a.assigned_user_name LIKE '%업무용%' OR a.current_user_id IS NULL)";
echo json_encode($db->query($query)->fetchAll()); echo json_encode($db->query($query)->fetchAll());
break; break;
case 'update_rental': case 'update_rental':
$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 rental_user_name = ? WHERE id = ?"); $startDate = $data['rental_start_date'] ?: date('Y-m-d');
$stmt->execute([$data['rental_user_name'], $data['id']]);
$stmt = $db->prepare("UPDATE laptop_assets SET
rental_user_id = ?,
rental_user_name = ?,
rental_start_date = ?,
rental_end_scheduled = ?,
rental_reason = ?,
rental_peripherals = ?,
remarks = ?
WHERE id = ?");
$stmt->execute([
$data['rental_user_id'] ?? null,
$data['rental_user_name'],
$startDate,
$data['rental_end_scheduled'] ?? null,
$data['rental_reason'] ?? '',
$data['rental_peripherals'] ?? '',
$data['remarks'] ?? '',
$data['id']
]);
// Log the rental action // Log the rental action
if ($data['rental_user_name']) { $peripherals = $data['rental_peripherals'] ? "\n부속품: " . $data['rental_peripherals'] : "";
$log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date) VALUES (?, 'rental', ?, ?)"); $note = "사유: " . ($data['rental_reason'] ?? '없음') . $peripherals;
$log_stmt->execute([$data['id'], $data['rental_user_name'], date('Y-m-d')]); $log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date, note) VALUES (?,
'rental', ?, ?, ?)");
$log_stmt->execute([$data['id'], $data['rental_user_name'], $startDate, $note]);
echo json_encode(['success' => true]);
break;
case 'return_rental':
$data = json_decode(file_get_contents('php://input'), true);
$asset = $db->query("SELECT * FROM laptop_assets WHERE id = " . (int) $data['id'])->fetch();
if ($asset) {
// 반납 처리: 임대 정보는 초기화하되 비고(remarks)는 유지
$stmt = $db->prepare("UPDATE laptop_assets SET
rental_user_id = NULL,
rental_user_name = NULL,
rental_start_date = NULL,
rental_end_scheduled = NULL,
rental_reason = NULL,
rental_peripherals = NULL
WHERE id = ?");
$stmt->execute([$data['id']]);
// Log return
$log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date, note) VALUES (?,
'return', ?, ?, ?)");
$log_stmt->execute([$data['id'], $asset['rental_user_name'], date('Y-m-d'), '반납 완료']);
} }
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
break; break;
case 'get_user_rentals':
$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.rental_user_id = ?";
$stmt = $db->prepare($query);
$stmt->execute([$user_id]);
echo json_encode($stmt->fetchAll());
break;
case 'update_asset_remarks':
$data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("UPDATE laptop_assets SET remarks = ? WHERE id = ?");
$stmt->execute([$data['remarks'] ?? '', $data['id']]);
echo json_encode(['success' => true]);
break;
case 'get_asset_history': case 'get_asset_history':
$asset_id = $_GET['asset_id']; $asset_id = $_GET['asset_id'];
$rental_logs = $db->query("SELECT * FROM asset_history WHERE asset_id = $asset_id AND log_type = 'rental' ORDER BY id DESC LIMIT 20")->fetchAll(); $rental_logs = $db->query("SELECT * FROM asset_history WHERE asset_id = $asset_id AND log_type = 'rental' ORDER BY id
$assign_logs = $db->query("SELECT * FROM asset_history WHERE asset_id = $asset_id AND log_type = 'assignment' ORDER BY id DESC LIMIT 20")->fetchAll(); DESC LIMIT 20")->fetchAll();
$assign_logs = $db->query("SELECT * FROM asset_history WHERE asset_id = $asset_id AND log_type = 'assignment' ORDER BY
id DESC LIMIT 20")->fetchAll();
echo json_encode([ echo json_encode([
'rental' => $rental_logs, 'rental' => $rental_logs,
'assignment' => $assign_logs 'assignment' => $assign_logs
@ -197,10 +360,10 @@ try {
case 'add_model': case 'add_model':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("INSERT INTO laptop_models ( $stmt = $db->prepare("INSERT INTO laptop_models (
model_name, manufacturer, specs, cpu, npu, hdd0_model, hdd0_capacity, model_name, manufacturer, specs, cpu, npu, hdd0_model, hdd0_capacity,
hdd1_model, hdd1_capacity, ram, asset_status, assigned_user, fixed_ip, hdd1_model, hdd1_capacity, ram, asset_status, assigned_user, fixed_ip,
remarks, options, power_rating, purchase_date, vendor, product_name remarks, options, power_rating, purchase_date, vendor, product_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([ $stmt->execute([
$data['model_name'], $data['model_name'],
$data['manufacturer'], $data['manufacturer'],
@ -227,13 +390,13 @@ try {
case 'update_model': case 'update_model':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("UPDATE laptop_models SET $stmt = $db->prepare("UPDATE laptop_models SET
model_name = ?, manufacturer = ?, specs = ?, cpu = ?, npu = ?, model_name = ?, manufacturer = ?, specs = ?, cpu = ?, npu = ?,
hdd0_model = ?, hdd0_capacity = ?, hdd1_model = ?, hdd1_capacity = ?, hdd0_model = ?, hdd0_capacity = ?, hdd1_model = ?, hdd1_capacity = ?,
ram = ?, asset_status = ?, assigned_user = ?, fixed_ip = ?, ram = ?, asset_status = ?, assigned_user = ?, fixed_ip = ?,
remarks = ?, options = ?, power_rating = ?, purchase_date = ?, remarks = ?, options = ?, power_rating = ?, purchase_date = ?,
vendor = ?, product_name = ? vendor = ?, product_name = ?
WHERE id = ?"); WHERE id = ?");
$stmt->execute([ $stmt->execute([
$data['model_name'], $data['model_name'],
$data['manufacturer'], $data['manufacturer'],
@ -261,26 +424,27 @@ try {
case 'get_departments': case 'get_departments':
$sql = "WITH RECURSIVE dept_path(id, name, path, level, parent_id) AS ( $sql = "WITH RECURSIVE dept_path(id, name, path, level, parent_id) AS (
SELECT id, name, name, level, parent_id FROM departments WHERE parent_id IS NULL SELECT id, name, name, level, parent_id FROM departments WHERE parent_id IS NULL
UNION ALL UNION ALL
SELECT d.id, d.name, dp.path || ' > ' || d.name, d.level, d.parent_id SELECT d.id, d.name, dp.path || ' > ' || d.name, d.level, d.parent_id
FROM departments d FROM departments d
JOIN dept_path dp ON d.parent_id = dp.id JOIN dept_path dp ON d.parent_id = dp.id
) )
SELECT dp.*, SELECT dp.*,
(SELECT COUNT(*) FROM users WHERE department_id = dp.id) as member_count (SELECT COUNT(*) FROM users WHERE department_id = dp.id) as member_count
FROM dept_path dp FROM dept_path dp
ORDER BY path"; ORDER BY path";
echo json_encode($db->query($sql)->fetchAll()); echo json_encode($db->query($sql)->fetchAll());
break; break;
case 'add_laptop_asset': case 'add_laptop_asset':
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
$stmt = $db->prepare("INSERT INTO laptop_assets ( $stmt = $db->prepare("INSERT INTO laptop_assets (
asset_tag, model_id, current_user_id, status, serial_number, purchase_date, ip_address, asset_tag, model_id, current_user_id, status, serial_number, purchase_date, ip_address,
cpu, npu, hdd0_model, hdd0_capacity, hdd1_model, hdd1_capacity, ram, cpu, npu, hdd0_model, hdd0_capacity, hdd1_model, hdd1_capacity, ram,
asset_status, assigned_user_name, fixed_ip, options, power_rating, manufacturer, vendor, product_name, remarks, last_confirmed_date asset_status, assigned_user_name, fixed_ip, options, power_rating, manufacturer, vendor, product_name, remarks,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); last_confirmed_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([ $stmt->execute([
$data['asset_tag'], $data['asset_tag'],
$data['model_id'], $data['model_id'],
@ -309,7 +473,8 @@ try {
]); ]);
$new_asset_id = $db->lastInsertId(); $new_asset_id = $db->lastInsertId();
if ($data['assigned_user_name']) { if ($data['assigned_user_name']) {
$log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date) VALUES (?, 'assignment', ?, ?)"); $log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date) VALUES (?,
'assignment', ?, ?)");
$log_stmt->execute([$new_asset_id, $data['assigned_user_name'], date('Y-m-d')]); $log_stmt->execute([$new_asset_id, $data['assigned_user_name'], date('Y-m-d')]);
} }
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@ -323,11 +488,12 @@ try {
$old_stmt->execute([$data['id']]); $old_stmt->execute([$data['id']]);
$old_asset = $old_stmt->fetch(); $old_asset = $old_stmt->fetch();
$stmt = $db->prepare("UPDATE laptop_assets SET $stmt = $db->prepare("UPDATE laptop_assets SET
asset_tag = ?, model_id = ?, current_user_id = ?, status = ?, serial_number = ?, purchase_date = ?, ip_address = ?, asset_tag = ?, model_id = ?, current_user_id = ?, status = ?, serial_number = ?, purchase_date = ?, ip_address = ?,
cpu = ?, npu = ?, hdd0_model = ?, hdd0_capacity = ?, hdd1_model = ?, hdd1_capacity = ?, ram = ?, cpu = ?, npu = ?, hdd0_model = ?, hdd0_capacity = ?, hdd1_model = ?, hdd1_capacity = ?, ram = ?,
asset_status = ?, assigned_user_name = ?, fixed_ip = ?, options = ?, power_rating = ?, manufacturer = ?, vendor = ?, product_name = ?, remarks = ?, last_confirmed_date = ? asset_status = ?, assigned_user_name = ?, fixed_ip = ?, options = ?, power_rating = ?, manufacturer = ?, vendor = ?,
WHERE id = ?"); product_name = ?, remarks = ?, last_confirmed_date = ?
WHERE id = ?");
$stmt->execute([ $stmt->execute([
$data['asset_tag'], $data['asset_tag'],
$data['model_id'], $data['model_id'],
@ -358,7 +524,8 @@ try {
// Auto-log assignment change // Auto-log assignment change
if ($data['assigned_user_name'] && $data['assigned_user_name'] !== ($old_asset['assigned_user_name'] ?? '')) { if ($data['assigned_user_name'] && $data['assigned_user_name'] !== ($old_asset['assigned_user_name'] ?? '')) {
$log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date) VALUES (?, 'assignment', ?, ?)"); $log_stmt = $db->prepare("INSERT INTO asset_history (asset_id, log_type, user_name, action_date) VALUES (?,
'assignment', ?, ?)");
$log_stmt->execute([$data['id'], $data['assigned_user_name'], date('Y-m-d')]); $log_stmt->execute([$data['id'], $data['assigned_user_name'], date('Y-m-d')]);
} }
@ -409,7 +576,8 @@ try {
$fki_root_id = $fki_root ? $fki_root['id'] : null; $fki_root_id = $fki_root ? $fki_root['id'] : null;
// 1. Find the '미소속' department under FKI Root // 1. Find the '미소속' department under FKI Root
$miso_id = $db->query("SELECT id FROM departments WHERE name = '미소속' AND parent_id IS " . ($fki_root_id ?: 'NULL') . " LIMIT 1")->fetchColumn(); $miso_id = $db->query("SELECT id FROM departments WHERE name = '미소속' AND parent_id IS " . ($fki_root_id ?: 'NULL') . "
LIMIT 1")->fetchColumn();
if (!$miso_id) { if (!$miso_id) {
$db->prepare("INSERT INTO departments (name, parent_id, level) VALUES ('미소속', ?, 1)")->execute([$fki_root_id]); $db->prepare("INSERT INTO departments (name, parent_id, level) VALUES ('미소속', ?, 1)")->execute([$fki_root_id]);
@ -473,7 +641,11 @@ try {
echo json_encode(['success' => false, 'error' => 'No updates specified']); echo json_encode(['success' => false, 'error' => 'No updates specified']);
break; break;
} }
$sql = "UPDATE laptop_assets SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(0, count($ids), "?")) . ")"; $sql = "UPDATE laptop_assets SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(
0,
count($ids),
"?"
)) . ")";
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute(array_merge($params, $ids)); $stmt->execute(array_merge($params, $ids));
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@ -502,7 +674,11 @@ try {
echo json_encode(['success' => false, 'error' => 'No updates specified']); echo json_encode(['success' => false, 'error' => 'No updates specified']);
break; break;
} }
$sql = "UPDATE access_cards SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(0, count($ids), "?")) . ")"; $sql = "UPDATE access_cards SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(
0,
count($ids),
"?"
)) . ")";
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute(array_merge($params, $ids)); $stmt->execute(array_merge($params, $ids));
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@ -531,7 +707,11 @@ try {
echo json_encode(['success' => false, 'error' => 'No updates specified']); echo json_encode(['success' => false, 'error' => 'No updates specified']);
break; break;
} }
$sql = "UPDATE mfp_accounts SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(0, count($ids), "?")) . ")"; $sql = "UPDATE mfp_accounts SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(
0,
count($ids),
"?"
)) . ")";
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute(array_merge($params, $ids)); $stmt->execute(array_merge($params, $ids));
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@ -560,7 +740,11 @@ try {
echo json_encode(['success' => false, 'error' => 'No updates specified']); echo json_encode(['success' => false, 'error' => 'No updates specified']);
break; break;
} }
$sql = "UPDATE laptop_models SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(0, count($ids), "?")) . ")"; $sql = "UPDATE laptop_models SET " . implode(", ", $updates) . " WHERE id IN (" . implode(",", array_fill(
0,
count($ids),
"?"
)) . ")";
$stmt = $db->prepare($sql); $stmt = $db->prepare($sql);
$stmt->execute(array_merge($params, $ids)); $stmt->execute(array_merge($params, $ids));
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
@ -615,6 +799,55 @@ try {
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
break; break;
case 'get_general_rentals':
$query = "SELECT r.*, GROUP_CONCAT(i.item_name, ', ') as items
FROM general_rentals r
LEFT JOIN general_rental_items i ON r.id = i.rental_id
GROUP BY r.id
ORDER BY r.id DESC";
echo json_encode($db->query($query)->fetchAll());
break;
case 'add_general_rental':
$data = json_decode(file_get_contents('php://input'), true);
$startDate = $data['rental_start_date'] ?: date('Y-m-d');
$db->beginTransaction();
try {
$stmt = $db->prepare("INSERT INTO general_rentals (user_id, user_name, rental_start_date, rental_reason, status) VALUES
(?, ?, ?, ?, 'rented')");
$stmt->execute([
$data['user_id'] ?? null,
$data['user_name'],
$startDate,
$data['rental_reason'] ?? ''
]);
$rentalId = $db->lastInsertId();
if (!empty($data['items'])) {
$itemStmt = $db->prepare("INSERT INTO general_rental_items (rental_id, item_name) VALUES (?, ?)");
foreach ($data['items'] as $itemName) {
if (trim($itemName)) {
$itemStmt->execute([$rentalId, trim($itemName)]);
}
}
}
$db->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$db->rollBack();
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
break;
case 'return_general_rental':
$data = json_decode(file_get_contents('php://input'), true);
$returnDate = date('Y-m-d');
$stmt = $db->prepare("UPDATE general_rentals SET status = 'returned', rental_return_date = ? WHERE id = ?");
$stmt->execute([$returnDate, $data['id']]);
echo json_encode(['success' => true]);
break;
default: default:
echo json_encode(['error' => 'Invalid action']); echo json_encode(['error' => 'Invalid action']);
break; break;

BIN
assets.db

Binary file not shown.

10
auth_check.php Normal file
View file

@ -0,0 +1,10 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['admin_id'])) {
header('Location: login.php');
exit;
}
?>

View file

@ -1,3 +1,4 @@
<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">

340
general_rental.php Normal file
View file

@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일반 물품 임대 관리 | FKI ASSET</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
<style>
body {
font-family: 'Pretendard', sans-serif;
}
[x-cloak] {
display: none !important;
}
.modal-bg {
background-color: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(4px);
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
</head>
<body class="bg-[#f8fafc] text-slate-800" x-data="generalRental()">
<?php include 'nav.php'; ?>
<main class="max-w-[1600px] mx-auto p-8 pt-10">
<header class="mb-10 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 class="text-3xl font-extrabold text-slate-900 tracking-tight">일반 물품 임대</h2>
<p class="text-slate-500 mt-1">사원별 각종 물품(어댑터, 주변기기 ) 단기 임대 반납 기록</p>
</div>
<button @click="openAddModal()"
class="px-6 py-3 bg-blue-600 text-white rounded-2xl font-bold shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
임대 등록
</button>
</header>
<!-- Rental Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<template x-for="rental in rentals" :key="rental.id">
<div class="bg-white rounded-[2rem] p-6 shadow-sm border border-slate-100 hover:shadow-xl transition-all relative overflow-hidden flex flex-col h-full"
:class="rental.status === 'returned' ? 'opacity-75' : ''">
<!-- Status Ribbon for Returned -->
<template x-if="rental.status === 'returned'">
<div class="absolute top-0 right-0">
<div
class="bg-slate-500 p-1 px-4 text-[10px] font-black text-white uppercase transform rotate-45 translate-x-3 translate-y-1 shadow-sm">
Returned
</div>
</div>
</template>
<div class="flex items-center gap-4 mb-5">
<div class="w-12 h-12 rounded-2xl flex items-center justify-center shadow-inner"
:class="rental.status === 'returned' ? 'bg-slate-100 text-slate-400' : 'bg-emerald-50 text-emerald-500'">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div>
<h4 class="text-lg font-black text-slate-900" x-text="rental.user_name"></h4>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Rental Employee
</p>
</div>
</div>
<div class="flex-1 space-y-4">
<!-- Item List -->
<div>
<div
class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
<div class="w-1 h-3 bg-blue-500 rounded-full"></div>
Rental Items
</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="item in rental.items.split(', ')" :key="item">
<span
class="px-2 py-1 bg-blue-50 text-blue-600 text-[11px] font-black rounded-lg border border-blue-100"
x-text="item"></span>
</template>
</div>
</div>
<!-- Date Info -->
<div class="grid grid-cols-2 gap-4 pt-2 border-t border-slate-50">
<div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">Start
Date</div>
<div class="text-sm font-bold text-slate-700" x-text="rental.rental_start_date"></div>
</div>
<div>
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">Status
</div>
<template x-if="rental.status === 'rented'">
<div class="text-xs font-black text-blue-600 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
<span x-text="calculateElapsed(rental.rental_start_date) + '일째'"></span>
</div>
</template>
<template x-if="rental.status === 'returned'">
<div class="text-xs font-black text-slate-400"
x-text="'반납 (' + rental.rental_return_date + ')'"></div>
</template>
</div>
</div>
<!-- Reason -->
<template x-if="rental.rental_reason">
<div class="bg-slate-50 p-3 rounded-xl border border-slate-100 mt-2">
<div class="text-[9px] font-black text-slate-400 uppercase mb-1 tracking-wider">Reason
</div>
<div class="text-[11px] text-slate-600 font-bold whitespace-pre-wrap leading-relaxed"
x-text="rental.rental_reason"></div>
</div>
</template>
</div>
<!-- Return Button -->
<template x-if="rental.status === 'rented'">
<button @click="returnRental(rental)"
class="mt-6 w-full py-3.5 bg-slate-900 text-white rounded-2xl font-black text-sm shadow-xl hover:bg-slate-800 transition-all active:scale-95 flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16l-4-4m0 0l4-4m-4 4h18" />
</svg>
반납 처리
</button>
</template>
</div>
</template>
</div>
</main>
<!-- Add Rental Modal -->
<div x-show="showAddModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showAddModal = false"></div>
<div
class="bg-white rounded-[2.5rem] p-10 max-w-lg w-full relative z-[111] shadow-2xl flex flex-col max-h-[90vh]">
<h3 class="text-3xl font-black text-slate-900 mb-2">임대 등록</h3>
<p class="text-slate-400 text-xs font-bold mb-8 uppercase tracking-widest">Register New General Rental</p>
<form @submit.prevent="submitRental" class="space-y-6 overflow-y-auto pr-2 no-scrollbar">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">임대사원 선택
(필수)</label>
<select x-model="formData.user_id" required @change="updateUserName()"
class="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
<option value="">직원 선택</option>
<template x-for="user in users" :key="user.id">
<option :value="user.id"
x-text="user.name + ' (' + (user.dept_name ? user.dept_name.split(' > ').pop() : '미소속') + ')'">
</option>
</template>
</select>
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">임대 시작일</label>
<input type="date" x-model="formData.rental_start_date"
class="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
</div>
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-3 ml-1">임대 품목 (항목별
입력)</label>
<div class="space-y-3">
<template x-for="(item, index) in formData.items" :key="index">
<div class="flex items-center gap-2 group">
<div class="flex-1 relative">
<input type="text" x-model="formData.items[index]" placeholder="품목명을 적으세요..."
class="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm transition-all group-hover:bg-white">
<div class="absolute left-3.5 top-3.5">
<input type="checkbox" checked disabled
class="w-3.5 h-3.5 text-blue-500 rounded border-slate-300">
</div>
</div>
<button type="button" @click="removeItem(index)" x-show="formData.items.length > 1"
class="p-3 text-slate-300 hover:text-rose-500 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</template>
<button type="button" @click="addItem()"
class="w-full py-3 bg-white border-2 border-dashed border-slate-200 rounded-xl text-xs font-black text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg>
항목 추가
</button>
</div>
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">임대 사유</label>
<textarea x-model="formData.rental_reason" rows="3" placeholder="임대 목적 및 사유를 입력하세요"
class="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none resize-none font-medium text-sm"></textarea>
</div>
<div class="pt-4 flex gap-3">
<button type="button" @click="showAddModal = false"
class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold hover:bg-slate-200 transition-all">취소</button>
<button type="submit"
class="flex-[2] py-4 bg-blue-600 text-white rounded-2xl font-bold shadow-2xl shadow-blue-200 hover:bg-blue-700 transition-all active:scale-95">
임대 적용
</button>
</div>
</form>
</div>
</div>
<script>
function generalRental() {
return {
rentals: [],
users: [],
showAddModal: false,
formData: {
user_id: '',
user_name: '',
rental_start_date: new Date().toISOString().split('T')[0],
rental_reason: '',
items: ['']
},
init() {
this.fetchRentals();
this.fetchUsers();
},
fetchRentals() {
fetch('api.php?action=get_general_rentals').then(res => res.json()).then(data => {
this.rentals = data;
});
},
fetchUsers() {
fetch('api.php?action=get_users').then(res => res.json()).then(data => {
this.users = data;
});
},
openAddModal() {
this.formData = {
user_id: '',
user_name: '',
rental_start_date: new Date().toISOString().split('T')[0],
rental_reason: '',
items: ['']
};
this.showAddModal = true;
},
addItem() {
this.formData.items.push('');
},
removeItem(index) {
this.formData.items.splice(index, 1);
},
updateUserName() {
const user = this.users.find(u => u.id == this.formData.user_id);
this.formData.user_name = user ? user.name : '';
},
submitRental() {
if (this.formData.items.every(item => !item.trim())) {
window.showAlert('최소 하나 이상의 품목을 입력해주세요.', '알림', 'warning');
return;
}
fetch('api.php?action=add_general_rental', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('임대 등록이 완료되었습니다.', '성공', 'success');
this.showAddModal = false;
this.fetchRentals();
} else {
window.showAlert(data.error || '오류가 발생했습니다.', '실패', 'error');
}
});
},
returnRental(rental) {
window.showConfirm(`${rental.user_name}님의 [${rental.items}] 품목 반납을 처리하시겠습니까?`, () => {
fetch('api.php?action=return_general_rental', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: rental.id })
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('반납 처리가 완료되었습니다.', '성공', 'success');
this.fetchRentals();
}
});
}, '반납 확인');
},
calculateElapsed(startDate) {
if (!startDate) return 0;
const start = new Date(startDate);
const now = new Date();
const diffTime = Math.abs(now - start);
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays + 1;
}
}
}
</script>
</body>
</html>

View file

@ -1,3 +1,4 @@
<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">

View file

@ -1,3 +1,4 @@
<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
@ -251,32 +252,44 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <!-- 개별 자산 정보 섹션 -->
<div> <div class="bg-blue-50/50 p-6 rounded-[2rem] border border-blue-100/50 space-y-4">
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">배정 사용자</label> <div class="flex items-center gap-2 mb-2">
<select x-model="formData.current_user_id" <div class="w-1.5 h-1.5 rounded-full bg-blue-500"></div>
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all"> <h4 class="text-xs font-black text-blue-600 uppercase tracking-widest">Individual Asset Info
<option value="">미배정 (재고)</option> </h4>
<template x-for="user in allUsers" :key="user.id">
<option :value="user.id" x-text="user.name + ' (' + user.emp_id + ')'"></option>
</template>
</select>
</div> </div>
<div> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">고정 IP 주소</label> <div>
<input type="text" x-model="formData.ip_address" placeholder="192.168.x.x" <label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">배정
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all"> 사용자</label>
</div> <select x-model="formData.current_user_id"
<div> class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-slate-700">
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">취득일</label> <option value="">미배정 (재고)</option>
<input type="date" x-model="formData.purchase_date" <template x-for="user in allUsers" :key="user.id">
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all"> <option :value="user.id" x-text="user.name + ' (' + user.emp_id + ')'"></option>
</div> </template>
<div> </select>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2 text-blue-600">실사 </div>
확인일</label> <div>
<input type="date" x-model="formData.last_confirmed_date" <label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">고정 IP
class="w-full px-4 py-3 bg-blue-50 border border-blue-100 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all"> 주소</label>
<input type="text" x-model="formData.ip_address" placeholder="192.168.x.x"
class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-slate-700">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">노트북
상태</label>
<input type="text" x-model="formData.asset_status" placeholder="예: 정상"
class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-slate-700">
</div>
<div>
<label
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1 text-blue-600">실사
확인일</label>
<input type="date" x-model="formData.last_confirmed_date"
class="w-full px-4 py-3 bg-white border border-blue-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-blue-600">
</div>
</div> </div>
</div> </div>
@ -330,21 +343,29 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <!-- 모델 기반 공통 정보 섹션 (Read Only) -->
<div> <div class="bg-slate-50 p-6 rounded-[2rem] border border-slate-100 space-y-4">
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">노트북 상태</label> <div class="flex items-center gap-2 mb-2">
<input type="text" x-model="formData.asset_status" <div class="w-1.5 h-1.5 rounded-full bg-slate-400"></div>
class="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm"> <h4 class="text-xs font-black text-slate-400 uppercase tracking-widest">Model Base Info (Read
Only)</h4>
</div> </div>
<div> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">정격입출력</label> <div>
<input type="text" x-model="formData.power_rating" <label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">취득일</label>
class="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm"> <input type="text" :value="formData.purchase_date" readonly
</div> class="w-full px-4 py-3 bg-slate-100 border border-slate-200 rounded-xl text-sm font-bold text-slate-500 cursor-not-allowed">
<div> </div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">옵션</label> <div>
<input type="text" x-model="formData.options" <label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">정격입출력</label>
class="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm"> <input type="text" :value="formData.power_rating" readonly
class="w-full px-4 py-3 bg-slate-100 border border-slate-200 rounded-xl text-sm font-bold text-slate-500 cursor-not-allowed">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">옵션</label>
<input type="text" :value="formData.options" readonly
class="w-full px-4 py-3 bg-slate-100 border border-slate-200 rounded-xl text-sm font-bold text-slate-500 cursor-not-allowed">
</div>
</div> </div>
</div> </div>
@ -401,7 +422,10 @@
applyBulkUpdate() { applyBulkUpdate() {
if (this.selectedIds.length === 0) return; if (this.selectedIds.length === 0) return;
if (!this.bulkStatus && !this.bulkModelId) { alert('변경할 항목을 선택해주세요.'); return; } if (!this.bulkStatus && !this.bulkModelId) {
window.showAlert('변경할 항목(상태 또는 모델)을 선택해주세요.', '선택 오류', 'error');
return;
}
fetch('api.php?action=bulk_update_laptops', { fetch('api.php?action=bulk_update_laptops', {
method: 'POST', method: 'POST',
@ -413,10 +437,13 @@
}) })
}).then(res => res.json()).then(data => { }).then(res => res.json()).then(data => {
if (data.success) { if (data.success) {
window.showAlert(`${this.selectedIds.length}개의 자산 정보가 일괄 변경되었습니다.`, '변경 성공', 'success');
this.selectedIds = []; this.selectedIds = [];
this.bulkStatus = ''; this.bulkStatus = '';
this.bulkModelId = ''; this.bulkModelId = '';
this.fetchAssets(); this.fetchAssets();
} else {
window.showAlert('자산 정보 일괄 변경에 실패했습니다.', '변경 실패', 'error');
} }
}); });
}, },

123
login.php Normal file
View file

@ -0,0 +1,123 @@
<?php
session_start();
if (isset($_SESSION['admin_id'])) {
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASSET | Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700;800&display=swap"
rel="stylesheet">
<style>
body {
font-family: 'Pretendard', sans-serif;
}
[x-cloak] {
display: none !important;
}
.glass {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
</style>
</head>
<body class="bg-[#0f172a] min-h-screen flex items-center justify-center p-6 relative overflow-hidden text-slate-800">
<!-- Animated background elements -->
<div class="absolute top-0 left-0 w-full h-full opacity-20 pointer-events-none">
<div
class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-600 rounded-full blur-[120px] animate-pulse">
</div>
<div class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-indigo-600 rounded-full blur-[120px] animate-pulse"
style="animation-delay: 2s;"></div>
</div>
<div class="w-full max-w-md relative z-10" x-data="{
login_id: '',
password: '',
loading: false,
error: '',
async submit() {
this.loading = true;
this.error = '';
try {
const res = await fetch('api.php?action=login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login_id: this.login_id, password: this.password })
});
const data = await res.json();
if (data.success) {
location.href = 'index.php';
} else {
this.error = data.error || '로그인에 실패했습니다.';
this.loading = false;
}
} catch (e) {
this.error = '서버 통신 오류가 발생했습니다.';
this.loading = false;
}
}
}">
<div class="text-center mb-10">
<h1 class="text-6xl font-black text-white tracking-tighter mb-2 italic">ASSET</h1>
<p class="text-blue-400 font-bold uppercase tracking-[0.3em] text-[10px]">Management System v2.0</p>
</div>
<div class="glass rounded-[2.5rem] p-10 shadow-2xl">
<h2 class="text-2xl font-black text-slate-900 mb-8">Sign In</h2>
<form @submit.prevent="submit" class="space-y-6">
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1 tracking-widest">Admin
ID</label>
<input type="text" x-model="login_id" required placeholder="Enter ID"
class="w-full px-5 py-4 bg-slate-100/50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold transition-all">
</div>
<div>
<label
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1 tracking-widest">Password</label>
<input type="password" x-model="password" required placeholder="••••••••"
class="w-full px-5 py-4 bg-slate-100/50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none font-bold transition-all">
</div>
<div x-show="error" x-transition x-cloak
class="p-4 bg-rose-50 border border-rose-100 rounded-2xl text-rose-600 text-xs font-bold"
x-text="error"></div>
<button type="submit" :disabled="loading"
class="w-full py-5 bg-blue-600 text-white rounded-2xl font-black text-sm shadow-xl shadow-blue-500/20 hover:bg-blue-700 transition-all active:scale-95 disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2">
<template x-if="loading">
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
</circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</template>
<span x-text="loading ? 'Authenticating...' : 'Access Console'"></span>
</button>
</form>
</div>
<p class="mt-8 text-center text-slate-500 text-[10px] font-bold uppercase tracking-widest">
Forbidden access is strictly monitored
</p>
</div>
</body>
</html>

View file

@ -1,3 +1,4 @@
<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">

View file

@ -1,3 +1,4 @@
<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
@ -289,23 +290,8 @@
</div> </div>
</div> </div>
<!-- 자산 정보 --> <!-- 자산 정보 (취득일) -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">노트북 상태</label>
<input type="text" x-model="formData.asset_status" placeholder="예: 정상"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">고정 IP</label>
<input type="text" x-model="formData.fixed_ip" placeholder="192.168.x.x"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">배정 사용자</label>
<input type="text" x-model="formData.assigned_user" placeholder="성함"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div> <div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2 text-blue-600">취득일</label> <label class="block text-xs font-bold text-slate-500 uppercase mb-2 text-blue-600">취득일</label>
<input type="date" x-model="formData.purchase_date" <input type="date" x-model="formData.purchase_date"
@ -376,6 +362,13 @@
<button type="submit" class="flex-1 py-3 bg-blue-600 text-white rounded-xl font-bold" <button type="submit" class="flex-1 py-3 bg-blue-600 text-white rounded-xl font-bold"
x-text="isDeptEdit ? '수정 완료' : '부서 생성'"></button> x-text="isDeptEdit ? '수정 완료' : '부서 생성'"></button>
</div> </div>
<div x-show="isDeptEdit" class="pt-4 mt-4 border-t border-slate-100">
<button type="button" @click="deleteDept()"
class="w-full py-3 text-sm font-bold text-rose-500 hover:text-white hover:bg-rose-500 border border-rose-200 rounded-xl transition-all">
부서 삭제하기
</button>
<p class="text-[10px] text-slate-400 mt-2 text-center"> 삭제 소속 인원은 '미소속'으로 이동됩니다.</p>
</div>
</form> </form>
</div> </div>
</div> </div>
@ -401,20 +394,30 @@
</thead> </thead>
<tbody class="divide-y divide-slate-50"> <tbody class="divide-y divide-slate-50">
<template x-for="user in members" :key="user.id"> <template x-for="user in members" :key="user.id">
<tr> <tr :class="getPositionInfo(user.position).bgClass"
<td class="px-4 py-3 text-sm font-bold text-slate-900" x-text="user.name"></td> :style="getPositionInfo(user.position).gradientStyle">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="text-xs font-black text-slate-400" x-text="user.emp_id"></div> <div class="flex items-center gap-2">
<div class="text-[10px] text-blue-500 font-bold uppercase" <div class="text-sm font-black text-slate-900" x-text="user.name"></div>
x-text="user.position || '-'"></div> <template x-if="getPositionInfo(user.position).isVIP">
<span
class="text-[9px] px-1.5 py-0.5 rounded-md font-black uppercase tracking-tighter"
:class="getPositionInfo(user.position).tagClass"
x-text="user.position"></span>
</template>
</div>
<div class="text-[10px] font-bold text-slate-400" x-text="user.emp_id"></div>
</td>
<td class="px-4 py-3">
<div class="text-xs font-black text-slate-900" x-text="user.position || '-'"></div>
</td> </td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<select @change="reassignMember(user.id, $event.target.value)" <select @change="reassignMember(user.id, $event.target.value)"
class="text-xs bg-slate-100 border-none rounded-lg px-2 py-1 focus:ring-2 focus:ring-blue-500"> class="text-[10px] font-bold bg-slate-50 border-none rounded-lg px-2 py-1 outline-none focus:ring-1 focus:ring-blue-500">
<option value="">부서 이동...</option> <option value="">부서 이동...</option>
<template x-for="d in depts" :key="d.id"> <template x-for="d in depts" :key="d.id">
<option :value="d.id" x-text="d.name" :disabled="d.id == selectedDept.id"> <option :value="d.id" x-text="d.name"
</option> :selected="d.id == user.department_id"></option>
</template> </template>
</select> </select>
</td> </td>
@ -480,7 +483,10 @@
}, },
applyBulkUpdate() { applyBulkUpdate() {
if (this.selectedIds.length === 0) return; if (this.selectedIds.length === 0) return;
if (!this.bulkManufacturer && !this.bulkProductName) { alert('변경할 항목을 선택해주세요.'); return; } if (!this.bulkManufacturer && !this.bulkProductName) {
window.showAlert('변경할 항목(제조사 또는 품명)을 입력해주세요.', '입력 오류', 'error');
return;
}
fetch('api.php?action=bulk_update_models', { fetch('api.php?action=bulk_update_models', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -503,7 +509,7 @@
this.formData = { this.formData = {
id: '', manufacturer: '', model_name: '', product_name: '', id: '', manufacturer: '', model_name: '', product_name: '',
cpu: '', npu: '', ram: '', hdd0_model: '', hdd0_capacity: '', hdd1_model: '', hdd1_capacity: '', cpu: '', npu: '', ram: '', hdd0_model: '', hdd0_capacity: '', hdd1_model: '', hdd1_capacity: '',
asset_status: '', fixed_ip: '', assigned_user: '', purchase_date: '', purchase_date: '',
power_rating: '', vendor: '', options: '', remarks: '', specs: '' power_rating: '', vendor: '', options: '', remarks: '', specs: ''
}; };
this.showModal = true; this.showModal = true;
@ -569,10 +575,49 @@
viewDeptMembers(dept) { viewDeptMembers(dept) {
this.selectedDept = dept; this.selectedDept = dept;
fetch(`api.php?action=get_dept_users&dept_id=${dept.id}`).then(res => res.json()).then(data => { fetch(`api.php?action=get_dept_users&dept_id=${dept.id}`).then(res => res.json()).then(data => {
this.members = data; // 직위 랭킹 정렬 적용
const getRank = (pos) => {
if (!pos) return 99;
if (pos.includes('부회장')) return 1;
if (pos.includes('총괄')) return 2;
if (pos.includes('원장')) return 3;
if (pos.includes('센터장')) return 4;
if (pos.includes('본부장')) return 5;
if (pos.includes('실장')) return 6;
if (pos.includes('부문장')) return 7;
if (pos.includes('팀장')) return 8;
return 90;
};
this.members = data.sort((a, b) => getRank(a.position) - getRank(b.position));
this.showMembersModal = true; this.showMembersModal = true;
}); });
}, },
getPositionInfo(pos) {
if (!pos) return { bgClass: 'bg-white', iconClass: 'bg-slate-100 text-slate-500', isVIP: false };
const configs = [
{ key: '부회장', from: '#fbbf24', to: '#d97706', icon: 'bg-amber-100 text-amber-600', tag: 'bg-amber-500 text-white' },
{ key: '총괄', from: '#3b82f6', to: '#1d4ed8', icon: 'bg-blue-100 text-blue-600', tag: 'bg-blue-600 text-white' },
{ key: '원장', from: '#10b981', to: '#059669', icon: 'bg-emerald-100 text-emerald-600', tag: 'bg-emerald-600 text-white' },
{ key: '센터장', from: '#06b6d4', to: '#0891b2', icon: 'bg-cyan-100 text-cyan-600', tag: 'bg-cyan-600 text-white' },
{ key: '본부장', from: '#8b5cf6', to: '#7c3aed', icon: 'bg-purple-100 text-purple-600', tag: 'bg-purple-600 text-white' },
{ key: '실장', from: '#64748b', to: '#475569', icon: 'bg-slate-100 text-slate-600', tag: 'bg-slate-600 text-white' },
{ key: '부문장', from: '#f43f5e', to: '#e11d48', icon: 'bg-rose-100 text-rose-600', tag: 'bg-rose-600 text-white' },
{ key: '팀장', from: '#6366f1', to: '#4f46e5', icon: 'bg-indigo-100 text-indigo-600', tag: 'bg-indigo-600 text-white' }
];
const config = configs.find(c => pos.includes(c.key));
if (config) {
return {
isVIP: true,
gradientStyle: `background: linear-gradient(135deg, ${config.from}08 0%, ${config.to}05 100%)`,
bgClass: 'hover:bg-opacity-50',
iconClass: config.icon,
tagClass: config.tag
};
}
return { bgClass: 'bg-white', iconClass: 'bg-slate-100 text-slate-500', isVIP: false };
},
reassignMember(userId, newDeptId) { reassignMember(userId, newDeptId) {
if (!newDeptId) return; if (!newDeptId) return;
fetch('api.php?action=reassign_user', { fetch('api.php?action=reassign_user', {
@ -585,6 +630,32 @@
this.fetchDepts(); this.fetchDepts();
} }
}); });
},
deleteDept() {
const deptId = this.deptFormData.id;
const deptName = this.deptFormData.name;
window.showConfirm(
`'${deptName}' 부서를 정말 삭제하시겠습니까?\n삭제 후 소속 인원은 '미소속' 부서로 이동하며, 하위 부서들은 상위 계층으로 재배치됩니다.`,
() => {
fetch(`api.php?action=delete_dept&id=${deptId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
window.showAlert('부서가 성공적으로 삭제되었습니다.', '성공', 'success');
this.showDeptModal = false;
this.fetchDepts();
} else {
window.showAlert('부서 삭제 중 오류: ' + (data.error || '알 수 없는 오류'), '삭제 실패', 'error');
}
})
.catch(err => {
console.error(err);
window.showAlert('서버 통신 중 오류가 발생했습니다.', '시스템 오류', 'error');
});
},
'부서 영구 삭제'
);
} }
} }
} }

114
nav.php
View file

@ -28,11 +28,13 @@ $current_page = basename($_SERVER['PHP_SELF']);
$menu_items = [ $menu_items = [
['url' => 'index.php', 'name' => '대시보드', 'icon' => 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'], ['url' => 'index.php', 'name' => '대시보드', 'icon' => 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
['url' => 'rental.php', 'name' => '노트북 임대', 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4'], ['url' => 'rental.php', 'name' => '노트북 임대', 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4'],
['url' => 'general_rental.php', 'name' => '임대', 'icon' => 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'],
['url' => 'users.php', 'name' => '직원 관리', 'icon' => 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'], ['url' => 'users.php', 'name' => '직원 관리', 'icon' => 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'],
['url' => 'laptops.php', 'name' => '노트북 자산', 'icon' => 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'], ['url' => 'laptops.php', 'name' => '노트북 자산', 'icon' => 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'],
['url' => 'cards.php', 'name' => '출입증 현황', 'icon' => 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'], ['url' => 'cards.php', 'name' => '출입증 현황', 'icon' => 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'],
['url' => 'mfp.php', 'name' => '복합기 계정', 'icon' => 'M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z'], ['url' => 'mfp.php', 'name' => '복합기 계정', 'icon' => 'M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z'],
['url' => 'models.php', 'name' => 'DB 관리', 'icon' => 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4'] ['url' => 'models.php', 'name' => 'DB 관리', 'icon' => 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4'],
['url' => 'admins.php', 'name' => 'ADMIN', 'icon' => 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z']
]; ];
foreach ($menu_items as $item): foreach ($menu_items as $item):
@ -51,15 +53,29 @@ $current_page = basename($_SERVER['PHP_SELF']);
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<!-- User Info --> <!-- User Info & Logout -->
<div class="hidden lg:flex items-center ml-10 space-x-4 border-l border-slate-700 pl-10"> <div class="hidden lg:flex items-center ml-10 space-x-4 border-l border-slate-700 pl-10" x-data="{
logout() {
window.showConfirm('로그아웃 하시겠습니까?', () => {
fetch('api.php?action=logout').then(() => location.href = 'login.php');
});
}
}">
<div class="text-right"> <div class="text-right">
<p class="text-xs font-black text-white">ADMIN MASTER</p> <p class="text-xs font-black text-white"><?php echo $_SESSION['admin_name'] ?? 'ADMIN'; ?></p>
<p class="text-[10px] text-blue-400 font-bold uppercase tracking-tight">System Online</p> <button @click="logout()"
class="text-[10px] text-blue-400 font-bold uppercase tracking-tight hover:text-white transition-colors flex items-center gap-1">
Sign Out
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div> </div>
<div <div
class="w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center border border-slate-700 shadow-inner"> class="w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center border border-slate-700 shadow-inner">
<span class="text-xs font-black text-slate-300">AD</span> <span
class="text-xs font-black text-slate-300"><?php echo strtoupper(substr($_SESSION['admin_login_id'] ?? 'AD', 0, 2)); ?></span>
</div> </div>
</div> </div>
@ -86,4 +102,88 @@ $current_page = basename($_SERVER['PHP_SELF']);
body { body {
padding-top: 80px; padding-top: 80px;
} }
</style> </style>
<!-- Global Notification System (Antigravity Standard) -->
<div x-data="{
show: false,
type: 'alert',
title: '',
message: '',
onConfirm: null,
init() {
window.showAlert = (msg, title = '알림', type = 'alert') => {
this.type = type;
this.title = title;
this.message = msg;
this.onConfirm = null;
this.show = true;
};
window.showConfirm = (msg, callback, title = '확인 요청') => {
this.type = 'confirm';
this.title = title;
this.message = msg;
this.onConfirm = callback;
this.show = true;
};
},
confirmAction() {
if (this.onConfirm) this.onConfirm();
this.show = false;
}
}" x-show="show" x-cloak class="fixed inset-0 z-[1000] flex items-center justify-center p-4">
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="show = false"></div>
<div
class="bg-white rounded-[2.5rem] p-8 max-w-sm w-full relative z-[1001] shadow-[0_32px_64px_-12px_rgba(0,0,0,0.2)] border border-white/20 transform transition-all animate-in zoom-in-95 duration-200">
<div class="mb-6 text-center">
<div class="w-20 h-20 rounded-3xl mb-6 flex items-center justify-center mx-auto transition-transform hover:scale-110 duration-300"
:class="{
'bg-amber-50 text-amber-500': type === 'confirm',
'bg-rose-50 text-rose-500': type === 'error',
'bg-emerald-50 text-emerald-500': type === 'success',
'bg-blue-50 text-blue-500': type === 'alert'
}">
<template x-if="type === 'confirm'">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</template>
<template x-if="type === 'error'">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</template>
<template x-if="type === 'success'">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</template>
<template x-if="type === 'alert'">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</template>
</div>
<h4 class="text-2xl font-black text-slate-900 mb-3 tracking-tight" x-text="title"></h4>
<p class="text-sm font-bold text-slate-500 leading-relaxed whitespace-pre-line px-2" x-text="message"></p>
</div>
<div class="flex gap-3 mt-8">
<template x-if="type === 'confirm'">
<button @click="show = false"
class="flex-1 py-4 bg-slate-100 rounded-2xl font-black text-sm text-slate-600 hover:bg-slate-200 transition-all active:scale-95">취소</button>
</template>
<button @click="confirmAction"
class="flex-1 py-4 rounded-2xl font-black text-sm text-white shadow-2xl transition-all active:scale-95"
:class="{
'bg-amber-500 shadow-amber-200 hover:bg-amber-600': type === 'confirm',
'bg-rose-500 shadow-rose-200 hover:bg-rose-600': type === 'error',
'bg-emerald-500 shadow-emerald-200 hover:bg-emerald-600': type === 'success',
'bg-blue-600 shadow-blue-200 hover:bg-blue-700': type === 'alert'
}" x-text="type === 'confirm' ? '확인 및 실행' : '확인'"></button>
</div>
</div>
</div>

View file

@ -1,3 +1,4 @@
<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
@ -67,6 +68,9 @@
현재 임대사원 <span x-show="sortKey === 'rental_user_name'" 현재 임대사원 <span x-show="sortKey === 'rental_user_name'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span> x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th> </th>
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">
임대 시작일 / 경과일 / 반납예정일
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider"> <th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">
비고(특기사항)</th> 비고(특기사항)</th>
<th class="px-6 py-4 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">액션 <th class="px-6 py-4 text-right text-xs font-bold text-slate-500 uppercase tracking-wider">액션
@ -77,7 +81,8 @@
<template x-for="item in sortedAssets" :key="item.id"> <template x-for="item in sortedAssets" :key="item.id">
<tr class="hover:bg-slate-50/80 transition-colors group"> <tr class="hover:bg-slate-50/80 transition-colors group">
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-black text-slate-900 px-2.5 py-1 bg-slate-100 rounded-lg" <span @click="openRemarksModal(item)"
class="text-sm font-black text-blue-600 px-2.5 py-1 bg-blue-50 rounded-lg cursor-pointer hover:bg-blue-600 hover:text-white transition-all shadow-sm border border-blue-100"
x-text="item.asset_tag"></span> x-text="item.asset_tag"></span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
@ -88,65 +93,253 @@
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<template x-if="item.rental_user_name"> <template x-if="item.rental_user_name">
<div class="flex items-center"> <div class="flex items-center">
<div class="w-2 h-2 bg-emerald-500 rounded-full mr-2"></div> <div
<span class="text-sm font-black text-slate-900" class="w-2.5 h-2.5 bg-emerald-500 rounded-full mr-2.5 shadow-[0_0_8px_rgba(16,185,129,0.5)]">
x-text="item.rental_user_name"></span> </div>
<div>
<span class="text-sm font-black text-slate-900"
x-text="item.rental_user_name"></span>
<div class="text-[10px] text-slate-400 font-bold"
x-text="item.rental_reason ? '사유: ' + item.rental_reason : ''"></div>
<template x-if="item.rental_peripherals">
<div class="flex flex-wrap gap-1 mt-1">
<template x-for="p in item.rental_peripherals.split(',')">
<span
class="px-1 py-0.5 bg-slate-100 text-[9px] text-slate-500 rounded border border-slate-200 font-bold"
x-text="p"></span>
</template>
</div>
</template>
</div>
</div> </div>
</template> </template>
<template x-if="!item.rental_user_name"> <template x-if="!item.rental_user_name">
<span class="text-xs text-slate-300 italic font-medium">임대 가능</span> <span class="text-xs text-slate-300 italic font-medium">임대 가능(STOCK)</span>
</template>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<template x-if="item.rental_user_name">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span
class="text-[10px] font-black text-slate-400 uppercase w-12">Start</span>
<span class="text-xs font-bold text-slate-700"
x-text="item.rental_start_date"></span>
</div>
<div class="flex items-center gap-2">
<span
class="text-[10px] font-black text-slate-400 uppercase w-12">Elapsed</span>
<span
class="text-xs font-black text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded"
x-text="calculateElapsed(item.rental_start_date) + '일째'"></span>
</div>
<div class="flex items-center gap-2" x-show="item.rental_end_scheduled">
<span
class="text-[10px] font-black text-rose-400 uppercase w-12">Return</span>
<span class="text-xs font-bold text-rose-500"
x-text="item.rental_end_scheduled"></span>
</div>
</div>
</template>
<template x-if="!item.rental_user_name">
<span class="text-xs text-slate-200">-</span>
</template> </template>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="text-xs text-slate-500 truncate max-w-xs" x-text="item.remarks || '-'"> <div class="text-xs text-slate-500 truncate max-w-[200px]" x-text="item.remarks || '-'">
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right"> <td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button @click="openHistoryModal(item)" <button @click="openHistoryModal(item)"
class="px-3 py-1.5 text-[11px] font-black text-slate-500 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded-lg transition-all uppercase">History</button> class="px-3 py-1.5 text-[11px] font-black text-slate-500 hover:text-slate-900 bg-slate-100 hover:bg-slate-200 rounded-lg transition-all uppercase">LOG</button>
<template x-if="item.rental_user_name">
<button @click="returnRental(item)"
class="px-3 py-1.5 text-[11px] font-black text-rose-600 bg-rose-50 hover:bg-rose-100 rounded-lg transition-all uppercase border border-rose-100">RETURN</button>
</template>
<button @click="openRentalModal(item)" <button @click="openRentalModal(item)"
:class="item.rental_user_name ? 'bg-amber-100 text-amber-600 hover:bg-amber-200' : 'bg-blue-600 text-white hover:bg-blue-700'" :class="item.rental_user_name ? 'bg-amber-100 text-amber-600 hover:bg-amber-200' : 'bg-blue-600 text-white hover:bg-blue-700'"
class="px-4 py-1.5 text-[11px] font-black rounded-lg transition-all uppercase shadow-sm" class="px-4 py-1.5 text-[11px] font-black rounded-lg transition-all uppercase shadow-sm"
x-text="item.rental_user_name ? 'Change/Return' : 'Start Rental'"></button> x-text="item.rental_user_name ? 'EDIT' : 'RENTAL'"></button>
</div> </div>
</td> </div>
</tr> </td>
</template> </tr>
</tbody> </template>
</table> </tbody>
<div x-show="filteredAssets.length === 0" class="py-20 text-center"> </table>
<div class="text-slate-300 text-5xl mb-4 italic">No Assets Found</div> <div x-show="filteredAssets.length === 0" class="py-20 text-center">
<p class="text-slate-400 text-sm font-medium">배정 사용자가 '업무용' 노트북이 없거나 검색 결과가 없습니다.</p> <div class="text-slate-300 text-5xl mb-4 italic">No Assets Found</div>
</div> <p class="text-slate-400 text-sm font-medium">배정 사용자가 '업무용' 노트북이 없거나 검색 결과가 없습니다.</p>
</div>
</div> </div>
</main> </main>
<!-- Rental Modal -->
<div x-show="showRentalModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4"> <div x-show="showRentalModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showRentalModal = false"></div> <div class="fixed inset-0 modal-bg" @click="showRentalModal = false"></div>
<div class="bg-white rounded-3xl p-8 max-w-md w-full relative z-[111] shadow-2xl"> <div
class="bg-white rounded-[2rem] p-8 max-w-lg w-full relative z-[111] shadow-2xl max-h-[90vh] overflow-y-auto">
<h3 class="text-2xl font-black mb-2" x-text="selectedAsset?.asset_tag + ' 임대 설정'"></h3> <h3 class="text-2xl font-black mb-2" x-text="selectedAsset?.asset_tag + ' 임대 설정'"></h3>
<p class="text-slate-400 text-xs font-bold mb-6" x-text="selectedAsset?.model_name"></p> <p class="text-slate-400 text-xs font-bold mb-6" x-text="selectedAsset?.model_name"></p>
<form @submit.prevent="submitRental" class="space-y-4"> <form @submit.prevent="submitRental" class="space-y-5">
<div> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">임대사원 성함</label> <div>
<input type="text" x-model="rentalName" required placeholder="임대받는 사원의 이름을 입력하세요" <label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">임대사원 선택
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none"> (필수)</label>
<p class="mt-2 text-[10px] text-slate-400 italic">* 공란으로 입력 적용 반납 처리됩니다.</p> <select x-model="rentalUserId" required @change="updateRentalName()"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
<option value="">직원 선택</option>
<template x-for="user in allUsers" :key="user.id">
<option :value="user.id"
x-text="user.name + ' (' + (user.dept_name ? user.dept_name.split(' > ').pop() : '미소속') + ')'">
</option>
</template>
</select>
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">임대 시작일</label>
<input type="date" x-model="rentalStartDate"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none font-bold">
</div>
</div> </div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-3 ml-1">부속품 선택</label>
<div class="grid grid-cols-3 gap-3">
<template x-for="item in ['어댑터', '마우스', '가방', '프리젠터', '모니터', '키보드']">
<label
class="flex items-center gap-2 p-3 bg-slate-50 border border-slate-200 rounded-xl cursor-pointer hover:bg-blue-50 hover:border-blue-200 transition-all group">
<input type="checkbox" :value="item" x-model="rentalPeripherals"
class="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500">
<span class="text-xs font-bold text-slate-600 group-hover:text-blue-600"
x-text="item"></span>
</label>
</template>
</div>
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">반납 예정일</label>
<input type="date" x-model="rentalEndDate"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none font-bold">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">임대 사유</label>
<textarea x-model="rentalReason" rows="4" placeholder="임대 목적 및 사유를 입력하세요 (4줄 권장)"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none resize-none font-medium text-sm"></textarea>
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">비고 (노트북 자체
기록)</label>
<textarea x-model="rentalRemarks" rows="2" placeholder="노트북 비고란에 영구 기록될 내용"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none resize-none font-medium text-sm"></textarea>
</div>
<div class="pt-4 flex gap-3"> <div class="pt-4 flex gap-3">
<button type="button" @click="showRentalModal = false" <button type="button" @click="showRentalModal = false"
class="flex-1 py-3 bg-slate-100 rounded-xl font-bold">취소</button> class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold">취소</button>
<button type="submit" <button type="submit"
class="flex-1 py-3 bg-blue-600 text-white rounded-xl font-bold shadow-lg shadow-blue-200">임대 class="flex-1 py-4 bg-blue-600 text-white rounded-2xl font-bold shadow-lg shadow-blue-200 transition-all hover:bg-blue-700">
적용</button> 임대 적용
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Remarks Modal -->
<div x-show="showRemarksModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showRemarksModal = false"></div>
<div
class="bg-white rounded-[2rem] p-8 max-w-md w-full relative z-[111] shadow-2xl transform transition-all animate-in zoom-in-95 duration-200">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 class="text-xl font-black text-slate-900" x-text="selectedAsset?.asset_tag"></h3>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">자산 상세 비고</p>
</div>
</div>
<button @click="showRemarksModal = false" class="text-slate-300 hover:text-slate-900 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Remarks Display/Editor Area -->
<div class="space-y-4">
<div class="bg-slate-50 rounded-2xl p-6 border border-slate-100 min-h-[160px] relative transition-all"
:class="isEditingRemarks ? 'ring-2 ring-blue-500 bg-white border-transparent' : ''">
<div class="flex justify-between items-center mb-3">
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Remarks Content
</div>
<template x-if="!isEditingRemarks">
<div class="flex gap-2">
<button @click="startEditRemarks()"
class="p-1.5 text-blue-500 hover:bg-blue-50 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button @click="deleteRemarks()"
class="p-1.5 text-rose-500 hover:bg-rose-50 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</template>
</div>
<template x-if="!isEditingRemarks">
<div class="text-slate-700 leading-relaxed font-semibold whitespace-pre-wrap"
x-text="selectedAsset?.remarks || '등록된 비고 사항이 없습니다.'"></div>
</template>
<template x-if="isEditingRemarks">
<textarea x-model="tempRemarks"
class="w-full h-32 bg-transparent border-none focus:ring-0 p-0 text-slate-700 font-semibold leading-relaxed resize-none"
placeholder="비고 내용을 입력하세요..."></textarea>
</template>
</div>
<!-- Action Buttons -->
<div class="flex gap-3">
<template x-if="isEditingRemarks">
<div class="flex gap-3 w-full">
<button @click="isEditingRemarks = false"
class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold hover:bg-slate-200 transition-all">
취소
</button>
<button @click="saveRemarks()"
class="flex-[2] py-4 bg-blue-600 text-white rounded-2xl font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-200">
저장하기
</button>
</div>
</template>
<template x-if="!isEditingRemarks">
<button @click="showRemarksModal = false"
class="w-full py-4 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-800 transition-all shadow-lg shadow-slate-200">
닫기
</button>
</template>
</div>
</div>
</div>
</div>
<!-- History Modal --> <!-- History Modal -->
<div x-show="showHistoryModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4"> <div x-show="showHistoryModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showHistoryModal = false"></div> <div class="fixed inset-0 modal-bg" @click="showHistoryModal = false"></div>
@ -175,15 +368,21 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="log in history.rental" :key="log.id"> <template x-for="log in history.rental" :key="log.id">
<div <div class="p-4 bg-emerald-50 rounded-2xl border border-emerald-100 flex flex-col gap-3">
class="flex items-center justify-between p-3 bg-emerald-50 rounded-xl border border-emerald-100"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center text-[10px] font-black text-emerald-500 shadow-sm" <div class="w-8 h-8 bg-white rounded-lg flex items-center justify-center text-[10px] font-black text-emerald-500 shadow-sm"
x-text="log.user_name.charAt(0)"></div> x-text="log.user_name.charAt(0)"></div>
<span class="text-sm font-black text-slate-900" x-text="log.user_name"></span> <span class="text-sm font-black text-slate-900" x-text="log.user_name"></span>
</div>
<span
class="text-[10px] font-bold text-emerald-600 bg-white px-2 py-1 rounded-md shadow-sm border border-emerald-100"
x-text="log.action_date"></span>
</div> </div>
<span class="text-[10px] font-bold text-emerald-600 bg-white px-2 py-1 rounded-md" <template x-if="log.note">
x-text="log.action_date"></span> <div class="text-[11px] text-slate-500 font-bold bg-white/50 p-2 rounded-lg border border-emerald-100/50 whitespace-pre-wrap leading-relaxed"
x-text="log.note"></div>
</template>
</div> </div>
</template> </template>
<div x-show="history.rental.length === 0" <div x-show="history.rental.length === 0"
@ -229,12 +428,23 @@
sortOrder: 'asc', sortOrder: 'asc',
showRentalModal: false, showRentalModal: false,
showHistoryModal: false, showHistoryModal: false,
showRemarksModal: false,
isEditingRemarks: false,
tempRemarks: '',
selectedAsset: null, selectedAsset: null,
rentalUserId: '',
rentalName: '', rentalName: '',
rentalStartDate: '',
rentalEndDate: '',
rentalReason: '',
rentalRemarks: '',
rentalPeripherals: [],
allUsers: [],
history: { rental: [], assignment: [] }, history: { rental: [], assignment: [] },
init() { init() {
this.fetchAssets(); this.fetchAssets();
this.fetchUsers();
}, },
fetchAssets() { fetchAssets() {
@ -243,6 +453,12 @@
}); });
}, },
fetchUsers() {
fetch('api.php?action=get_users').then(res => res.json()).then(data => {
this.allUsers = data;
});
},
get filteredAssets() { get filteredAssets() {
if (!this.searchQuery) return this.assets; if (!this.searchQuery) return this.assets;
const q = this.searchQuery.toLowerCase(); const q = this.searchQuery.toLowerCase();
@ -273,32 +489,134 @@
openRentalModal(asset) { openRentalModal(asset) {
this.selectedAsset = asset; this.selectedAsset = asset;
this.rentalUserId = asset.rental_user_id || '';
this.rentalName = asset.rental_user_name || ''; this.rentalName = asset.rental_user_name || '';
this.rentalStartDate = asset.rental_start_date || new Date().toISOString().split('T')[0];
this.rentalEndDate = asset.rental_end_scheduled || '';
this.rentalReason = asset.rental_reason || '';
this.rentalRemarks = asset.remarks || '';
this.rentalPeripherals = asset.rental_peripherals ? asset.rental_peripherals.split(',') : [];
this.showRentalModal = true; this.showRentalModal = true;
}, },
updateRentalName() {
const user = this.allUsers.find(u => u.id == this.rentalUserId);
if (user) {
this.rentalName = user.name;
} else {
this.rentalName = '';
}
},
submitRental() { submitRental() {
if (!this.rentalUserId) {
window.showAlert('임대할 직원을 선택해주세요.', '알림', 'warning');
return;
}
fetch('api.php?action=update_rental', { fetch('api.php?action=update_rental', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
id: this.selectedAsset.id, id: this.selectedAsset.id,
rental_user_name: this.rentalName rental_user_id: this.rentalUserId,
rental_user_name: this.rentalName,
rental_start_date: this.rentalStartDate,
rental_end_scheduled: this.rentalEndDate,
rental_reason: this.rentalReason,
rental_peripherals: this.rentalPeripherals.join(','),
remarks: this.rentalRemarks
}) })
}).then(res => res.json()).then(data => { }).then(res => res.json()).then(data => {
if (data.success) { if (data.success) {
window.showAlert('임대 설정이 완료되었습니다.', '성공', 'success');
this.showRentalModal = false; this.showRentalModal = false;
this.fetchAssets(); this.fetchAssets();
} else {
window.showAlert(data.error || '임대 적용 중 오류가 발생했습니다.', '오류', 'error');
} }
}).catch(err => {
window.showAlert('서버와의 통신에 실패했습니다.', '통신 오류', 'error');
}); });
}, },
returnRental(asset) {
window.showConfirm(`${asset.rental_user_name}님의 노트북 반납을 처리하시겠습니까?`, () => {
fetch('api.php?action=return_rental', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: asset.id })
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert('반납 처리가 완료되었습니다.', '성공', 'success');
this.fetchAssets();
}
});
}, '반납 확인');
},
calculateElapsed(startDate) {
if (!startDate) return 0;
const start = new Date(startDate);
const now = new Date();
const diffTime = Math.abs(now - start);
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return diffDays + 1;
},
openHistoryModal(asset) { openHistoryModal(asset) {
this.selectedAsset = asset; this.selectedAsset = asset;
fetch(`api.php?action=get_asset_history&asset_id=${asset.id}`).then(res => res.json()).then(data => { fetch(`api.php?action=get_asset_history&asset_id=${asset.id}`).then(res => res.json()).then(data => {
this.history = data; this.history = data;
this.showHistoryModal = true; this.showHistoryModal = true;
}); });
},
openRemarksModal(asset) {
this.selectedAsset = asset;
this.isEditingRemarks = false;
this.tempRemarks = asset.remarks || '';
this.showRemarksModal = true;
},
startEditRemarks() {
this.tempRemarks = this.selectedAsset.remarks || '';
this.isEditingRemarks = true;
},
saveRemarks() {
fetch('api.php?action=update_asset_remarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: this.selectedAsset.id,
remarks: this.tempRemarks
})
}).then(res => res.json()).then(data => {
if (data.success) {
this.selectedAsset.remarks = this.tempRemarks;
this.isEditingRemarks = false;
this.fetchAssets();
}
});
},
deleteRemarks() {
window.showConfirm('비고 내용을 완전히 삭제하시겠습니까?', () => {
fetch('api.php?action=update_asset_remarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: this.selectedAsset.id,
remarks: ''
})
}).then(res => res.json()).then(data => {
if (data.success) {
this.selectedAsset.remarks = '';
this.tempRemarks = '';
this.fetchAssets();
}
});
}, '비고 삭제 확인');
} }
} }
} }

328
users.php
View file

@ -1,3 +1,4 @@
<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
@ -96,6 +97,13 @@
<div class="w-2 h-2 rounded-full mr-2" :class="filterOnLeave ? 'bg-white' : 'bg-amber-500'"></div> <div class="w-2 h-2 rounded-full mr-2" :class="filterOnLeave ? 'bg-white' : 'bg-amber-500'"></div>
휴직자 표시 휴직자 표시
</button> </button>
<button @click="filterClassification = !filterClassification"
:class="filterClassification ? 'bg-indigo-600 text-white border-indigo-600 shadow-md shadow-indigo-100' : 'bg-white text-slate-500 border-slate-200 hover:border-indigo-300 hover:text-indigo-500'"
class="px-4 py-2 rounded-xl text-xs font-bold border transition-all flex items-center">
<div class="w-2 h-2 rounded-full mr-2" :class="filterClassification ? 'bg-white' : 'bg-indigo-500'">
</div>
분류 대상 표시
</button>
</div> </div>
<!-- Bulk Command Center (Fixed Floating Bar) --> <!-- Bulk Command Center (Fixed Floating Bar) -->
@ -167,6 +175,26 @@
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span> x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</div> </div>
</th> </th>
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">
<div class="flex items-center gap-1">
<span class="mr-1">노트북</span>
<span class="text-slate-300 font-normal">(</span>
<button @click="toggleSort('laptop_purchase_date')"
class="hover:text-blue-600 transition-colors flex items-center">
취득년도
<span class="ml-0.5 text-[10px]" x-show="sortKey === 'laptop_purchase_date'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</button>
<span class="text-slate-300 font-normal">/</span>
<button @click="toggleSort('laptop_model_name')"
class="hover:text-blue-600 transition-colors flex items-center">
모델명
<span class="ml-0.5 text-[10px]" x-show="sortKey === 'laptop_model_name'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</button>
<span class="text-slate-300 font-normal">)</span>
</div>
</th>
<th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">연락처 / <th class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider">연락처 /
사내전화</th> 사내전화</th>
<th @click="toggleSort('accounting_type')" <th @click="toggleSort('accounting_type')"
@ -185,31 +213,71 @@
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span> x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</div> </div>
</th> </th>
<th class="px-6 py-4 text-left text-xs font-bold text-emerald-500 uppercase tracking-wider">단기임대
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-50"> <tbody class="divide-y divide-slate-50">
<template x-for="user in filteredUsers" :key="user.id"> <template x-for="user in filteredUsers" :key="user.id">
<tr class="hover:bg-slate-50/80 transition-colors group"> <tr class="hover:shadow-md transition-all group relative overflow-hidden"
<td class="px-6 py-4"> :class="getPositionInfo(user.position).bgClass" :style="getRowStyle(user)">
<td class="px-6 py-4 relative z-10">
<input type="checkbox" :value="user.id" x-model="selectedIds" <input type="checkbox" :value="user.id" x-model="selectedIds"
class="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"> class="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500">
</td> </td>
<td class="px-6 py-4 whitespace-nowrap cursor-pointer" @click="editUser(user)"> <td class="px-6 py-4 whitespace-nowrap cursor-pointer relative z-10"
@click="editUser(user)">
<div class="flex items-center"> <div class="flex items-center">
<div class="h-10 w-10 flex-shrink-0 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600 font-bold" <div class="h-11 w-11 flex-shrink-0 rounded-2xl flex items-center justify-center font-black text-lg shadow-sm transform group-hover:scale-110 transition-transform duration-300"
x-text="user.name.charAt(0)"></div> :class="getPositionInfo(user.position).iconClass" x-text="user.name.charAt(0)">
</div>
<div class="ml-4"> <div class="ml-4">
<div class="text-sm font-bold text-slate-900 group-hover:text-blue-600 transition-colors" <div class="flex items-center gap-2">
x-text="user.name"></div> <div class="text-sm font-black text-slate-900 group-hover:text-blue-700 transition-colors"
<div class="text-xs text-slate-400" x-text="user.emp_id"></div> x-text="user.name"></div>
<template x-if="getPositionInfo(user.position).isVIP">
<span
class="text-[9px] px-1.5 py-0.5 rounded-md font-black uppercase tracking-tighter"
:class="getPositionInfo(user.position).tagClass"
x-text="'VIP Member'"></span>
</template>
</div>
<div class="text-[11px] font-bold text-slate-400" x-text="user.emp_id"></div>
</div> </div>
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap cursor-pointer" @click="editUser(user)"> <td class="px-6 py-4 whitespace-nowrap cursor-pointer relative z-10"
<div class="text-sm font-semibold text-slate-700" @click="editUser(user)">
<div class="text-sm font-bold text-slate-700"
x-text="user.dept_name ? user.dept_name.split(' > ').pop() : '미소속'"> x-text="user.dept_name ? user.dept_name.split(' > ').pop() : '미소속'">
</div> </div>
<div class="text-xs text-slate-400" x-text="user.position || '-'"></div> <div class="text-xs font-black text-slate-900 mt-0.5 flex items-center gap-1.5"
:class="getPositionInfo(user.position).isVIP ? getPositionInfo(user.position).textClass : ''">
<template x-if="getPositionInfo(user.position).isVIP">
<div class="w-1.5 h-1.5 rounded-full"
:class="getPositionInfo(user.position).tagClass"></div>
</template>
<span x-text="user.position || '-'"></span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap cursor-pointer relative z-10"
@click="editUser(user)">
<template x-if="user.laptop_tag">
<div>
<div class="text-[11px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded inline-block mb-1"
x-text="user.laptop_tag"></div>
<div class="text-[10px] text-slate-500 font-bold">
<span
x-text="user.laptop_purchase_date ? user.laptop_purchase_date.substring(0,4) : ''"></span>
<span x-show="user.laptop_purchase_date && user.laptop_model_name"> /
</span>
<span x-text="user.laptop_model_name || ''"></span>
</div>
</div>
</template>
<template x-if="!user.laptop_tag">
<span class="text-[10px] text-slate-300 italic">미배정</span>
</template>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-slate-600 font-medium" x-text="user.email || '-'"></div> <div class="text-sm text-slate-600 font-medium" x-text="user.email || '-'"></div>
@ -226,7 +294,8 @@
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<span <span
class="px-3 py-1 text-[10px] font-black rounded-lg border border-slate-200 bg-slate-50 text-slate-500 uppercase" class="px-3 py-1.5 text-[10px] font-black rounded-lg border shadow-sm transition-all"
:class="getAccountingInfo(user.accounting_type).badgeClass"
x-text="user.accounting_type"></span> x-text="user.accounting_type"></span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
@ -248,6 +317,16 @@
class="px-3 py-1 text-xs font-bold rounded-full bg-indigo-50 text-indigo-600 border border-indigo-100">분류</span> class="px-3 py-1 text-xs font-bold rounded-full bg-indigo-50 text-indigo-600 border border-indigo-100">분류</span>
</template> </template>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<template x-if="user.rental_count > 0">
<button @click="showUserRentals(user)"
class="w-8 h-8 rounded-full bg-emerald-100 text-emerald-600 font-black text-sm hover:bg-emerald-600 hover:text-white transition-all shadow-sm border border-emerald-200"
x-text="user.rental_count"></button>
</template>
<template x-if="!user.rental_count || user.rental_count == 0">
<span class="text-slate-200 text-xs">-</span>
</template>
</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
@ -264,6 +343,80 @@
</div> </div>
</main> </main>
<!-- Rental List Modal -->
<div x-show="showRentalListModal" x-cloak class="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showRentalListModal = false"></div>
<div
class="bg-white rounded-[2rem] p-8 max-w-2xl w-full relative z-[121] shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="flex justify-between items-start mb-6">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-emerald-100 rounded-2xl flex items-center justify-center">
<svg class="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</div>
<div>
<h3 class="text-2xl font-black text-slate-900" x-text="selectedUser?.name + '님의 단기 임대 목록'"></h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest"
x-text="(selectedUser?.dept_name || '미소속') + ' / ' + (selectedUser?.position || '-')"></p>
</div>
</div>
<button @click="showRentalListModal = false"
class="text-slate-300 hover:text-slate-900 transition-colors">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto pr-2 no-scrollbar">
<div class="grid grid-cols-1 gap-4">
<template x-for="rental in userRentals" :key="rental.id">
<div
class="bg-slate-50 rounded-2xl p-5 border border-slate-100 items-center justify-between flex">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-black text-blue-600 bg-blue-100 px-2 py-0.5 rounded-lg"
x-text="rental.asset_tag"></span>
<span class="text-sm font-bold text-slate-800" x-text="rental.model_name"></span>
</div>
<div class="text-[10px] text-slate-400 font-bold uppercase tracking-wider"
x-text="rental.manufacturer"></div>
<template x-if="rental.rental_peripherals">
<div class="flex flex-wrap gap-1 mt-2">
<template x-for="p in rental.rental_peripherals.split(',')">
<span
class="px-1.5 py-0.5 bg-emerald-50 text-emerald-600 text-[9px] font-black rounded border border-emerald-100"
x-text="p"></span>
</template>
</div>
</template>
</div>
<div class="text-right">
<div class="text-[11px] font-black text-slate-400 uppercase mb-1">임대 현황</div>
<div class="space-y-1">
<div class="text-xs font-bold text-slate-700">시작: <span
x-text="rental.rental_start_date"></span></div>
<div class="text-xs font-bold text-rose-500" x-show="rental.rental_end_scheduled">
예정: <span x-text="rental.rental_end_scheduled"></span></div>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="mt-8">
<button @click="showRentalListModal = false"
class="w-full py-4 bg-slate-900 text-white rounded-2xl font-bold hover:bg-slate-800 transition-all shadow-lg shadow-slate-200">
확인완료
</button>
</div>
</div>
</div>
<!-- User Registration/Edit Modal --> <!-- User Registration/Edit Modal -->
<div x-show="showModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4"> <div x-show="showModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showModal = false"></div> <div class="fixed inset-0 modal-bg" @click="showModal = false"></div>
@ -338,6 +491,36 @@
</select> </select>
</div> </div>
</div> </div>
<!-- Laptop Info Section (Read-only) -->
<template x-if="isEdit && formData.laptop_tag">
<div class="bg-blue-50/50 p-6 rounded-[2rem] border border-blue-100/50 space-y-4 mt-6">
<div class="flex items-center gap-2 mb-2">
<div class="w-1.5 h-1.5 rounded-full bg-blue-500"></div>
<h4 class="text-xs font-black text-blue-600 uppercase tracking-widest">사용 중인 노트북 정보</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">자산번호</label>
<div class="px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-blue-600"
x-text="formData.laptop_tag"></div>
</div>
<div>
<label
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">모델명</label>
<div class="px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700"
x-text="formData.laptop_model_name || '-'"></div>
</div>
<div>
<label
class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">취득일</label>
<div class="px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700"
x-text="formData.laptop_purchase_date || '-'"></div>
</div>
</div>
</div>
</template>
<div class="pt-6 flex gap-4"> <div class="pt-6 flex gap-4">
<button type="button" @click="showModal = false" <button type="button" @click="showModal = false"
class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold hover:bg-slate-200 transition-all">취소</button> class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold hover:bg-slate-200 transition-all">취소</button>
@ -360,12 +543,15 @@
searchQuery: '', searchQuery: '',
filterRetired: false, filterRetired: false,
filterOnLeave: true, filterOnLeave: true,
filterClassification: true,
sortKey: 'name', sortKey: 'name',
sortOrder: 'asc', sortOrder: 'asc',
selectedIds: [], selectedIds: [],
bulkStatus: '', bulkStatus: '',
bulkDeptId: '', bulkDeptId: '',
bulkPosition: '', bulkPosition: '',
showRentalListModal: false,
userRentals: [],
formData: { id: '', name: '', emp_id: '', department_id: '', position: '', email: '', mobile: '', accounting_type: '일반회계', status: 'active', phone: '' }, formData: { id: '', name: '', emp_id: '', department_id: '', position: '', email: '', mobile: '', accounting_type: '일반회계', status: 'active', phone: '' },
init() { init() {
@ -384,22 +570,99 @@
result = result.filter(u => u.name.toLowerCase().includes(q) || u.emp_id.toLowerCase().includes(q) || (u.email && u.email.toLowerCase().includes(q))); result = result.filter(u => u.name.toLowerCase().includes(q) || u.emp_id.toLowerCase().includes(q) || (u.email && u.email.toLowerCase().includes(q)));
} }
if (!this.filterRetired) result = result.filter(u => u.status !== 'retired'); if (!this.filterRetired) result = result.filter(u => u.status !== 'retired');
if (!this.filterOnLeave) result = result.filter(u => u.status !== 'on_leave' && u.status !== 'classification'); if (!this.filterOnLeave) result = result.filter(u => u.status !== 'on_leave');
if (!this.filterClassification) result = result.filter(u => u.status !== 'classification');
if (this.sortKey) { // 직위 랭킹 함수
result.sort((a, b) => { const getRank = (pos) => {
if (!pos) return 99;
if (pos.includes('부회장')) return 1;
if (pos.includes('총괄')) return 2;
if (pos.includes('원장')) return 3;
if (pos.includes('센터장')) return 4;
if (pos.includes('본부장')) return 5;
if (pos.includes('실장')) return 6;
if (pos.includes('부문장')) return 7;
if (pos.includes('팀장')) return 8;
return 90;
};
result.sort((a, b) => {
if (this.sortKey) {
let valA = a[this.sortKey] || ''; let valA = a[this.sortKey] || '';
let valB = b[this.sortKey] || ''; let valB = b[this.sortKey] || '';
if (typeof valA === 'string') valA = valA.toLowerCase(); if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase(); if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return this.sortOrder === 'asc' ? -1 : 1; if (valA !== valB) {
if (valA > valB) return this.sortOrder === 'asc' ? 1 : -1; if (valA < valB) return this.sortOrder === 'asc' ? -1 : 1;
return 0; if (valA > valB) return this.sortOrder === 'asc' ? 1 : -1;
}); }
} }
// 2순위: 직위 랭킹 (정렬 기준이 같을 때만 적용)
return getRank(a.position) - getRank(b.position);
});
return result; return result;
}, },
getPositionInfo(pos) {
if (!pos) return { bgClass: 'bg-white', iconClass: 'bg-slate-100 text-slate-500', isVIP: false };
const configs = [
{ key: '부회장', from: '#fbbf24', to: '#d97706', text: 'text-amber-700', icon: 'bg-amber-100 text-amber-600', tag: 'bg-amber-500 text-white' },
{ key: '총괄', from: '#3b82f6', to: '#1d4ed8', text: 'text-blue-700', icon: 'bg-blue-100 text-blue-600', tag: 'bg-blue-600 text-white' },
{ key: '원장', from: '#10b981', to: '#059669', text: 'text-emerald-700', icon: 'bg-emerald-100 text-emerald-600', tag: 'bg-emerald-600 text-white' },
{ key: '센터장', from: '#06b6d4', to: '#0891b2', text: 'text-cyan-700', icon: 'bg-cyan-100 text-cyan-600', tag: 'bg-cyan-600 text-white' },
{ key: '본부장', from: '#8b5cf6', to: '#7c3aed', text: 'text-purple-700', icon: 'bg-purple-100 text-purple-600', tag: 'bg-purple-600 text-white' },
{ key: '실장', from: '#64748b', to: '#475569', text: 'text-slate-700', icon: 'bg-slate-100 text-slate-600', tag: 'bg-slate-600 text-white' },
{ key: '부문장', from: '#f43f5e', to: '#e11d48', text: 'text-rose-700', icon: 'bg-rose-100 text-rose-600', tag: 'bg-rose-600 text-white' },
{ key: '팀장', from: '#6366f1', to: '#4f46e5', text: 'text-indigo-700', icon: 'bg-indigo-100 text-indigo-600', tag: 'bg-indigo-600 text-white' }
];
const config = configs.find(c => pos.includes(c.key));
if (config) {
return {
isVIP: true,
from: config.from,
bgClass: 'hover:bg-opacity-50 transition-all cursor-pointer',
iconClass: config.icon,
tagClass: config.tag,
textClass: config.text
};
}
return { bgClass: 'bg-white', iconClass: 'bg-slate-100 text-slate-500', isVIP: false };
},
getAccountingInfo(type) {
if (type === '특별회계') {
return {
isSpecial: true,
badgeClass: 'bg-amber-100 text-amber-700 border-amber-200',
rowGradient: 'linear-gradient(to left, rgba(251, 191, 36, 0.1) 0%, transparent 40%)'
};
}
return {
isSpecial: false,
badgeClass: 'bg-blue-50 text-blue-600 border-blue-100',
rowGradient: ''
};
},
getRowStyle(user) {
const vip = this.getPositionInfo(user.position);
const acc = this.getAccountingInfo(user.accounting_type);
let gradients = [];
if (vip.isVIP) {
gradients.push(`linear-gradient(to right, ${vip.from}08 0%, transparent 60%)`);
}
if (acc.isSpecial) {
gradients.push(acc.rowGradient);
}
return gradients.length > 0 ? `background: ${gradients.join(', ')}` : 'background: white';
},
fetchUsers() { fetchUsers() {
const scrollPos = window.scrollY; const scrollPos = window.scrollY;
this.loading = true; this.loading = true;
@ -429,6 +692,16 @@
this.selectedIds = checked ? this.filteredUsers.map(u => u.id) : []; this.selectedIds = checked ? this.filteredUsers.map(u => u.id) : [];
}, },
showUserRentals(user) {
this.selectedUser = user;
fetch(`api.php?action=get_user_rentals&user_id=${user.id}`)
.then(res => res.json())
.then(data => {
this.userRentals = data;
this.showRentalListModal = true;
});
},
openAddModal() { openAddModal() {
this.resetForm(); this.resetForm();
this.showModal = true; this.showModal = true;
@ -441,6 +714,10 @@
}, },
submitUser() { submitUser() {
if (!this.formData.department_id) {
window.showAlert('부서를 선택해주세요.', '입력 확인', 'error');
return;
}
const action = this.isEdit ? 'update_user' : 'add_user'; const action = this.isEdit ? 'update_user' : 'add_user';
fetch(`api.php?action=${action}`, { fetch(`api.php?action=${action}`, {
method: 'POST', method: 'POST',
@ -448,9 +725,15 @@
body: JSON.stringify(this.formData) body: JSON.stringify(this.formData)
}).then(res => res.json()).then(data => { }).then(res => res.json()).then(data => {
if (data.success) { if (data.success) {
window.showAlert(this.isEdit ? '정보가 수정되었습니다.' : '새 직원이 등록되었습니다.', '성공', 'success');
this.showModal = false; this.showModal = false;
this.fetchUsers(); this.fetchUsers();
} else {
window.showAlert(data.error || '처리 중 오류가 발생했습니다.', '오류', 'error');
} }
}).catch(err => {
console.error(err);
window.showAlert('서버와 통신 중 문제가 발생했습니다.', '시스템 오류', 'error');
}); });
}, },
@ -467,18 +750,21 @@
}) })
}).then(res => res.json()).then(data => { }).then(res => res.json()).then(data => {
if (data.success) { if (data.success) {
window.showAlert(`${this.selectedIds.length}명의 직원 정보가 일괄 변경되었습니다.`, '변경 성공', 'success');
this.selectedIds = []; this.selectedIds = [];
this.bulkStatus = ''; this.bulkStatus = '';
this.bulkDeptId = ''; this.bulkDeptId = '';
this.bulkPosition = ''; this.bulkPosition = '';
this.fetchUsers(); this.fetchUsers();
} else {
window.showAlert('직원 정보 일괄 변경에 실패했습니다.', '변경 실패', 'error');
} }
}); });
}, },
resetForm() { resetForm() {
this.isEdit = false; this.isEdit = false;
this.formData = { id: '', name: '', emp_id: '', department_id: '', position: '', email: '', mobile: '', accounting_type: '일반회계', status: 'active' }; this.formData = { id: '', name: '', emp_id: '', department_id: '', position: '', email: '', mobile: '', accounting_type: '일반회계', status: 'active', phone: '' };
} }
} }
} }