485 lines
No EOL
27 KiB
PHP
485 lines
No EOL
27 KiB
PHP
<!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>
|
|
<div class="flex items-center gap-3">
|
|
<div class="relative group">
|
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-blue-500 transition-colors"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
<input type="text" x-model="searchQuery"
|
|
class="block w-64 pl-10 pr-3 py-2.5 border border-slate-200 rounded-xl leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm shadow-sm"
|
|
placeholder="사원명, 품목 검색...">
|
|
</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 shrink-0">
|
|
<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>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Table List -->
|
|
<div class="bg-white rounded-[2.5rem] shadow-sm border border-slate-200 overflow-hidden"
|
|
x-show="!loading && sortedRentals.length > 0">
|
|
<table class="min-w-full divide-y divide-slate-100">
|
|
<thead class="bg-slate-50/50">
|
|
<tr>
|
|
<th @click="sortBy('user_name')"
|
|
class="px-6 py-4 text-left text-[11px] font-black text-slate-400 uppercase tracking-widest cursor-pointer hover:bg-slate-100 transition-colors">
|
|
<div class="flex items-center gap-1">
|
|
사원명
|
|
<svg class="w-3 h-3 transition-transform"
|
|
:class="sortKey === 'user_name' ? (sortOrder === 'asc' ? 'rotate-180' : '') : 'opacity-20'"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3"
|
|
d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</th>
|
|
<th class="px-6 py-4 text-left text-[11px] font-black text-slate-400 uppercase tracking-widest">
|
|
임대 품목</th>
|
|
<th @click="sortBy('rental_start_date')"
|
|
class="px-6 py-4 text-left text-[11px] font-black text-slate-400 uppercase tracking-widest cursor-pointer hover:bg-slate-100 transition-colors">
|
|
<div class="flex items-center gap-1">
|
|
임대일
|
|
<svg class="w-3 h-3 transition-transform"
|
|
:class="sortKey === 'rental_start_date' ? (sortOrder === 'asc' ? 'rotate-180' : '') : 'opacity-20'"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3"
|
|
d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</th>
|
|
<th @click="sortBy('status')"
|
|
class="px-6 py-4 text-left text-[11px] font-black text-slate-400 uppercase tracking-widest cursor-pointer hover:bg-slate-100 transition-colors">
|
|
<div class="flex items-center gap-1">
|
|
상태/경과
|
|
<svg class="w-3 h-3 transition-transform"
|
|
:class="sortKey === 'status' ? (sortOrder === 'asc' ? 'rotate-180' : '') : 'opacity-20'"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3"
|
|
d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</th>
|
|
<th class="px-6 py-4 text-left text-[11px] font-black text-slate-400 uppercase tracking-widest">
|
|
사유</th>
|
|
<th
|
|
class="px-6 py-4 text-right text-[11px] font-black text-slate-400 uppercase tracking-widest">
|
|
관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-50">
|
|
<template x-for="rental in sortedRentals" :key="rental.id">
|
|
<tr class="hover:bg-slate-50/80 transition-colors group"
|
|
:class="rental.status === 'returned' ? 'opacity-60 bg-slate-50/30' : ''">
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-black"
|
|
:class="rental.status === 'returned' ? 'bg-slate-100 text-slate-400' : 'bg-emerald-50 text-emerald-600'">
|
|
<span x-text="rental.user_name.charAt(0)"></span>
|
|
</div>
|
|
<span class="text-sm font-black text-slate-900" x-text="rental.user_name"></span>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<div class="flex flex-wrap gap-1">
|
|
<template x-for="item in (rental.items ? rental.items.split(', ') : [])"
|
|
:key="item">
|
|
<span
|
|
class="px-2 py-0.5 bg-blue-50 text-blue-600 text-[10px] font-black rounded-md border border-blue-100"
|
|
x-text="item"></span>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="text-xs font-bold text-slate-600" x-text="rental.rental_start_date"></span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<template x-if="rental.status === 'rented'">
|
|
<div
|
|
class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-blue-50 text-blue-600 rounded-full">
|
|
<span class="w-1 h-1 bg-blue-500 rounded-full animate-pulse"></span>
|
|
<span class="text-[10px] font-black"
|
|
x-text="calculateElapsed(rental.rental_start_date) + '일째'"></span>
|
|
</div>
|
|
</template>
|
|
<template x-if="rental.status === 'returned'">
|
|
<div class="inline-flex items-center bg-slate-100 px-2.5 py-1 rounded-full">
|
|
<span class="text-[10px] font-black text-slate-500"
|
|
x-text="'반납 (' + rental.rental_return_date + ')'"></span>
|
|
</div>
|
|
</template>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<p class="text-[11px] text-slate-500 font-medium truncate max-w-[200px]"
|
|
:title="rental.rental_reason" x-text="rental.rental_reason || '-'"></p>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
|
<template x-if="rental.status === 'rented'">
|
|
<button @click="returnRental(rental)"
|
|
class="px-4 py-1.5 bg-slate-900 text-white rounded-xl font-black text-[10px] hover:bg-blue-600 transition-all active:scale-95">
|
|
반납 처리
|
|
</button>
|
|
</template>
|
|
<template x-if="rental.status === 'returned'">
|
|
<span class="text-[10px] font-bold text-slate-300">처리완료</span>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div x-show="loading" class="p-20 flex justify-center">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
<div x-show="!loading && filteredRentals.length === 0"
|
|
class="p-20 text-center bg-white rounded-3xl border border-dashed border-slate-300">
|
|
<template x-if="searchQuery">
|
|
<p class="text-slate-400 font-medium italic">검색 결과와 일치하는 임대 기록이 없습니다.</p>
|
|
</template>
|
|
<template x-if="!searchQuery">
|
|
<p class="text-slate-400 font-medium italic">등록된 임대 기록이 없습니다.</p>
|
|
</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 class="relative" x-on:click.outside="showUserDropdown = false">
|
|
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">임대사원 선택
|
|
(필수)</label>
|
|
<div class="relative">
|
|
<input type="text" x-model="userSearchQuery"
|
|
@focus="showUserDropdown = true; userSearchQuery = ''" @input="showUserDropdown = true"
|
|
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 font-bold text-sm text-slate-700">
|
|
|
|
<div class="absolute right-3 top-3.5 flex items-center gap-2">
|
|
<button type="button" x-show="userSearchQuery" @click="clearUser()"
|
|
class="text-slate-400 hover:text-slate-600">
|
|
<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="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Search Dropdown -->
|
|
<div x-show="showUserDropdown" x-transition
|
|
class="absolute z-[120] mt-2 w-full bg-white rounded-[1.5rem] shadow-2xl border border-slate-100 max-h-60 overflow-y-auto no-scrollbar">
|
|
<div class="p-2 border-b border-slate-50 sticky top-0 bg-white/90 backdrop-blur-sm">
|
|
<div class="text-[9px] font-black text-slate-400 uppercase px-2 py-1">Search Results
|
|
</div>
|
|
</div>
|
|
<div class="py-1">
|
|
<template x-for="user in filteredUsers" :key="user.id">
|
|
<div @click="selectUser(user)"
|
|
class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between transition-colors group">
|
|
<div>
|
|
<div class="text-sm font-bold text-slate-700 group-hover:text-blue-600"
|
|
x-text="user.name"></div>
|
|
<div class="text-[10px] text-slate-400 font-medium"
|
|
x-text="user.dept_name || '부서 정보 없음'"></div>
|
|
</div>
|
|
<div class="text-[10px] font-black text-slate-300 group-hover:text-blue-400"
|
|
x-text="user.emp_id"></div>
|
|
</div>
|
|
</template>
|
|
<div x-show="filteredUsers.length === 0" class="px-4 py-8 text-center">
|
|
<p class="text-xs text-slate-400 italic">검색 결과가 없습니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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: [],
|
|
loading: true,
|
|
searchQuery: '',
|
|
showAddModal: false,
|
|
formData: {
|
|
user_id: '',
|
|
user_name: '',
|
|
rental_start_date: new Date().toISOString().split('T')[0],
|
|
rental_reason: '',
|
|
items: ['']
|
|
},
|
|
sortKey: 'rental_start_date',
|
|
sortOrder: 'desc',
|
|
userSearchQuery: '',
|
|
showUserDropdown: false,
|
|
|
|
get filteredUsers() {
|
|
if (!this.userSearchQuery) return this.users.slice(0, 50);
|
|
const q = this.userSearchQuery.toLowerCase();
|
|
return this.users.filter(u =>
|
|
(u.name && u.name.toLowerCase().includes(q)) ||
|
|
(u.emp_id && u.emp_id.toLowerCase().includes(q)) ||
|
|
(u.dept_name && u.dept_name.toLowerCase().includes(q))
|
|
);
|
|
},
|
|
|
|
selectUser(user) {
|
|
this.formData.user_id = user.id;
|
|
this.userSearchQuery = `${user.name} (${user.emp_id})`;
|
|
this.showUserDropdown = false;
|
|
this.updateUserName();
|
|
},
|
|
|
|
clearUser() {
|
|
this.formData.user_id = '';
|
|
this.userSearchQuery = '';
|
|
this.updateUserName();
|
|
},
|
|
|
|
init() {
|
|
this.fetchRentals();
|
|
this.fetchUsers();
|
|
},
|
|
|
|
fetchRentals() {
|
|
const scrollPos = window.scrollY;
|
|
this.loading = true;
|
|
fetch('api.php?action=get_general_rentals').then(res => res.json()).then(data => {
|
|
this.rentals = data;
|
|
this.loading = false;
|
|
this.$nextTick(() => window.scrollTo(0, scrollPos));
|
|
});
|
|
},
|
|
|
|
get filteredRentals() {
|
|
if (!this.searchQuery) return this.rentals;
|
|
const q = this.searchQuery.toLowerCase();
|
|
return this.rentals.filter(r =>
|
|
(r.user_name && r.user_name.toLowerCase().includes(q)) ||
|
|
(r.items && r.items.toLowerCase().includes(q)) ||
|
|
(r.rental_reason && r.rental_reason.toLowerCase().includes(q))
|
|
);
|
|
},
|
|
|
|
get sortedRentals() {
|
|
return [...this.filteredRentals].sort((a, b) => {
|
|
let modifier = this.sortOrder === 'asc' ? 1 : -1;
|
|
if (a[this.sortKey] < b[this.sortKey]) return -1 * modifier;
|
|
if (a[this.sortKey] > b[this.sortKey]) return 1 * modifier;
|
|
return 0;
|
|
});
|
|
},
|
|
|
|
sortBy(key) {
|
|
if (this.sortKey === key) {
|
|
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
this.sortKey = key;
|
|
this.sortOrder = 'asc';
|
|
}
|
|
},
|
|
|
|
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.userSearchQuery = '';
|
|
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>
|