feat: 품목정보 상세 패널 추가 및 프로젝트/근태/권한 기능 확장
- Items: 우측에 이미지, 담당자, 입고/발주내역 패널 추가 (fItems 윈폼 동일) - Project: 목록 및 상세 다이얼로그 구현 - Kuntae: 오류검사/수정 기능 추가 - UserAuth: 사용자 권한 관리 페이지 추가 - UserGroup: 그룹정보 다이얼로그로 전환 - Header: 사용자 메뉴 서브메뉴 방향 수정, 즐겨찾기 기능 - Backend API: Items 상세/담당자/구매내역, 근태 오류검사, 프로젝트 목록 등 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, Plus, Package } from 'lucide-react';
|
||||
import { Search, Plus, Package, Image, Users, TrendingDown, ShoppingCart } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { ItemInfo } from '@/types';
|
||||
import { ItemInfo, ItemDetail, SupplierStaff, PurchaseHistoryItem } from '@/types';
|
||||
import { ItemEditDialog } from '@/components/items';
|
||||
|
||||
export function ItemsPage() {
|
||||
@@ -15,6 +15,14 @@ export function ItemsPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<ItemInfo | null>(null);
|
||||
|
||||
// 우측 패널 상태
|
||||
const [selectedItemDetail, setSelectedItemDetail] = useState<ItemDetail | null>(null);
|
||||
const [itemImage, setItemImage] = useState<string | null>(null);
|
||||
const [supplierStaff, setSupplierStaff] = useState<SupplierStaff[]>([]);
|
||||
const [incomingHistory, setIncomingHistory] = useState<PurchaseHistoryItem[]>([]);
|
||||
const [orderHistory, setOrderHistory] = useState<PurchaseHistoryItem[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
@@ -36,6 +44,12 @@ export function ItemsPage() {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
// 선택 초기화
|
||||
setSelectedItemDetail(null);
|
||||
setItemImage(null);
|
||||
setSupplierStaff([]);
|
||||
setIncomingHistory([]);
|
||||
setOrderHistory([]);
|
||||
try {
|
||||
const result = await comms.getItemList(selectedCategory, searchKey);
|
||||
setItems(result);
|
||||
@@ -46,6 +60,58 @@ export function ItemsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 선택 시 상세 정보 로드
|
||||
const loadItemDetail = async (item: ItemInfo) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
// 병렬로 상세정보, 이미지, 구매내역 로드
|
||||
const [detailRes, imageRes, incomingRes, orderRes] = await Promise.all([
|
||||
comms.getItemDetail(item.idx),
|
||||
comms.getItemImage(item.idx),
|
||||
comms.getIncomingHistory(item.idx),
|
||||
comms.getOrderHistory(item.idx)
|
||||
]);
|
||||
|
||||
if (detailRes.Success && detailRes.Data) {
|
||||
setSelectedItemDetail(detailRes.Data);
|
||||
|
||||
// 공급처 담당자 로드 (supplyidx가 있을 때만)
|
||||
if (detailRes.Data.supplyidx && detailRes.Data.supplyidx > 0) {
|
||||
const staffRes = await comms.getSupplierStaff(detailRes.Data.supplyidx);
|
||||
if (staffRes.Success && staffRes.Data) {
|
||||
setSupplierStaff(staffRes.Data);
|
||||
} else {
|
||||
setSupplierStaff([]);
|
||||
}
|
||||
} else {
|
||||
setSupplierStaff([]);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageRes.Success && imageRes.Data) {
|
||||
setItemImage(imageRes.Data);
|
||||
} else {
|
||||
setItemImage(null);
|
||||
}
|
||||
|
||||
if (incomingRes.Success && incomingRes.Data) {
|
||||
setIncomingHistory(incomingRes.Data);
|
||||
} else {
|
||||
setIncomingHistory([]);
|
||||
}
|
||||
|
||||
if (orderRes.Success && orderRes.Data) {
|
||||
setOrderHistory(orderRes.Data);
|
||||
} else {
|
||||
setOrderHistory([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('상세 정보 로드 실패:', error);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (item: ItemInfo) => {
|
||||
const response = await comms.saveItem(item);
|
||||
if (response.Success) {
|
||||
@@ -63,6 +129,14 @@ export function ItemsPage() {
|
||||
setDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
setItems(items.filter((i) => i.idx !== idx));
|
||||
// 상세 패널 초기화
|
||||
if (selectedItemDetail?.idx === idx) {
|
||||
setSelectedItemDetail(null);
|
||||
setItemImage(null);
|
||||
setSupplierStaff([]);
|
||||
setIncomingHistory([]);
|
||||
setOrderHistory([]);
|
||||
}
|
||||
} else {
|
||||
alert(response.Message || '삭제 실패');
|
||||
}
|
||||
@@ -89,6 +163,11 @@ export function ItemsPage() {
|
||||
};
|
||||
|
||||
const handleRowClick = (item: ItemInfo) => {
|
||||
// 상세 정보 로드
|
||||
loadItemDetail(item);
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (item: ItemInfo) => {
|
||||
setSelectedItem(item);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -156,61 +235,206 @@ export function ItemsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">품목 목록</h2>
|
||||
<span className="text-sm text-white/50">({filteredItems.length}건)</span>
|
||||
{/* 메인 컨텐츠: 목록 + 상세 패널 */}
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* 품목 목록 (좌측) */}
|
||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-white/10 flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-white/70" />
|
||||
<h2 className="text-lg font-semibold text-white">품목 목록</h2>
|
||||
<span className="text-sm text-white/50">({filteredItems.length}건)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">분류</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">품명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">모델</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-white/70 w-24">단가</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">공급처</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item) => (
|
||||
<tr
|
||||
key={item.idx || 'new'}
|
||||
onClick={() => handleRowClick(item)}
|
||||
onDoubleClick={() => handleRowDoubleClick(item)}
|
||||
className={clsx(
|
||||
'hover:bg-white/10 transition-colors cursor-pointer',
|
||||
item.disable && 'opacity-50',
|
||||
selectedItemDetail?.idx === item.idx && 'bg-blue-600/30'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.cate}</td>
|
||||
<td className="px-3 py-2 text-white">{item.name}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.model}</td>
|
||||
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.supply}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-white/50">
|
||||
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
{/* 상세 패널 (우측) */}
|
||||
<div className="w-80 flex flex-col gap-4">
|
||||
{/* 이미지 */}
|
||||
<div className="glass-effect rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||
<Image className="w-4 h-4 text-white/70" />
|
||||
<h3 className="text-sm font-medium text-white">품목 이미지</h3>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-20">분류</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">품명</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70">모델</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-white/70 w-24">단가</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">공급처</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-white/70 w-24">제조사</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredItems.map((item) => (
|
||||
<tr
|
||||
key={item.idx || 'new'}
|
||||
onClick={() => handleRowClick(item)}
|
||||
className={clsx(
|
||||
'hover:bg-white/10 transition-colors cursor-pointer',
|
||||
item.disable && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.cate}</td>
|
||||
<td className="px-3 py-2 text-white">{item.name}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.model}</td>
|
||||
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.supply}</td>
|
||||
<td className="px-3 py-2 text-white/70">{item.manu}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-white/50">
|
||||
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="aspect-[4/3] bg-white/5 rounded-lg flex items-center justify-center overflow-hidden">
|
||||
{detailLoading ? (
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white/50"></div>
|
||||
) : itemImage ? (
|
||||
<img
|
||||
src={`data:image/jpeg;base64,${itemImage}`}
|
||||
alt="품목 이미지"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/30 text-sm">이미지 없음</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급처 담당자 */}
|
||||
<div className="glass-effect rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||
<Users className="w-4 h-4 text-white/70" />
|
||||
<h3 className="text-sm font-medium text-white">
|
||||
{selectedItemDetail?.supply ? `[${selectedItemDetail.supply}] 담당자` : '공급처 담당자'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="text-center py-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div>
|
||||
</div>
|
||||
) : supplierStaff.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-white/5">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left text-white/60">이름</th>
|
||||
<th className="px-2 py-1 text-left text-white/60">연락처</th>
|
||||
<th className="px-2 py-1 text-left text-white/60">이메일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{supplierStaff.map((staff) => (
|
||||
<tr key={staff.idx}>
|
||||
<td className="px-2 py-1 text-white">{staff.name}</td>
|
||||
<td className="px-2 py-1 text-white/70">{staff.tel}</td>
|
||||
<td className="px-2 py-1 text-white/70 truncate max-w-[100px]" title={staff.email}>{staff.email}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-center text-white/30 text-xs py-2">담당자 정보 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최근 입고내역 */}
|
||||
<div className="glass-effect rounded-xl p-3 flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||
<TrendingDown className="w-4 h-4 text-green-400" />
|
||||
<h3 className="text-sm font-medium text-white">최근 입고내역</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="text-center py-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div>
|
||||
</div>
|
||||
) : incomingHistory.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-1 py-1 text-left text-white/60">일자</th>
|
||||
<th className="px-1 py-1 text-left text-white/60">요청자</th>
|
||||
<th className="px-1 py-1 text-right text-white/60">수량</th>
|
||||
<th className="px-1 py-1 text-right text-white/60">금액</th>
|
||||
<th className="px-1 py-1 text-left text-white/60">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{incomingHistory.map((h) => (
|
||||
<tr key={h.idx}>
|
||||
<td className="px-1 py-1 text-white/80 whitespace-nowrap">{h.date}</td>
|
||||
<td className="px-1 py-1 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td>
|
||||
<td className="px-1 py-1 text-white text-right">{h.qty.toLocaleString()}</td>
|
||||
<td className="px-1 py-1 text-white text-right">{h.price.toLocaleString()}</td>
|
||||
<td className="px-1 py-1 text-white/70">{h.state}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-center text-white/30 text-xs py-2">입고내역 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발주내역 */}
|
||||
<div className="glass-effect rounded-xl p-3 flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2">
|
||||
<ShoppingCart className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-sm font-medium text-white">발주내역</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="text-center py-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div>
|
||||
</div>
|
||||
) : orderHistory.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-1 py-1 text-left text-white/60">일자</th>
|
||||
<th className="px-1 py-1 text-left text-white/60">요청자</th>
|
||||
<th className="px-1 py-1 text-right text-white/60">수량</th>
|
||||
<th className="px-1 py-1 text-right text-white/60">금액</th>
|
||||
<th className="px-1 py-1 text-left text-white/60">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{orderHistory.map((h) => (
|
||||
<tr key={h.idx}>
|
||||
<td className="px-1 py-1 text-white/80 whitespace-nowrap">{h.date}</td>
|
||||
<td className="px-1 py-1 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td>
|
||||
<td className="px-1 py-1 text-white text-right">{h.qty.toLocaleString()}</td>
|
||||
<td className="px-1 py-1 text-white text-right">{h.price.toLocaleString()}</td>
|
||||
<td className="px-1 py-1 text-white/70">{h.state}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="text-center text-white/30 text-xs py-2">발주내역 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Jobreport() {
|
||||
|
||||
// 페이징 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
const pageSize = 10;
|
||||
|
||||
// 권한 상태
|
||||
const [canViewOT, setCanViewOT] = useState(false);
|
||||
@@ -191,6 +191,7 @@ export function Jobreport() {
|
||||
setFormData({
|
||||
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
||||
projectName: data.projectName || '',
|
||||
pidx: data.pidx ?? null, // pidx도 복사
|
||||
requestpart: data.requestpart || '',
|
||||
package: data.package || '',
|
||||
type: data.type || '',
|
||||
@@ -220,6 +221,7 @@ export function Jobreport() {
|
||||
setFormData({
|
||||
pdate: data.pdate ? data.pdate.split('T')[0] : '',
|
||||
projectName: data.projectName || '',
|
||||
pidx: data.pidx ?? null,
|
||||
requestpart: data.requestpart || '',
|
||||
package: data.package || '',
|
||||
type: data.type || '',
|
||||
@@ -228,8 +230,8 @@ export function Jobreport() {
|
||||
description: data.description || '',
|
||||
hrs: data.hrs || 0,
|
||||
ot: data.ot || 0,
|
||||
jobgrp: '', // 뷰에 없는 필드
|
||||
tag: '', // 뷰에 없는 필드
|
||||
jobgrp: data.jobgrp || '',
|
||||
tag: data.tag || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
@@ -264,6 +266,7 @@ export function Jobreport() {
|
||||
itemIdx,
|
||||
formData.pdate || '',
|
||||
formData.projectName || '',
|
||||
formData.pidx,
|
||||
formData.requestpart || '',
|
||||
formData.package || '',
|
||||
formData.type || '',
|
||||
@@ -279,6 +282,7 @@ export function Jobreport() {
|
||||
response = await comms.addJobReport(
|
||||
formData.pdate || '',
|
||||
formData.projectName || '',
|
||||
formData.pidx,
|
||||
formData.requestpart || '',
|
||||
formData.package || '',
|
||||
formData.type || '',
|
||||
|
||||
457
Project/frontend/src/pages/Project.tsx
Normal file
457
Project/frontend/src/pages/Project.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FolderKanban,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
User,
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { ProjectListItem, ProjectListResponse } from '@/types';
|
||||
import { ProjectDetailDialog } from '@/components/project';
|
||||
|
||||
// 상태별 색상 매핑
|
||||
const statusColors: Record<string, { text: string; bg: string }> = {
|
||||
검토: { text: 'text-blue-400', bg: 'bg-blue-500/20' },
|
||||
진행: { text: 'text-green-400', bg: 'bg-green-500/20' },
|
||||
대기: { text: 'text-yellow-400', bg: 'bg-yellow-500/20' },
|
||||
보류: { text: 'text-orange-400', bg: 'bg-orange-500/20' },
|
||||
완료: { text: 'text-purple-400', bg: 'bg-purple-500/20' },
|
||||
'완료(보고)': { text: 'text-gray-400', bg: 'bg-gray-500/20' },
|
||||
취소: { text: 'text-red-400', bg: 'bg-red-500/20' },
|
||||
};
|
||||
|
||||
export function Project() {
|
||||
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<ProjectListItem | null>(null);
|
||||
const [showDetailDialog, setShowDetailDialog] = useState(false);
|
||||
|
||||
// 필터 상태
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [processes, setProcesses] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('--전체--');
|
||||
const [selectedProcess, setSelectedProcess] = useState('전체');
|
||||
const [userFilter, setUserFilter] = useState('');
|
||||
const [currentUserName, setCurrentUserName] = useState('');
|
||||
|
||||
// 상태 필터 체크박스
|
||||
const [statusChecks, setStatusChecks] = useState({
|
||||
검토: true,
|
||||
진행: true,
|
||||
대기: true,
|
||||
보류: true,
|
||||
완료: false,
|
||||
'완료(보고)': false,
|
||||
취소: false,
|
||||
});
|
||||
|
||||
// 날짜 필터
|
||||
const [dateType, setDateType] = useState('0');
|
||||
const [yearStart, setYearStart] = useState(new Date().getFullYear().toString());
|
||||
|
||||
// 검색
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [filteredProjects, setFilteredProjects] = useState<ProjectListItem[]>([]);
|
||||
|
||||
// 상태별 건수
|
||||
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
|
||||
|
||||
// 페이징
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 연도 목록 생성
|
||||
const years = Array.from({ length: new Date().getFullYear() - 2009 }, (_, i) => (2010 + i).toString());
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '-';
|
||||
const d = dateStr.substring(0, 10);
|
||||
if (d.length < 10) return d;
|
||||
return `${d.substring(2, 4)}-${d.substring(5, 7)}-${d.substring(8, 10)}`;
|
||||
};
|
||||
|
||||
// 초기화
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
// 현재 사용자 로드
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
const userName = (loginStatus.User as { NameK?: string }).NameK || loginStatus.User.Name || '';
|
||||
setCurrentUserName(userName);
|
||||
setUserFilter(userName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 정보 로드 오류:', error);
|
||||
}
|
||||
|
||||
// 분류/공정 목록 로드
|
||||
try {
|
||||
const [catRes, procRes] = await Promise.all([
|
||||
comms.getProjectCategories(),
|
||||
comms.getProjectProcesses(),
|
||||
]);
|
||||
if (catRes.Success && catRes.Data) setCategories(catRes.Data as string[]);
|
||||
if (procRes.Success && procRes.Data) setProcesses(procRes.Data as string[]);
|
||||
} catch (error) {
|
||||
console.error('필터 목록 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
const loadProjects = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const checkedStatuses = Object.entries(statusChecks)
|
||||
.filter(([, checked]) => checked)
|
||||
.map(([status]) => status);
|
||||
|
||||
const statusFilter = checkedStatuses.length === 7 ? 'all' : checkedStatuses.join(',');
|
||||
|
||||
const response = await comms.getProjectList(
|
||||
statusFilter,
|
||||
selectedCategory,
|
||||
selectedProcess,
|
||||
userFilter,
|
||||
dateType !== '0' ? yearStart : '',
|
||||
dateType !== '0' ? yearStart : '',
|
||||
dateType
|
||||
) as ProjectListResponse;
|
||||
|
||||
if (response.Success && response.Data) {
|
||||
setProjects(response.Data);
|
||||
setFilteredProjects(response.Data);
|
||||
if (response.StatusCounts) {
|
||||
setStatusCounts(response.StatusCounts as Record<string, number>);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('프로젝트 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusChecks, selectedCategory, selectedProcess, userFilter, dateType, yearStart]);
|
||||
|
||||
// 필터 변경 시 자동 조회
|
||||
useEffect(() => {
|
||||
if (currentUserName) {
|
||||
loadProjects();
|
||||
}
|
||||
}, [loadProjects, currentUserName]);
|
||||
|
||||
// 검색어 필터링
|
||||
useEffect(() => {
|
||||
if (!searchKey.trim()) {
|
||||
setFilteredProjects(projects);
|
||||
} else {
|
||||
const key = searchKey.toLowerCase();
|
||||
const filtered = projects.filter(
|
||||
(p) =>
|
||||
p.name?.toLowerCase().includes(key) ||
|
||||
p.userManager?.toLowerCase().includes(key) ||
|
||||
p.usermain?.toLowerCase().includes(key) ||
|
||||
p.orderno?.toLowerCase().includes(key) ||
|
||||
p.memo?.toLowerCase().includes(key)
|
||||
);
|
||||
setFilteredProjects(filtered);
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, [searchKey, projects]);
|
||||
|
||||
// 프로젝트 선택 시 다이얼로그 표시
|
||||
const handleSelectProject = (project: ProjectListItem) => {
|
||||
setSelectedProject(project);
|
||||
setShowDetailDialog(true);
|
||||
};
|
||||
|
||||
// 다이얼로그 닫기
|
||||
const handleCloseDialog = () => {
|
||||
setShowDetailDialog(false);
|
||||
};
|
||||
|
||||
// 담당자 필터 토글
|
||||
const toggleUserFilter = () => {
|
||||
setUserFilter(userFilter ? '' : currentUserName);
|
||||
};
|
||||
|
||||
// 상태 체크박스 토글
|
||||
const toggleStatus = (status: string) => {
|
||||
setStatusChecks((prev) => ({ ...prev, [status]: !prev[status as keyof typeof prev] }));
|
||||
};
|
||||
|
||||
// 페이징 계산
|
||||
const totalPages = Math.ceil(filteredProjects.length / pageSize);
|
||||
const paginatedProjects = filteredProjects.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
|
||||
// 자스민 링크 열기
|
||||
const openJasmin = (jasminId?: number) => {
|
||||
if (jasminId && jasminId > 0) {
|
||||
window.open(`https://scwa.amkor.co.kr/jasmine/view/${jasminId}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="glass-effect rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderKanban className="w-6 h-6 text-primary-400" />
|
||||
<h1 className="text-xl font-bold text-white">프로젝트 목록</h1>
|
||||
<span className="text-white/50 text-sm">({filteredProjects.length}건)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadProjects}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span>새로고침</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터 영역 */}
|
||||
<div className="space-y-3">
|
||||
{/* 상태 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.entries(statusChecks).map(([status, checked]) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => toggleStatus(status)}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
||||
checked
|
||||
? `${statusColors[status]?.bg || 'bg-white/20'} ${statusColors[status]?.text || 'text-white'} font-semibold`
|
||||
: 'bg-white/5 text-white/50'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
<span className="ml-1 text-xs">({statusCounts[status] || 0})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 추가 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={toggleUserFilter}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||
userFilter ? 'bg-primary-500/20 text-primary-400' : 'bg-white/5 text-white/50'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span>{userFilter || '전체'}</span>
|
||||
</button>
|
||||
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat} className="bg-gray-800">
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedProcess}
|
||||
onChange={(e) => setSelectedProcess(e.target.value)}
|
||||
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||
>
|
||||
{processes.map((proc) => (
|
||||
<option key={proc} value={proc} className="bg-gray-800">
|
||||
{proc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-white/50" />
|
||||
<select
|
||||
value={dateType}
|
||||
onChange={(e) => setDateType(e.target.value)}
|
||||
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||
>
|
||||
<option value="0" className="bg-gray-800">전체기간</option>
|
||||
<option value="1" className="bg-gray-800">시작일</option>
|
||||
<option value="2" className="bg-gray-800">만료일</option>
|
||||
<option value="3" className="bg-gray-800">완료일</option>
|
||||
<option value="4" className="bg-gray-800">출고일</option>
|
||||
</select>
|
||||
{dateType !== '0' && (
|
||||
<select
|
||||
value={yearStart}
|
||||
onChange={(e) => setYearStart(e.target.value)}
|
||||
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm"
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y} className="bg-gray-800">
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Search className="w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="프로젝트명, 담당자..."
|
||||
className="px-3 py-1.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="glass-effect rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/5 sticky top-0">
|
||||
<tr className="text-white/60 text-left">
|
||||
<th className="px-3 py-2 w-16">상태</th>
|
||||
<th className="px-3 py-2">프로젝트명</th>
|
||||
<th className="px-3 py-2 w-20">담당</th>
|
||||
<th className="px-3 py-2 w-28">요청자</th>
|
||||
<th className="px-3 py-2 w-20 text-center">진행률</th>
|
||||
<th className="px-3 py-2 w-24">시작</th>
|
||||
<th className="px-3 py-2 w-24">만료/완료</th>
|
||||
<th className="px-3 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
||||
로딩중...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedProjects.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
||||
프로젝트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedProjects.map((project) => {
|
||||
const statusColor = statusColors[project.status] || { text: 'text-white', bg: 'bg-white/10' };
|
||||
const isSelected = selectedProject?.idx === project.idx;
|
||||
const rowBg = project.bHighlight
|
||||
? 'bg-lime-500/10'
|
||||
: project.bCost
|
||||
? 'bg-yellow-500/10'
|
||||
: project.bmajoritem
|
||||
? 'bg-pink-500/10'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={project.idx}
|
||||
onClick={() => handleSelectProject(project)}
|
||||
className={`cursor-pointer transition-colors ${rowBg} ${
|
||||
isSelected ? 'bg-primary-500/20' : 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}>
|
||||
{project.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-3 py-2 ${statusColor.text}`}>
|
||||
<div className="truncate max-w-xs" title={project.name}>
|
||||
{project.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white/70">{project.name_champion || project.userManager}</td>
|
||||
<td className="px-3 py-2 text-white/70 text-xs">
|
||||
<div>{project.ReqLine}</div>
|
||||
<div className="text-white/50">{project.reqstaff}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-500 transition-all"
|
||||
style={{ width: `${project.progress || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-white/50">{project.progress || 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-white/50">{formatDate(project.sdate)}</td>
|
||||
<td className="px-3 py-2 text-white/50 text-xs">
|
||||
<div>{formatDate(project.ddate)}</div>
|
||||
<div className="text-white/40">{formatDate(project.edate)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{project.jasmin && project.jasmin > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openJasmin(project.jasmin);
|
||||
}}
|
||||
className="text-primary-400 hover:text-primary-300"
|
||||
title="자스민 열기"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
<span className="text-white/70 text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 상세 다이얼로그 */}
|
||||
{showDetailDialog && selectedProject && (
|
||||
<ProjectDetailDialog
|
||||
project={selectedProject}
|
||||
onClose={handleCloseDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
502
Project/frontend/src/pages/UserAuth.tsx
Normal file
502
Project/frontend/src/pages/UserAuth.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Shield, Plus, Save, Trash2, Search, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { AuthItem, AuthFieldInfo, GroupUser } from '@/types';
|
||||
import { UserSearchDialog } from '@/components/user';
|
||||
|
||||
interface AuthFormData {
|
||||
idx: number;
|
||||
user: string;
|
||||
account: number;
|
||||
purchase: number;
|
||||
purchaseEB: number;
|
||||
holyday: number;
|
||||
project: number;
|
||||
jobreport: number;
|
||||
scheapp: number;
|
||||
equipment: number;
|
||||
otconfirm: number;
|
||||
holyreq: number;
|
||||
kuntae: number;
|
||||
}
|
||||
|
||||
const initialFormData: AuthFormData = {
|
||||
idx: 0,
|
||||
user: '',
|
||||
account: 0,
|
||||
purchase: 0,
|
||||
purchaseEB: 0,
|
||||
holyday: 0,
|
||||
project: 0,
|
||||
jobreport: 0,
|
||||
scheapp: 0,
|
||||
equipment: 0,
|
||||
otconfirm: 0,
|
||||
holyreq: 0,
|
||||
kuntae: 0,
|
||||
};
|
||||
|
||||
// 권한 필드 정보 (하드코딩 - API와 동일)
|
||||
const authFields: AuthFieldInfo[] = [
|
||||
{ field: 'user', label: '사용자 ID', description: '권한을 설정할 사용자 ID' },
|
||||
{ field: 'account', label: '계정', description: '계정 관리 권한' },
|
||||
{ field: 'purchase', label: '구매', description: '구매 관리 권한' },
|
||||
{ field: 'purchaseEB', label: '구매(전자실)', description: '전자실 구매 권한' },
|
||||
{ field: 'holyday', label: '출근부', description: '출근부 관리 권한' },
|
||||
{ field: 'project', label: '프로젝트', description: '프로젝트 관리 권한' },
|
||||
{ field: 'jobreport', label: '업무일지', description: '업무일지 관리 권한' },
|
||||
{ field: 'scheapp', label: '스케쥴', description: '스케쥴 관리 권한' },
|
||||
{ field: 'equipment', label: '장비목록', description: '장비 목록 관리 권한' },
|
||||
{ field: 'otconfirm', label: 'OT승인', description: '초과근무 승인 권한' },
|
||||
{ field: 'holyreq', label: '휴가요청', description: '휴가 요청 관리 권한' },
|
||||
{ field: 'kuntae', label: '근태', description: '근태 관리 권한' },
|
||||
];
|
||||
|
||||
export default function UserAuth() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [canAccess, setCanAccess] = useState(false);
|
||||
const [accessMessage, setAccessMessage] = useState('');
|
||||
const [authList, setAuthList] = useState<AuthItem[]>([]);
|
||||
const [filteredList, setFilteredList] = useState<AuthItem[]>([]);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<AuthItem | null>(null);
|
||||
const [formData, setFormData] = useState<AuthFormData>(initialFormData);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [userNameMap, setUserNameMap] = useState<Record<string, string>>({});
|
||||
const [showUserSearch, setShowUserSearch] = useState(false);
|
||||
|
||||
// 접근 권한 확인
|
||||
const checkAccess = useCallback(async () => {
|
||||
try {
|
||||
const response = await comms.userAuthCanAccess();
|
||||
if (response.Success) {
|
||||
setCanAccess(response.CanAccess ?? false);
|
||||
if (!response.CanAccess) {
|
||||
setAccessMessage(response.Message || '(관리자/계정담당자) 전용 메뉴 입니다');
|
||||
}
|
||||
} else {
|
||||
setCanAccess(false);
|
||||
setAccessMessage(response.Message || '접근 권한 확인 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('접근 권한 확인 오류:', error);
|
||||
setCanAccess(false);
|
||||
setAccessMessage('접근 권한 확인 중 오류가 발생했습니다');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 사용자 이름 목록 로드
|
||||
const loadUserNames = useCallback(async () => {
|
||||
try {
|
||||
const result = await comms.getUserList('%');
|
||||
if (Array.isArray(result)) {
|
||||
const nameMap: Record<string, string> = {};
|
||||
result.forEach((user: GroupUser) => {
|
||||
nameMap[user.id] = user.name;
|
||||
});
|
||||
setUserNameMap(nameMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 이름 목록 로드 오류:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 목록 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getUserAuthList();
|
||||
if (response.Success && response.Data) {
|
||||
setAuthList(response.Data);
|
||||
setFilteredList(response.Data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('권한 목록 로드 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await checkAccess();
|
||||
await loadUserNames();
|
||||
await loadData();
|
||||
};
|
||||
init();
|
||||
}, [checkAccess, loadUserNames, loadData]);
|
||||
|
||||
// 검색 필터링 (사번 또는 이름으로 검색)
|
||||
useEffect(() => {
|
||||
if (!searchKey.trim()) {
|
||||
setFilteredList(authList);
|
||||
} else {
|
||||
const key = searchKey.toLowerCase();
|
||||
setFilteredList(authList.filter(item =>
|
||||
item.user.toLowerCase().includes(key) ||
|
||||
(userNameMap[item.user] || '').toLowerCase().includes(key)
|
||||
));
|
||||
}
|
||||
}, [searchKey, authList, userNameMap]);
|
||||
|
||||
// 항목 선택
|
||||
const handleSelectItem = (item: AuthItem) => {
|
||||
if (hasChanges) {
|
||||
if (!confirm('변경사항이 있습니다. 저장하지 않고 다른 항목을 선택하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedItem(item);
|
||||
setFormData({
|
||||
idx: item.idx,
|
||||
user: item.user,
|
||||
account: item.account || 0,
|
||||
purchase: item.purchase || 0,
|
||||
purchaseEB: item.purchaseEB || 0,
|
||||
holyday: item.holyday || 0,
|
||||
project: item.project || 0,
|
||||
jobreport: item.jobreport || 0,
|
||||
scheapp: item.scheapp || 0,
|
||||
equipment: item.equipment || 0,
|
||||
otconfirm: item.otconfirm || 0,
|
||||
holyreq: item.holyreq || 0,
|
||||
kuntae: item.kuntae || 0,
|
||||
});
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
// 새 항목 추가 - 사용자 검색 다이얼로그 열기
|
||||
const handleAddNew = () => {
|
||||
if (hasChanges) {
|
||||
if (!confirm('변경사항이 있습니다. 저장하지 않고 새 항목을 추가하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setShowUserSearch(true);
|
||||
};
|
||||
|
||||
// 사용자 검색에서 선택 시
|
||||
const handleUserSelected = async (user: GroupUser) => {
|
||||
// 이미 등록된 사용자인지 확인
|
||||
const existingItem = authList.find(item => item.user === user.id);
|
||||
if (existingItem) {
|
||||
// 이미 등록되어 있으면 해당 항목 선택
|
||||
handleSelectItem(existingItem);
|
||||
return;
|
||||
}
|
||||
|
||||
// 새 사용자 권한 추가
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.saveUserAuth(
|
||||
0, // 새 항목
|
||||
user.id,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 모든 권한 0으로 초기화
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
// 목록 새로고침
|
||||
await loadData();
|
||||
// 새로 추가된 항목 선택
|
||||
const data = response.Data as { idx?: number } | undefined;
|
||||
const newIdx = data?.idx;
|
||||
if (newIdx) {
|
||||
// 잠시 후 새 항목 선택 (loadData가 완료된 후)
|
||||
setTimeout(() => {
|
||||
const newItem = authList.find(item => item.user === user.id);
|
||||
if (newItem) {
|
||||
handleSelectItem(newItem);
|
||||
} else {
|
||||
// authList가 아직 업데이트되지 않았을 수 있으므로 폼 데이터 직접 설정
|
||||
setSelectedItem(null);
|
||||
setFormData({
|
||||
idx: newIdx,
|
||||
user: user.id,
|
||||
account: 0, purchase: 0, purchaseEB: 0, holyday: 0,
|
||||
project: 0, jobreport: 0, scheapp: 0, equipment: 0,
|
||||
otconfirm: 0, holyreq: 0, kuntae: 0,
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
alert(response.Message || '사용자 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 추가 오류:', error);
|
||||
alert('사용자 추가 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 변경
|
||||
const handleFieldChange = (field: keyof AuthFormData, value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.user.trim()) {
|
||||
alert('사용자 ID를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.saveUserAuth(
|
||||
formData.idx,
|
||||
formData.user,
|
||||
formData.account,
|
||||
formData.purchase,
|
||||
formData.purchaseEB,
|
||||
formData.holyday,
|
||||
formData.project,
|
||||
formData.jobreport,
|
||||
formData.scheapp,
|
||||
formData.equipment,
|
||||
formData.otconfirm,
|
||||
formData.holyreq,
|
||||
formData.kuntae
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
setHasChanges(false);
|
||||
await loadData();
|
||||
// 새로 추가한 경우 해당 항목 선택
|
||||
const data = response.Data as { idx?: number } | undefined;
|
||||
if (formData.idx === 0 && data?.idx) {
|
||||
const newIdx = data.idx;
|
||||
const newItem = authList.find(item => item.idx === newIdx);
|
||||
if (newItem) {
|
||||
setSelectedItem(newItem);
|
||||
setFormData(prev => ({ ...prev, idx: newIdx }));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 오류:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async () => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
if (!confirm(`"${selectedItem.user}" 사용자의 권한 설정을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await comms.deleteUserAuth(selectedItem.idx);
|
||||
if (response.Success) {
|
||||
setSelectedItem(null);
|
||||
setFormData(initialFormData);
|
||||
setHasChanges(false);
|
||||
await loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 접근 불가 화면
|
||||
if (!canAccess && !loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="glass-effect rounded-2xl p-8 text-center max-w-md">
|
||||
<AlertCircle className="w-16 h-16 text-danger-400 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">접근 권한 없음</h2>
|
||||
<p className="text-white/70">{accessMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6 animate-fade-in h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-primary-500/20 rounded-xl">
|
||||
<Shield className="w-6 h-6 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">사용자 권한</h1>
|
||||
<p className="text-white/60 text-sm">사용자별 기능 접근 권한 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-white/70 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="flex-1 flex gap-6 min-h-0">
|
||||
{/* 좌측: 사용자 목록 */}
|
||||
<div className="w-80 glass-effect rounded-2xl p-4 flex flex-col">
|
||||
{/* 검색 */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="사번/이름 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
<button
|
||||
onClick={handleAddNew}
|
||||
className="w-full mb-4 flex items-center justify-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>새 사용자 추가</span>
|
||||
</button>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-primary-400 mx-auto mb-2" />
|
||||
<p className="text-white/50">로딩 중...</p>
|
||||
</div>
|
||||
) : filteredList.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/50">
|
||||
{searchKey ? '검색 결과가 없습니다' : '등록된 사용자가 없습니다'}
|
||||
</div>
|
||||
) : (
|
||||
filteredList.map(item => (
|
||||
<button
|
||||
key={item.idx}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors ${
|
||||
selectedItem?.idx === item.idx
|
||||
? 'bg-primary-500/30 border border-primary-400/50'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-white">
|
||||
{item.user}
|
||||
{userNameMap[item.user] && (
|
||||
<span className="text-white/60 font-normal ml-2">({userNameMap[item.user]})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-white/50 mt-1">
|
||||
계정: {item.account || 0} | 구매: {item.purchase || 0} | 업무일지: {item.jobreport || 0}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 항목 수 */}
|
||||
<div className="mt-4 pt-4 border-t border-white/10 text-center text-sm text-white/50">
|
||||
총 {filteredList.length}명
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 편집 */}
|
||||
<div className="flex-1 glass-effect rounded-2xl p-6 overflow-y-auto">
|
||||
{formData.idx === 0 && !formData.user ? (
|
||||
<div className="h-full flex items-center justify-center text-white/50">
|
||||
<div className="text-center">
|
||||
<Shield className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>좌측에서 사용자를 선택하거나</p>
|
||||
<p>새 사용자를 추가하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 권한 설정 그리드 */}
|
||||
<div>
|
||||
<p className="text-white/50 text-sm mb-4">0: 권한 없음, 1~9: 레벨 (숫자가 높을수록 높은 권한)</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{authFields.filter(f => f.field !== 'user').map(field => (
|
||||
<div key={field.field} className="bg-white/5 rounded-lg p-3">
|
||||
<label className="block text-white/70 text-sm font-medium mb-2">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={formData[field.field as keyof AuthFormData] || 0}
|
||||
onChange={(e) => handleFieldChange(field.field as keyof AuthFormData, parseInt(e.target.value) || 0)}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-white text-center focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
<p className="text-xs text-white/40 mt-1 truncate" title={field.description}>
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-between pt-4 border-t border-white/10">
|
||||
<div>
|
||||
{selectedItem && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={processing}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-danger-500 hover:bg-danger-600 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>삭제</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={processing || !hasChanges}
|
||||
className="flex items-center space-x-2 px-6 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{processing ? '저장 중...' : '저장'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 검색 다이얼로그 */}
|
||||
<UserSearchDialog
|
||||
isOpen={showUserSearch}
|
||||
onClose={() => setShowUserSearch(false)}
|
||||
onSelect={handleUserSelected}
|
||||
title="사용자 선택"
|
||||
excludeUsers={authList.map(item => item.user)}
|
||||
initialSearchKey={searchKey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export { Dashboard } from './Dashboard';
|
||||
export { Todo } from './Todo';
|
||||
export { Kuntae } from './Kuntae';
|
||||
export { Jobreport } from './Jobreport';
|
||||
export { Project } from './Project';
|
||||
export { PlaceholderPage } from './Placeholder';
|
||||
export { Login } from './Login';
|
||||
export { CommonCodePage } from './CommonCode';
|
||||
@@ -10,3 +11,4 @@ export { UserListPage } from './UserList';
|
||||
export { MonthlyWorkPage } from './MonthlyWork';
|
||||
export { MailFormPage } from './MailForm';
|
||||
export { UserGroupPage } from './UserGroup';
|
||||
export { default as UserAuthPage } from './UserAuth';
|
||||
|
||||
Reference in New Issue
Block a user