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:
backuppc
2025-11-28 17:36:20 +09:00
parent c9b5d756e1
commit adcdc40169
32 changed files with 6668 additions and 292 deletions

View File

@@ -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>

View File

@@ -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 || '',

View 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>
);
}

View 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}
/>
</>
);
}

View File

@@ -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';