..
This commit is contained in:
@@ -6,6 +6,8 @@ import { PatchList } from '@/pages/PatchList';
|
||||
import { BugReport } from '@/pages/BugReport';
|
||||
import { MailList } from '@/pages/MailList';
|
||||
import { Customs } from '@/pages/Customs';
|
||||
import { LicenseList } from '@/components/license/LicenseList';
|
||||
import { PartList } from '@/pages/PartList';
|
||||
import { comms } from '@/communication';
|
||||
import { UserInfo } from '@/types';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
@@ -101,6 +103,8 @@ export default function App() {
|
||||
<Route path="/patch-list" element={<PatchList />} />
|
||||
<Route path="/bug-report" element={<BugReport />} />
|
||||
<Route path="/mail-list" element={<MailList />} />
|
||||
<Route path="/license" element={<LicenseList />} />
|
||||
<Route path="/partlist" element={<PartList />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
{/* Tailwind Breakpoint Indicator - 개발용 */}
|
||||
|
||||
@@ -42,6 +42,8 @@ import type {
|
||||
BoardItem,
|
||||
MailItem,
|
||||
CustomItem,
|
||||
LicenseItem,
|
||||
PartListItem,
|
||||
} from '@/types';
|
||||
|
||||
// WebView2 환경 감지
|
||||
@@ -464,6 +466,21 @@ class CommunicationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
public async saveProjectHistory(historyData: { idx?: number; pidx: number; pdate: string; progress: number; remark: string }): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Project_SaveHistory(
|
||||
historyData.idx || 0,
|
||||
historyData.pidx,
|
||||
historyData.pdate,
|
||||
historyData.progress,
|
||||
historyData.remark
|
||||
);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('PROJECT_SAVE_HISTORY', 'PROJECT_SAVE_HISTORY_RESULT', historyData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ===== Login API =====
|
||||
|
||||
@@ -1361,6 +1378,64 @@ class CommunicationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 데이터 추가 (발송 대기열)
|
||||
* @param cate 분류
|
||||
* @param subject 제목
|
||||
* @param fromlist 발신자
|
||||
* @param tolist 수신자
|
||||
* @param cc 참조
|
||||
* @param bcc 숨은참조
|
||||
* @param body 내용
|
||||
* @returns ApiResponse
|
||||
*/
|
||||
public async addMailData(cate: string, subject: string, fromlist: string, tolist: string, cc: string, bcc: string, body: string): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Mail_AddData(cate, subject, fromlist, tolist, cc, bcc, body);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('MAIL_ADD_DATA', 'MAIL_ADD_DATA_RESULT', { cate, subject, fromlist, tolist, cc, bcc, body });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 직접 발송 (SMTP)
|
||||
* @param cate 분류
|
||||
* @param subject 제목
|
||||
* @param fromlist 발신자
|
||||
* @param tolist 수신자
|
||||
* @param cc 참조
|
||||
* @param bcc 숨은참조
|
||||
* @param body 내용
|
||||
* @returns ApiResponse
|
||||
*/
|
||||
public async sendMailDirect(cate: string, subject: string, fromlist: string, tolist: string, cc: string, bcc: string, body: string): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Mail_SendDirect(cate, subject, fromlist, tolist, cc, bcc, body);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('MAIL_SEND_DIRECT', 'MAIL_SEND_DIRECT_RESULT', { cate, subject, fromlist, tolist, cc, bcc, body });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outlook으로 메일 미리보기/발송
|
||||
* @param subject 제목
|
||||
* @param tolist 수신자
|
||||
* @param cc 참조
|
||||
* @param bcc 숨은참조
|
||||
* @param body 내용
|
||||
* @returns ApiResponse
|
||||
*/
|
||||
public async sendMailOutlook(subject: string, tolist: string, cc: string, bcc: string, body: string): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Mail_SendOutlook(subject, tolist, cc, bcc, body);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('MAIL_SEND_OUTLOOK', 'MAIL_SEND_OUTLOOK_RESULT', { subject, tolist, cc, bcc, body });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체정보 목록 조회
|
||||
* @param searchKey 검색어
|
||||
@@ -1388,6 +1463,175 @@ class CommunicationLayer {
|
||||
return this.wsRequest<ApiResponse<CustomItem>>('CUSTOMS_GET_DETAIL', 'CUSTOMS_DETAIL_DATA', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이선스 목록 조회
|
||||
* @returns ApiResponse<LicenseItem[]>
|
||||
*/
|
||||
public async getLicenseList(): Promise<ApiResponse<LicenseItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.License_GetList();
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<LicenseItem[]>>('LICENSE_GET_LIST', 'LICENSE_LIST_DATA', {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이선스 추가
|
||||
*/
|
||||
public async addLicense(
|
||||
name: string,
|
||||
version: string,
|
||||
meterialNo: string,
|
||||
supply: string,
|
||||
qty: number,
|
||||
uids: string,
|
||||
serialNo: string,
|
||||
remark: string,
|
||||
sdate: string,
|
||||
edate: string,
|
||||
manu: string,
|
||||
expire: boolean
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.License_Add(name, version, meterialNo, supply, qty, uids, serialNo, remark, sdate, edate, manu, expire);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('LICENSE_ADD', 'LICENSE_ADD_RESULT', {
|
||||
name, version, meterialNo, supply, qty, uids, serialNo, remark, sdate, edate, manu, expire
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이선스 수정
|
||||
*/
|
||||
public async updateLicense(
|
||||
idx: number,
|
||||
name: string,
|
||||
version: string,
|
||||
meterialNo: string,
|
||||
supply: string,
|
||||
qty: number,
|
||||
uids: string,
|
||||
serialNo: string,
|
||||
remark: string,
|
||||
sdate: string,
|
||||
edate: string,
|
||||
manu: string,
|
||||
expire: boolean
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.License_Update(idx, name, version, meterialNo, supply, qty, uids, serialNo, remark, sdate, edate, manu, expire);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('LICENSE_UPDATE', 'LICENSE_UPDATE_RESULT', {
|
||||
idx, name, version, meterialNo, supply, qty, uids, serialNo, remark, sdate, edate, manu, expire
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이선스 삭제
|
||||
*/
|
||||
public async deleteLicense(idx: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.License_Delete(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('LICENSE_DELETE', 'LICENSE_DELETE_RESULT', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이선스 폴더 열기
|
||||
*/
|
||||
public async openLicenseFolder(idx: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.License_OpenFolder(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('LICENSE_OPEN_FOLDER', 'LICENSE_OPEN_FOLDER_RESULT', { idx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이선스 CSV 내보내기
|
||||
*/
|
||||
public async exportLicenseCSV(filePath: string): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.License_ExportCSV(filePath);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('LICENSE_EXPORT_CSV', 'LICENSE_EXPORT_CSV_RESULT', { filePath });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== PartList API =====
|
||||
|
||||
/**
|
||||
* 프로젝트별 파트리스트 조회
|
||||
*/
|
||||
public async getPartList(projectIdx: number): Promise<ApiResponse<PartListItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.PartList_GetList(projectIdx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<PartListItem[]>>('PARTLIST_GET_LIST', 'PARTLIST_LIST_DATA', { projectIdx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트리스트 항목 저장 (추가/수정)
|
||||
*/
|
||||
public async savePartList(
|
||||
idx: number,
|
||||
projectIdx: number,
|
||||
itemgroup: string,
|
||||
itemname: string,
|
||||
item: string,
|
||||
itemmodel: string,
|
||||
itemscale: string,
|
||||
itemunit: string,
|
||||
qty: number,
|
||||
price: number,
|
||||
itemsupply: string,
|
||||
itemsupplyidx: number,
|
||||
itemmanu: string,
|
||||
itemsid: string,
|
||||
option1: string,
|
||||
remark: string,
|
||||
no: number,
|
||||
qtybuy: number
|
||||
): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.PartList_Save(
|
||||
idx, projectIdx, itemgroup, itemname, item, itemmodel, itemscale,
|
||||
itemunit, qty, price, itemsupply, itemsupplyidx, itemmanu, itemsid,
|
||||
option1, remark, no, qtybuy
|
||||
);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('PARTLIST_SAVE', 'PARTLIST_SAVE_RESULT', {
|
||||
idx, projectIdx, itemgroup, itemname, item, itemmodel, itemscale,
|
||||
itemunit, qty, price, itemsupply, itemsupplyidx, itemmanu, itemsid,
|
||||
option1, remark, no, qtybuy
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트리스트 항목 삭제
|
||||
*/
|
||||
public async deletePartList(idx: number): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.PartList_Delete(idx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('PARTLIST_DELETE', 'PARTLIST_DELETE_RESULT', { idx });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comms = new CommunicationLayer();
|
||||
|
||||
@@ -262,7 +262,9 @@ export function JobreportEditModal({
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-slate-800/95 backdrop-blur z-10">
|
||||
<div className={`px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 backdrop-blur z-10 ${
|
||||
editingItem ? 'bg-slate-800/95' : 'bg-primary-600/30'
|
||||
}`}>
|
||||
<h2 className="text-xl font-semibold text-white flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
{editingItem ? '업무일지 수정' : '업무일지 등록'}
|
||||
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
Building,
|
||||
Star,
|
||||
Bug,
|
||||
Settings,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
||||
import { UserGroupDialog } from '@/components/user/UserGroupDialog';
|
||||
import { KuntaeErrorCheckDialog } from '@/components/kuntae/KuntaeErrorCheckDialog';
|
||||
@@ -39,6 +42,7 @@ interface NavItem {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
action?: string;
|
||||
className?: string; // 추가: 클래스 이름
|
||||
}
|
||||
|
||||
interface SubMenu {
|
||||
@@ -54,6 +58,7 @@ interface MenuItem {
|
||||
label: string;
|
||||
submenu?: SubMenu;
|
||||
action?: string;
|
||||
className?: string; // gold 등 스타일 적용용
|
||||
}
|
||||
|
||||
interface DropdownMenuConfig {
|
||||
@@ -78,6 +83,13 @@ const leftDropdownMenus: DropdownMenuConfig[] = [
|
||||
{ type: 'action', icon: AlertTriangle, label: '오류검사', action: 'kuntaeErrorCheck' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '관리',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{ type: 'link', path: '/license', icon: Key, label: '라이선스' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 좌측 단독 액션 버튼
|
||||
@@ -90,44 +102,54 @@ const rightNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
// 드롭다운 메뉴 (2단계 지원)
|
||||
const dropdownMenus: DropdownMenuConfig[] = [
|
||||
{
|
||||
label: '공용정보',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ type: 'link', path: '/common', icon: Code, label: '공용코드' },
|
||||
{ type: 'link', path: '/items', icon: Package, label: '품목정보' },
|
||||
{ type: 'link', path: '/customs', icon: Building, label: '업체정보' },
|
||||
{
|
||||
type: 'submenu',
|
||||
icon: Users,
|
||||
label: '사용자',
|
||||
submenu: {
|
||||
label: '사용자',
|
||||
const getDropdownMenus = (userLevel: number, userCode: string): DropdownMenuConfig[] => {
|
||||
const mailListItem = {
|
||||
type: 'link' as const,
|
||||
path: '/mail-list',
|
||||
icon: Mail,
|
||||
label: '메일 내역',
|
||||
className: (userCode === '395552') ? 'text-[gold] font-bold' : '',
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
label: '공용정보',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ type: 'link', path: '/common', icon: Code, label: '공용코드' },
|
||||
{ type: 'link', path: '/items', icon: Package, label: '품목정보' },
|
||||
{ type: 'link', path: '/customs', icon: Building, label: '업체정보' },
|
||||
{
|
||||
type: 'submenu',
|
||||
icon: Users,
|
||||
items: [
|
||||
{ icon: User, label: '정보', action: 'userInfo' },
|
||||
{ path: '/user/list', icon: Users, label: '목록' },
|
||||
{ path: '/user/auth', icon: Shield, label: '권한' },
|
||||
{ icon: Users, label: '그룹정보', action: 'userGroup' },
|
||||
],
|
||||
label: '사용자',
|
||||
submenu: {
|
||||
label: '사용자',
|
||||
icon: Users,
|
||||
items: [
|
||||
{ icon: User, label: '정보', action: 'userInfo' },
|
||||
{ path: '/user/list', icon: Users, label: '목록' },
|
||||
{ path: '/user/auth', icon: Shield, label: '권한' },
|
||||
{ icon: Users, label: '그룹정보', action: 'userGroup' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
||||
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '문서',
|
||||
icon: FileText,
|
||||
items: [
|
||||
{ type: 'link', path: '/note', icon: FileText, label: '메모장' },
|
||||
{ type: 'link', path: '/patch-list', icon: FileText, label: '패치 내역' },
|
||||
{ type: 'link', path: '/bug-report', icon: Bug, label: '버그 신고' },
|
||||
{ type: 'link', path: '/mail-list', icon: Mail, label: '메일 내역' },
|
||||
],
|
||||
},
|
||||
];
|
||||
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
|
||||
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '문서',
|
||||
icon: FileText,
|
||||
items: [
|
||||
{ type: 'link', path: '/note', icon: FileText, label: '메모장' },
|
||||
{ type: 'link', path: '/patch-list', icon: FileText, label: '패치 내역' },
|
||||
{ type: 'link', path: '/bug-report', icon: Bug, label: '버그 신고' },
|
||||
...(userLevel >= 9 || userCode === '395552' ? [mailListItem] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
function DropdownNavMenu({
|
||||
menu,
|
||||
@@ -194,7 +216,7 @@ function DropdownNavMenu({
|
||||
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: (item.className || 'text-white/70 hover:bg-white/10 hover:text-white')
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -326,7 +348,7 @@ function MobileDropdownMenu({
|
||||
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: (item.className || 'text-white/70 hover:bg-white/10 hover:text-white')
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -373,7 +395,7 @@ function MobileDropdownMenu({
|
||||
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: (item.className || 'text-white/70 hover:bg-white/10 hover:text-white')
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -409,6 +431,27 @@ export function Header(_props: HeaderProps) {
|
||||
const [showUserGroupDialog, setShowUserGroupDialog] = useState(false);
|
||||
const [showKuntaeErrorCheckDialog, setShowKuntaeErrorCheckDialog] = useState(false);
|
||||
const [showFavoriteDialog, setShowFavoriteDialog] = useState(false);
|
||||
const [userLevel, setUserLevel] = useState<number>(0);
|
||||
const [userCode, setUserCode] = useState<string>('');
|
||||
|
||||
// 사용자 정보 로드
|
||||
useEffect(() => {
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
console.log('Login Status:', loginStatus);
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
const user = loginStatus.User as { Level?: number; Id?: string };
|
||||
setUserLevel(user.Level || 0);
|
||||
setUserCode(user.Id || '');
|
||||
console.log('userLevel:', user.Level, 'userCode:', user.Id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
loadUserInfo();
|
||||
}, []);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
if (action === 'userInfo') {
|
||||
@@ -485,7 +528,7 @@ export function Header(_props: HeaderProps) {
|
||||
{/* Desktop Navigation - Right */}
|
||||
<nav className="hidden lg:flex items-center space-x-1">
|
||||
{/* 드롭다운 메뉴들 (공용정보) */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
{getDropdownMenus(userLevel, userCode).map((menu) => (
|
||||
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
|
||||
))}
|
||||
|
||||
@@ -574,7 +617,7 @@ export function Header(_props: HeaderProps) {
|
||||
<div className="border-t border-white/10 my-2" />
|
||||
|
||||
{/* 우측 드롭다운 메뉴들 (공용정보) */}
|
||||
{dropdownMenus.map((menu) => (
|
||||
{getDropdownMenus(userLevel, userCode).map((menu) => (
|
||||
<MobileDropdownMenu
|
||||
key={menu.label}
|
||||
menu={menu}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface StatusBarProps {
|
||||
export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [versionDisplay, setVersionDisplay] = useState('');
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
|
||||
@@ -25,6 +26,7 @@ export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
||||
const result = await comms.getAppVersion();
|
||||
if (result.Success) {
|
||||
setVersionDisplay(result.DisplayVersion);
|
||||
setHasNewVersion(result.HasNewVersion || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('버전 정보 로드 오류:', error);
|
||||
@@ -41,8 +43,13 @@ export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
||||
</div>
|
||||
|
||||
{/* Center: App Version */}
|
||||
<div className="text-white/50">
|
||||
<div className={`font-medium ${hasNewVersion ? 'text-yellow-400 animate-pulse' : 'text-white/50'}`}>
|
||||
{versionDisplay || 'Loading...'}
|
||||
{hasNewVersion && (
|
||||
<span className="ml-2 text-xs bg-yellow-500/20 text-yellow-400 px-2 py-0.5 rounded animate-pulse">
|
||||
업데이트 필요
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Connection Status & Time */}
|
||||
|
||||
293
Project/frontend/src/components/license/LicenseEditDialog.tsx
Normal file
293
Project/frontend/src/components/license/LicenseEditDialog.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { X, Save, Trash2 } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { LicenseItem } from '@/types';
|
||||
|
||||
interface LicenseEditDialogProps {
|
||||
item: LicenseItem | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: Partial<LicenseItem>) => Promise<void>;
|
||||
onDelete?: (idx: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: LicenseEditDialogProps) {
|
||||
const [formData, setFormData] = useState<Partial<LicenseItem>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setFormData({
|
||||
idx: item.idx,
|
||||
expire: item.expire || false,
|
||||
name: item.name || '',
|
||||
version: item.version || '',
|
||||
meterialNo: item.meterialNo || '',
|
||||
supply: item.supply || '',
|
||||
qty: item.qty || 1,
|
||||
uids: item.uids || '',
|
||||
serialNo: item.serialNo || '',
|
||||
remark: item.remark || '',
|
||||
sdate: item.sdate ? item.sdate.split('T')[0] : '',
|
||||
edate: item.edate ? item.edate.split('T')[0] : '',
|
||||
manu: item.manu || '',
|
||||
});
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name?.trim()) {
|
||||
alert('제품명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!formData.idx) return;
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (onDelete) {
|
||||
await onDelete(formData.idx);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
alert('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="glass-effect rounded-lg w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{formData.idx ? '라이선스 수정' : '라이선스 추가'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>기본 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-1 flex items-center">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.expire || false}
|
||||
onChange={(e) => setFormData({ ...formData, expire: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-white/70">만료</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<label className="block text-sm text-white/70 mb-1">제품명 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-white/70 mb-1">버전</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version || ''}
|
||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-white/70 mb-1">자재번호</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.meterialNo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, meterialNo: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>공급 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">공급업체</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.supply || ''}
|
||||
onChange={(e) => setFormData({ ...formData, supply: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">제조사</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.manu || ''}
|
||||
onChange={(e) => setFormData({ ...formData, manu: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>사용 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm text-white/70 mb-1">수량</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.qty || 1}
|
||||
onChange={(e) => setFormData({ ...formData, qty: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<label className="block text-sm text-white/70 mb-1">사용자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.uids || ''}
|
||||
onChange={(e) => setFormData({ ...formData, uids: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6">
|
||||
<label className="block text-sm text-white/70 mb-1">S/N</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serialNo || ''}
|
||||
onChange={(e) => setFormData({ ...formData, serialNo: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기간 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>기간 정보</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.sdate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.edate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>비고</span>
|
||||
</h3>
|
||||
<textarea
|
||||
value={formData.remark || ''}
|
||||
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500 resize-none"
|
||||
placeholder="추가 메모를 입력하세요..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-white/10">
|
||||
<div>
|
||||
{formData.idx && onDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-red-500 hover:bg-red-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>삭제</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-800 text-white rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{saving ? '저장 중...' : '저장'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
387
Project/frontend/src/components/license/LicenseList.tsx
Normal file
387
Project/frontend/src/components/license/LicenseList.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
FolderOpen,
|
||||
Download,
|
||||
Search,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { LicenseEditDialog } from './LicenseEditDialog';
|
||||
import type { LicenseItem } from '@/types';
|
||||
|
||||
export function LicenseList() {
|
||||
const [list, setList] = useState<LicenseItem[]>([]);
|
||||
const [filteredList, setFilteredList] = useState<LicenseItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<LicenseItem | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 25;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyFilter();
|
||||
}, [searchText, list]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await comms.getLicenseList();
|
||||
if (response.Success && response.Data) {
|
||||
setList(response.Data);
|
||||
} else {
|
||||
alert(response.Message || '라이선스 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load license list:', error);
|
||||
alert('라이선스 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
if (!searchText.trim()) {
|
||||
setFilteredList(list);
|
||||
return;
|
||||
}
|
||||
|
||||
const search = searchText.toLowerCase();
|
||||
const filtered = list.filter((item) => {
|
||||
return (
|
||||
item.name?.toLowerCase().includes(search) ||
|
||||
item.version?.toLowerCase().includes(search) ||
|
||||
item.supply?.toLowerCase().includes(search) ||
|
||||
item.manu?.toLowerCase().includes(search) ||
|
||||
item.serialNo?.toLowerCase().includes(search) ||
|
||||
item.meterialNo?.toLowerCase().includes(search) ||
|
||||
item.remark?.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
setFilteredList(filtered);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setSelectedItem({
|
||||
expire: false,
|
||||
name: '',
|
||||
version: '',
|
||||
meterialNo: '',
|
||||
supply: '',
|
||||
qty: 1,
|
||||
uids: '',
|
||||
serialNo: '',
|
||||
remark: '',
|
||||
sdate: new Date().toISOString().split('T')[0],
|
||||
edate: '',
|
||||
manu: '',
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRowClick = (item: LicenseItem) => {
|
||||
setSelectedItem(item);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Partial<LicenseItem>) => {
|
||||
if (!formData.name?.trim()) {
|
||||
alert('제품명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
let response;
|
||||
|
||||
if (formData.idx) {
|
||||
// Update
|
||||
response = await comms.updateLicense(
|
||||
formData.idx,
|
||||
formData.name!,
|
||||
formData.version || '',
|
||||
formData.meterialNo || '',
|
||||
formData.supply || '',
|
||||
formData.qty || 1,
|
||||
formData.uids || '',
|
||||
formData.serialNo || '',
|
||||
formData.remark || '',
|
||||
formData.sdate || '',
|
||||
formData.edate || '',
|
||||
formData.manu || '',
|
||||
formData.expire || false
|
||||
);
|
||||
} else {
|
||||
// Add
|
||||
response = await comms.addLicense(
|
||||
formData.name!,
|
||||
formData.version || '',
|
||||
formData.meterialNo || '',
|
||||
formData.supply || '',
|
||||
formData.qty || 1,
|
||||
formData.uids || '',
|
||||
formData.serialNo || '',
|
||||
formData.remark || '',
|
||||
formData.sdate || '',
|
||||
formData.edate || '',
|
||||
formData.manu || '',
|
||||
formData.expire || false
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
alert(response.Message || '저장되었습니다.');
|
||||
await loadData();
|
||||
} else {
|
||||
alert(response.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save license:', error);
|
||||
alert('저장에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (idx: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await comms.deleteLicense(idx);
|
||||
if (response.Success) {
|
||||
alert(response.Message || '삭제되었습니다.');
|
||||
await loadData();
|
||||
} else {
|
||||
alert(response.Message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete license:', error);
|
||||
alert('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFolder = async (item: LicenseItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!item.idx) {
|
||||
alert('저장된 자료만 폴더를 열 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await comms.openLicenseFolder(item.idx);
|
||||
if (!response.Success) {
|
||||
alert(response.Message || '폴더 열기에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open folder:', error);
|
||||
alert('폴더 열기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
const filename = `license_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
const filepath = `C:\\Temp\\${filename}`;
|
||||
|
||||
try {
|
||||
const response = await comms.exportLicenseCSV(filepath);
|
||||
if (response.Success) {
|
||||
alert(`CSV 파일이 생성되었습니다.\n\n${filepath}`);
|
||||
} else {
|
||||
alert(response.Message || 'CSV 내보내기에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to export CSV:', error);
|
||||
alert('CSV 내보내기에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setSelectedItem(null);
|
||||
};
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(filteredList.length / pageSize);
|
||||
const paginatedList = filteredList.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const goToNextPage = () => {
|
||||
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">라이선스 관리</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>추가</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
disabled={loading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="검색 (제품명, 버전, 공급업체, 제조사, S/N, 자재번호, 비고)"
|
||||
className="w-full pl-10 pr-10 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
onClick={() => setSearchText('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/50 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="glass-effect rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-white border-r border-white/10 w-16">상태</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '25%' }}>제품명</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '25%' }}>버전</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10 w-20">수량</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '12%' }}>사용자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white" style={{ width: '15%' }}>S/N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-white/70">
|
||||
로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && paginatedList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-white/70">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading &&
|
||||
paginatedList.map((item) => (
|
||||
<tr
|
||||
key={item.idx}
|
||||
onClick={() => handleRowClick(item)}
|
||||
className={`border-t border-white/10 hover:bg-white/10 cursor-pointer transition-colors ${
|
||||
item.expire ? 'bg-red-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3 text-center border-r border-white/10">
|
||||
<div className="flex justify-center" title={item.expire ? '만료' : '유효'}>
|
||||
{item.expire ? (
|
||||
<XCircle className="w-5 h-5 text-red-500" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 max-w-xs">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(e) => handleOpenFolder(item, e)}
|
||||
className="p-1 text-yellow-400 hover:text-yellow-300 transition-colors flex-shrink-0"
|
||||
title="폴더 열기"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="break-words">{item.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 break-words">{item.version}</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10">{item.qty}</td>
|
||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 break-words max-w-[8rem]">{item.uids}</td>
|
||||
<td className="px-4 py-3 text-sm text-white break-words">{item.serialNo}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-white/10">
|
||||
<div className="text-sm text-white/70">
|
||||
전체 {filteredList.length}건 중 {(currentPage - 1) * pageSize + 1}~
|
||||
{Math.min(currentPage * pageSize, filteredList.length)}건
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={goToPreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-white">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<LicenseEditDialog
|
||||
item={selectedItem}
|
||||
isOpen={isDialogOpen}
|
||||
onClose={handleCloseDialog}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
Project/frontend/src/components/mail/MailTestDialog.tsx
Normal file
304
Project/frontend/src/components/mail/MailTestDialog.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Mail, Send } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
|
||||
interface MailTestDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MailTestDialog({ isOpen, onClose }: MailTestDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
cate: '테스트',
|
||||
subject: '',
|
||||
fromlist: '',
|
||||
tolist: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
body: '',
|
||||
});
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUserEmail = async () => {
|
||||
try {
|
||||
const response = await comms.checkLoginStatus();
|
||||
if (response.Success && response.IsLoggedIn && response.User) {
|
||||
const user = response.User as { Email?: string };
|
||||
if (user.Email) {
|
||||
setFormData(prev => ({ ...prev, fromlist: user.Email || '' }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 로드 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
loadUserEmail();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSubmit = async (mode: 'queue' | 'direct' | 'outlook' = 'queue') => {
|
||||
if (!formData.subject.trim()) {
|
||||
alert('제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.tolist.trim()) {
|
||||
alert('수신자를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.body.trim()) {
|
||||
alert('내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
let response;
|
||||
if (mode === 'outlook') {
|
||||
// Outlook 미리보기
|
||||
response = await comms.sendMailOutlook(
|
||||
formData.subject,
|
||||
formData.tolist,
|
||||
formData.cc,
|
||||
formData.bcc,
|
||||
formData.body
|
||||
);
|
||||
} else if (mode === 'direct') {
|
||||
// 직접 발송
|
||||
response = await comms.sendMailDirect(
|
||||
formData.cate,
|
||||
formData.subject,
|
||||
formData.fromlist,
|
||||
formData.tolist,
|
||||
formData.cc,
|
||||
formData.bcc,
|
||||
formData.body
|
||||
);
|
||||
} else {
|
||||
// 발송 대기열에 추가
|
||||
response = await comms.addMailData(
|
||||
formData.cate,
|
||||
formData.subject,
|
||||
formData.fromlist,
|
||||
formData.tolist,
|
||||
formData.cc,
|
||||
formData.bcc,
|
||||
formData.body
|
||||
);
|
||||
}
|
||||
|
||||
if (response.Success) {
|
||||
alert(response.Message || '처리되었습니다.');
|
||||
if (mode !== 'outlook') {
|
||||
onClose();
|
||||
// 폼 초기화
|
||||
setFormData({
|
||||
cate: '테스트',
|
||||
subject: '',
|
||||
fromlist: formData.fromlist, // 발신자는 유지
|
||||
tolist: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
body: '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
alert(response.Message || '메일 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메일 처리 오류:', error);
|
||||
alert('메일 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-3xl glass-effect-solid rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-5 h-5 text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-white">메일 테스트</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{/* 분류 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">분류</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cate}
|
||||
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
||||
className="w-full px-3 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>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">제목 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
placeholder="메일 제목을 입력하세요"
|
||||
className="w-full px-3 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>
|
||||
|
||||
{/* 발신자 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">발신자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.fromlist}
|
||||
onChange={(e) => setFormData({ ...formData, fromlist: e.target.value })}
|
||||
placeholder="발신자 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
|
||||
{/* 수신자 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">수신자 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tolist}
|
||||
onChange={(e) => setFormData({ ...formData, tolist: e.target.value })}
|
||||
placeholder="수신자 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
|
||||
{/* 참조 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">참조 (CC)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cc}
|
||||
onChange={(e) => setFormData({ ...formData, cc: e.target.value })}
|
||||
placeholder="참조 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
|
||||
{/* 숨은참조 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">숨은참조 (BCC)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.bcc}
|
||||
onChange={(e) => setFormData({ ...formData, bcc: e.target.value })}
|
||||
placeholder="숨은참조 이메일 (쉼표로 구분)"
|
||||
className="w-full px-3 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>
|
||||
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<label className="block text-white/80 text-sm font-medium mb-2">내용 *</label>
|
||||
<textarea
|
||||
value={formData.body}
|
||||
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
|
||||
placeholder="메일 내용을 입력하세요 (HTML 가능)"
|
||||
rows={8}
|
||||
className="w-full px-3 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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-white/50 text-xs">
|
||||
* 메일은 발송 대기열에 추가됩니다. 실제 발송은 메일 서비스가 처리합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-white/10 bg-black/20">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit('queue')}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
처리중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="w-4 h-4" />
|
||||
대기열 추가
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit('direct')}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
처리중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
즉시 발송
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit('outlook')}
|
||||
disabled={processing}
|
||||
className="px-4 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-white transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
처리중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="w-4 h-4" />
|
||||
Outlook
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
519
Project/frontend/src/components/project/PartListDialog.tsx
Normal file
519
Project/frontend/src/components/project/PartListDialog.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Trash2, Plus, RefreshCw } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { PartListItem } from '@/types';
|
||||
|
||||
interface PartListDialogProps {
|
||||
projectIdx: number;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PartListDialog({ projectIdx, projectName, onClose }: PartListDialogProps) {
|
||||
const [parts, setParts] = useState<PartListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
||||
const [editForm, setEditForm] = useState<Partial<PartListItem>>({});
|
||||
|
||||
// ESC 키 핸들러
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (editingIdx !== null) {
|
||||
setEditingIdx(null);
|
||||
setEditForm({});
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [editingIdx, onClose]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadParts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('[PartList] 로드 시작, projectIdx:', projectIdx);
|
||||
const result = await comms.getPartList(projectIdx);
|
||||
console.log('[PartList] 결과:', result);
|
||||
if (result.Success && result.Data) {
|
||||
console.log('[PartList] 데이터 개수:', result.Data.length);
|
||||
setParts(result.Data);
|
||||
} else {
|
||||
console.error('[PartList] 실패:', result.Message);
|
||||
alert(result.Message || '파트리스트 로드 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파트리스트 로드 실패:', error);
|
||||
alert('파트리스트 로드 중 오류: ' + error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadParts();
|
||||
}, [projectIdx]);
|
||||
|
||||
// 편집 시작
|
||||
const startEdit = (part: PartListItem) => {
|
||||
setEditingIdx(part.idx);
|
||||
setEditForm({ ...part });
|
||||
};
|
||||
|
||||
// 편집 취소
|
||||
const cancelEdit = () => {
|
||||
setEditingIdx(null);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!editForm.itemname || !editForm.item) {
|
||||
alert('품명과 자재번호는 필수입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await comms.savePartList(
|
||||
editingIdx || 0,
|
||||
projectIdx,
|
||||
editForm.itemgroup || '',
|
||||
editForm.itemname || '',
|
||||
editForm.item || '',
|
||||
editForm.itemmodel || '',
|
||||
editForm.itemscale || '',
|
||||
editForm.itemunit || '',
|
||||
editForm.qty || 0,
|
||||
editForm.price || 0,
|
||||
editForm.itemsupply || '',
|
||||
editForm.itemsupplyidx || 0,
|
||||
editForm.itemmanu || '',
|
||||
editForm.itemsid || '',
|
||||
editForm.option1 || '',
|
||||
editForm.remark || '',
|
||||
editForm.no || 0,
|
||||
editForm.qtybuy || 0
|
||||
);
|
||||
|
||||
if (result.Success) {
|
||||
await loadParts();
|
||||
cancelEdit();
|
||||
} else {
|
||||
alert(result.Message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (idx: number) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const result = await comms.deletePartList(idx);
|
||||
if (result.Success) {
|
||||
await loadParts();
|
||||
} else {
|
||||
alert(result.Message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 새 항목 추가
|
||||
const addNew = () => {
|
||||
setEditingIdx(-1);
|
||||
setEditForm({
|
||||
Project: projectIdx,
|
||||
itemgroup: '',
|
||||
itemname: '',
|
||||
item: '',
|
||||
itemmodel: '',
|
||||
itemscale: '',
|
||||
itemunit: 'EA',
|
||||
qty: 1,
|
||||
price: 0,
|
||||
itemsupply: '',
|
||||
itemsupplyidx: 0,
|
||||
itemmanu: '',
|
||||
itemsid: '',
|
||||
option1: '',
|
||||
remark: '',
|
||||
no: 0,
|
||||
qtybuy: 0,
|
||||
});
|
||||
};
|
||||
|
||||
// 금액 계산
|
||||
const getAmount = (qty: number, price: number) => qty * price;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800/95 backdrop-blur rounded-lg w-full max-w-7xl max-h-[90vh] flex flex-col shadow-2xl border border-white/10">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-primary-600/30 sticky top-0 z-10">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">파트리스트</h2>
|
||||
<p className="text-sm text-white/60">{projectName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={addNew}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="text-sm">추가</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={loadParts}
|
||||
disabled={loading}
|
||||
className="p-2 hover:bg-white/10 rounded transition-colors disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-white/70 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/10 rounded transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading && parts.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 text-primary-500 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 bg-slate-700/50 backdrop-blur z-10">
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-12">No</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-24">그룹</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium">품명</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">모델</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">규격</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-16">단위</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-20">수량</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-28">단가</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-32">금액</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">공급처</th>
|
||||
<th className="px-2 py-2 text-center text-xs text-white/70 font-medium w-20">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-2 py-8 text-center text-white/40 text-sm">
|
||||
등록된 파트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
parts.map((part) => {
|
||||
const isEditing = editingIdx === part.idx;
|
||||
return (
|
||||
<tr
|
||||
key={part.idx}
|
||||
className={`border-b border-white/5 hover:bg-white/5 transition-colors ${
|
||||
isEditing ? 'bg-primary-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.no || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, no: parseInt(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.no || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemgroup || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemgroup: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemgroup || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemname || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemname: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/90 text-xs font-medium">{part.itemname || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemmodel || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemmodel: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemmodel || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemscale || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemscale: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemscale || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemunit || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemunit: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemunit || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.qty || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, qty: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.qty?.toLocaleString() || 0}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.price || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.price?.toLocaleString() || 0}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<span className="text-white/90 text-xs font-medium">
|
||||
{getAmount(
|
||||
isEditing ? editForm.qty || 0 : part.qty || 0,
|
||||
isEditing ? editForm.price || 0 : part.price || 0
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsupply || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsupply: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemsupply || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded transition-colors"
|
||||
title="저장"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-white/10 text-white/50 rounded transition-colors"
|
||||
title="취소"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(part)}
|
||||
className="p-1 hover:bg-white/10 text-white/70 rounded transition-colors text-xs"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(part.idx)}
|
||||
className="p-1 hover:bg-red-500/20 text-red-400 rounded transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{/* 새 항목 추가 행 */}
|
||||
{editingIdx === -1 && (
|
||||
<tr className="border-b border-white/5 bg-primary-500/10">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.no || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, no: parseInt(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemgroup || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemgroup: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="그룹"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemname || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemname: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="품명 *"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemmodel || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemmodel: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="모델"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemscale || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemscale: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="규격"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemunit || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemunit: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="단위"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.qty || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, qty: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.price || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<span className="text-white/90 text-xs font-medium">
|
||||
{getAmount(editForm.qty || 0, editForm.price || 0).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsupply || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsupply: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="공급처"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded transition-colors"
|
||||
title="저장"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-white/10 text-white/50 rounded transition-colors"
|
||||
title="취소"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
{parts.length > 0 && (
|
||||
<div className="p-4 border-t border-white/10 bg-slate-900/50">
|
||||
<div className="flex justify-end gap-4 text-sm">
|
||||
<span className="text-white/70">
|
||||
총 <span className="text-white font-medium">{parts.length}</span>개 항목
|
||||
</span>
|
||||
<span className="text-white/70">
|
||||
합계: <span className="text-primary-400 font-medium">
|
||||
{parts.reduce((sum, part) => sum + getAmount(part.qty || 0, part.price || 0), 0).toLocaleString()}
|
||||
</span>원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { ProjectDetailDialog } from './ProjectDetailDialog';
|
||||
export { PartListDialog } from './PartListDialog';
|
||||
|
||||
@@ -101,6 +101,24 @@ export function Dashboard() {
|
||||
setUrgentTodos(allUrgentTodos.slice(start, end));
|
||||
}, [todoPage, allUrgentTodos]);
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showTodoAddModal) {
|
||||
setShowTodoAddModal(false);
|
||||
} else if (showTodoEditModal) {
|
||||
setShowTodoEditModal(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (showTodoAddModal || showTodoEditModal) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [showTodoAddModal, showTodoEditModal]);
|
||||
|
||||
const loadDashboardData = useCallback(async () => {
|
||||
try {
|
||||
// 오늘 날짜 (로컬 시간 기준)
|
||||
@@ -225,6 +243,7 @@ export function Dashboard() {
|
||||
|
||||
const getPriorityText = (seqno: number) => {
|
||||
switch (seqno) {
|
||||
case -1: return '낮음';
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
@@ -234,6 +253,7 @@ export function Dashboard() {
|
||||
|
||||
const getPriorityClass = (seqno: number) => {
|
||||
switch (seqno) {
|
||||
case -1: return 'bg-white/5 text-white/40';
|
||||
case 1: return 'bg-primary-500/20 text-primary-300';
|
||||
case 2: return 'bg-warning-500/20 text-warning-300';
|
||||
case 3: return 'bg-danger-500/20 text-danger-300';
|
||||
@@ -782,10 +802,11 @@ export function Dashboard() {
|
||||
onChange={(e) => setTodoFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
>
|
||||
<option value={0}>보통</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={3}>긴급</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={0}>보통</option>
|
||||
<option value={-1}>낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
@@ -844,9 +865,22 @@ export function Dashboard() {
|
||||
<Edit2 className="w-5 h-5 mr-2" />
|
||||
할일 수정
|
||||
</h2>
|
||||
<button onClick={() => setShowTodoEditModal(false)} className="text-white/70 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex items-center space-x-2">
|
||||
{editingTodo.status !== '5' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTodoComplete}
|
||||
disabled={processing}
|
||||
className="bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center disabled:opacity-50 text-sm"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
완료 처리
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowTodoEditModal(false)} className="text-white/70 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
@@ -929,10 +963,11 @@ export function Dashboard() {
|
||||
onChange={(e) => setTodoFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
>
|
||||
<option value={0}>보통</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={3}>긴급</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={0}>보통</option>
|
||||
<option value={-1}>낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
@@ -950,40 +985,8 @@ export function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
|
||||
{/* 왼쪽: 삭제 버튼 */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTodoDelete}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 취소, 완료, 수정 버튼 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end">
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTodoEditModal(false)}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
{editingTodo.status !== '5' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTodoComplete}
|
||||
disabled={processing}
|
||||
className="bg-success-500 hover:bg-success-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
완료 처리
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTodoUpdate}
|
||||
@@ -997,6 +1000,15 @@ export function Dashboard() {
|
||||
)}
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTodoDelete}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Info,
|
||||
Plus,
|
||||
Calendar,
|
||||
} from 'lucide-react';
|
||||
@@ -567,8 +568,8 @@ export function Jobreport() {
|
||||
<thead className="bg-white/10">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-center text-xs font-medium text-white/70 uppercase w-10"></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">날짜</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">프로젝트</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-24">날짜</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase" style={{ width: '35%' }}>프로젝트</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">업무형태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">시간</th>
|
||||
@@ -596,38 +597,51 @@ export function Jobreport() {
|
||||
paginatedList.map((item) => (
|
||||
<tr
|
||||
key={item.idx}
|
||||
className={`hover:bg-white/5 transition-colors cursor-pointer ${item.type === '휴가' ? 'bg-gradient-to-r from-lime-400/30 via-emerald-400/20 to-teal-400/30' : ''}`}
|
||||
onClick={() => openEditModal(item)}
|
||||
className="hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td className="px-2 py-3 text-center">
|
||||
<button
|
||||
onClick={(e) => openCopyModal(item, e)}
|
||||
className="text-white/40 hover:text-primary-400 transition-colors"
|
||||
title="복사하여 새로 작성"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<td
|
||||
className="px-2 py-3 text-center cursor-pointer hover:bg-primary-500/10 transition-colors"
|
||||
onClick={(e) => openCopyModal(item, e)}
|
||||
title="복사하여 새로 작성"
|
||||
>
|
||||
<Copy className="w-4 h-4 mx-auto text-white/40" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.pdate)}</td>
|
||||
<td className={`px-4 py-3 text-sm font-medium max-w-xs truncate ${item.pidx && item.pidx > 0 ? 'text-white' : 'text-white/50'}`} title={item.projectName}>
|
||||
{item.projectName || '-'}
|
||||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>{formatDate(item.pdate)}</td>
|
||||
<td className={`px-4 py-3 text-sm font-medium ${item.pidx && item.pidx > 0 ? 'text-white' : 'text-white/50'}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.pidx && item.pidx > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(`#/project-detail/${item.pidx}`, '_blank');
|
||||
}}
|
||||
className="text-primary-400 hover:text-primary-300 transition-colors flex-shrink-0"
|
||||
title="프로젝트 정보 보기"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<span className="truncate cursor-pointer" onClick={() => openEditModal(item)} title={item.projectName}>
|
||||
{item.projectName || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>{item.type || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm cursor-pointer" onClick={() => openEditModal(item)}>
|
||||
<span className={`px-2 py-1 rounded text-xs ${item.status?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70'
|
||||
}`}>
|
||||
{item.status || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>
|
||||
{item.hrs || 0}
|
||||
</td>
|
||||
{canViewOT && (
|
||||
<td className="px-4 py-3 text-white text-sm">
|
||||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>
|
||||
{item.ot ? <span className="text-warning-400">{item.ot}</span> : '-'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-white text-sm">{item.name || item.id || '-'}</td>
|
||||
<td className="px-4 py-3 text-white text-sm cursor-pointer" onClick={() => openEditModal(item)}>{item.name || item.id || '-'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Mail, Search, RefreshCw, Calendar } from 'lucide-react';
|
||||
import { Mail, Search, RefreshCw, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { MailItem, UserInfo } from '@/types';
|
||||
import { MailTestDialog } from '@/components/mail/MailTestDialog';
|
||||
|
||||
export function MailList() {
|
||||
const [mailList, setMailList] = useState<MailItem[]>([]);
|
||||
@@ -11,7 +12,10 @@ export function MailList() {
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<MailItem | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showTestDialog, setShowTestDialog] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 25;
|
||||
|
||||
const formatDateLocal = (date: Date) => {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
@@ -78,9 +82,17 @@ export function MailList() {
|
||||
alert('시작일은 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 페이징 계산
|
||||
const totalPages = Math.ceil(mailList.length / pageSize);
|
||||
const paginatedList = mailList.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize
|
||||
);
|
||||
|
||||
const handleRowClick = (item: MailItem) => {
|
||||
// 레벨 9 이상(개발자)만 상세보기 가능
|
||||
if (!currentUser || currentUser.Level < 9) {
|
||||
@@ -151,6 +163,16 @@ export function MailList() {
|
||||
)}
|
||||
조회
|
||||
</button>
|
||||
|
||||
{currentUser && currentUser.Level >= 9 && (
|
||||
<button
|
||||
onClick={() => setShowTestDialog(true)}
|
||||
className="h-10 bg-green-500 hover:bg-green-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
테스트
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +186,7 @@ export function MailList() {
|
||||
<span className="text-white/60 text-sm">{mailList.length}건</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/10 max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||
<div className="divide-y divide-white/10 max-h-[calc(100vh-380px)] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="px-6 py-8 text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
@@ -178,7 +200,7 @@ export function MailList() {
|
||||
<p className="text-white/50">조회된 데이터가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
mailList.map((item) => (
|
||||
paginatedList.map((item) => (
|
||||
<div
|
||||
key={item.idx}
|
||||
className={`px-6 py-4 transition-colors ${currentUser && currentUser.Level >= 9 ? 'hover:bg-white/5 cursor-pointer' : 'cursor-default'}`}
|
||||
@@ -215,6 +237,29 @@ export function MailList() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 px-6 py-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 text-white/70"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</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 text-white/70"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 모달 */}
|
||||
@@ -283,6 +328,15 @@ export function MailList() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메일 테스트 다이얼로그 */}
|
||||
<MailTestDialog
|
||||
isOpen={showTestDialog}
|
||||
onClose={() => {
|
||||
setShowTestDialog(false);
|
||||
loadData(); // 목록 새로고침
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
588
Project/frontend/src/pages/PartList.tsx
Normal file
588
Project/frontend/src/pages/PartList.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
ClipboardList,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { PartListItem } from '@/types';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
export function PartList() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const projectIdx = parseInt(searchParams.get('idx') || '0');
|
||||
const projectName = searchParams.get('name') || '';
|
||||
|
||||
const [parts, setParts] = useState<PartListItem[]>([]);
|
||||
const [filteredParts, setFilteredParts] = useState<PartListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKey, setSearchKey] = useState('');
|
||||
const [editingIdx, setEditingIdx] = useState<number | null>(null);
|
||||
const [editForm, setEditForm] = useState<Partial<PartListItem>>({});
|
||||
const [showSummary, setShowSummary] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadParts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('[PartList] 로드 시작, projectIdx:', projectIdx);
|
||||
const result = await comms.getPartList(projectIdx);
|
||||
console.log('[PartList] 결과:', result);
|
||||
if (result.Success && result.Data) {
|
||||
console.log('[PartList] 데이터 개수:', result.Data.length);
|
||||
setParts(result.Data);
|
||||
setFilteredParts(result.Data);
|
||||
} else {
|
||||
console.error('[PartList] 실패:', result.Message);
|
||||
alert(result.Message || '파트리스트 로드 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파트리스트 로드 실패:', error);
|
||||
alert('파트리스트 로드 중 오류: ' + error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectIdx > 0) {
|
||||
loadParts();
|
||||
}
|
||||
}, [projectIdx]);
|
||||
|
||||
// 검색
|
||||
useEffect(() => {
|
||||
if (!searchKey.trim()) {
|
||||
setFilteredParts(parts);
|
||||
return;
|
||||
}
|
||||
|
||||
const search = searchKey.toLowerCase();
|
||||
const filtered = parts.filter((part) => {
|
||||
return (
|
||||
part.itemsid?.toLowerCase().includes(search) ||
|
||||
part.itemname?.toLowerCase().includes(search) ||
|
||||
part.itemmodel?.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
setFilteredParts(filtered);
|
||||
}, [searchKey, parts]);
|
||||
|
||||
// 편집 시작
|
||||
const startEdit = (part: PartListItem) => {
|
||||
setEditingIdx(part.idx);
|
||||
setEditForm({ ...part });
|
||||
};
|
||||
|
||||
// 편집 취소
|
||||
const cancelEdit = () => {
|
||||
setEditingIdx(null);
|
||||
setEditForm({});
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!editForm.itemname || !editForm.item) {
|
||||
alert('품명과 자재번호는 필수입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await comms.savePartList(
|
||||
editingIdx || 0,
|
||||
projectIdx,
|
||||
editForm.itemgroup || '',
|
||||
editForm.itemname || '',
|
||||
editForm.item || '',
|
||||
editForm.itemmodel || '',
|
||||
'', // itemscale 제거됨
|
||||
editForm.itemunit || '',
|
||||
editForm.qty || 0,
|
||||
editForm.price || 0,
|
||||
editForm.itemsupply || '',
|
||||
editForm.itemsupplyidx || 0,
|
||||
editForm.itemmanu || '',
|
||||
editForm.itemsid || '',
|
||||
editForm.option1 || '',
|
||||
editForm.remark || '',
|
||||
editForm.no || 0,
|
||||
editForm.qtybuy || 0
|
||||
);
|
||||
|
||||
if (result.Success) {
|
||||
await loadParts();
|
||||
cancelEdit();
|
||||
} else {
|
||||
alert(result.Message || '저장 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (idx: number) => {
|
||||
if (!confirm('정말 삭제하시겠습니까?')) return;
|
||||
|
||||
try {
|
||||
const result = await comms.deletePartList(idx);
|
||||
if (result.Success) {
|
||||
await loadParts();
|
||||
} else {
|
||||
alert(result.Message || '삭제 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
alert('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 새 항목 추가
|
||||
const addNew = () => {
|
||||
setEditingIdx(-1);
|
||||
setEditForm({
|
||||
Project: projectIdx,
|
||||
itemgroup: '',
|
||||
itemname: '',
|
||||
item: '',
|
||||
itemmodel: '',
|
||||
itemunit: 'EA',
|
||||
qty: 1,
|
||||
price: 0,
|
||||
itemsupply: '',
|
||||
itemsupplyidx: 0,
|
||||
itemmanu: '',
|
||||
itemsid: '',
|
||||
option1: '',
|
||||
remark: '',
|
||||
no: 0,
|
||||
qtybuy: 0,
|
||||
});
|
||||
};
|
||||
|
||||
// 금액 계산
|
||||
const getAmount = (qty: number, price: number) => qty * price;
|
||||
|
||||
// 합계 계산
|
||||
const totalAmount = filteredParts.reduce((sum, part) => sum + getAmount(part.qty || 0, part.price || 0), 0);
|
||||
|
||||
// 그룹별 합계
|
||||
const groupSummary = filteredParts.reduce((acc, part) => {
|
||||
const group = part.itemgroup || '미분류';
|
||||
if (!acc[group]) {
|
||||
acc[group] = { count: 0, amount: 0 };
|
||||
}
|
||||
acc[group].count++;
|
||||
acc[group].amount += getAmount(part.qty || 0, part.price || 0);
|
||||
return acc;
|
||||
}, {} as Record<string, { count: number; amount: number }>);
|
||||
|
||||
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">
|
||||
<ClipboardList className="w-6 h-6 text-amber-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">파트리스트</h1>
|
||||
<p className="text-sm text-white/60">{projectName}</p>
|
||||
</div>
|
||||
<span className="text-white/50 text-sm">({filteredParts.length}건)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={addNew}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="text-sm">추가</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={loadParts}
|
||||
disabled={loading}
|
||||
className="p-2 hover:bg-white/10 rounded transition-colors disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 text-white/70 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="flex items-center gap-2 bg-white/5 rounded-lg px-3 py-2">
|
||||
<Search className="w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKey}
|
||||
onChange={(e) => setSearchKey(e.target.value)}
|
||||
placeholder="SID, 품명, 모델로 검색..."
|
||||
className="flex-1 bg-transparent text-white placeholder-white/30 focus:outline-none text-sm"
|
||||
/>
|
||||
{searchKey && (
|
||||
<button onClick={() => setSearchKey('')} className="text-white/50 hover:text-white/70">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="glass-effect rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
{loading && parts.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-8 h-8 text-primary-500 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50 sticky top-0 z-10">
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-12">No</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-24">그룹</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-24">SID</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium">품명</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">모델</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-16">단위</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-20">수량</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-28">단가</th>
|
||||
<th className="px-2 py-2 text-right text-xs text-white/70 font-medium w-32">금액</th>
|
||||
<th className="px-2 py-2 text-left text-xs text-white/70 font-medium w-32">공급처</th>
|
||||
<th className="px-2 py-2 text-center text-xs text-white/70 font-medium w-20">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredParts.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-2 py-8 text-center text-white/40 text-sm">
|
||||
{searchKey ? '검색 결과가 없습니다.' : '등록된 파트가 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredParts.map((part) => {
|
||||
const isEditing = editingIdx === part.idx;
|
||||
return (
|
||||
<tr
|
||||
key={part.idx}
|
||||
className={`border-b border-white/5 hover:bg-white/5 transition-colors ${
|
||||
isEditing ? 'bg-primary-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.no || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, no: parseInt(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.no || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemgroup || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemgroup: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemgroup || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsid || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsid: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemsid || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemname || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemname: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/90 text-xs font-medium">{part.itemname || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemmodel || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemmodel: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemmodel || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemunit || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemunit: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemunit || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.qty || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, qty: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.qty?.toLocaleString() || 0}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.price || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.price?.toLocaleString() || 0}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<span className="text-white/90 text-xs font-medium">
|
||||
{getAmount(
|
||||
isEditing ? editForm.qty || 0 : part.qty || 0,
|
||||
isEditing ? editForm.price || 0 : part.price || 0
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsupply || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsupply: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white/70 text-xs">{part.itemsupply || ''}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded transition-colors"
|
||||
title="저장"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-white/10 text-white/50 rounded transition-colors"
|
||||
title="취소"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(part)}
|
||||
className="p-1 hover:bg-white/10 text-white/70 rounded transition-colors text-xs"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(part.idx)}
|
||||
className="p-1 hover:bg-red-500/20 text-red-400 rounded transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{/* 새 항목 추가 행 */}
|
||||
{editingIdx === -1 && (
|
||||
<tr className="border-b border-white/5 bg-primary-500/10">
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.no || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, no: parseInt(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemgroup || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemgroup: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="그룹"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsid || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsid: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="SID"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemname || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemname: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="품명 *"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemmodel || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemmodel: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="모델"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemunit || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemunit: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="단위"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.qty || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, qty: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.price || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<span className="text-white/90 text-xs font-medium">
|
||||
{getAmount(editForm.qty || 0, editForm.price || 0).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.itemsupply || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, itemsupply: e.target.value })}
|
||||
className="w-full bg-slate-700/50 text-white text-xs px-2 py-1 rounded border border-white/10 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="공급처"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-1 hover:bg-green-500/20 text-green-400 rounded transition-colors"
|
||||
title="저장"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 hover:bg-white/10 text-white/50 rounded transition-colors"
|
||||
title="취소"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<div className="flex gap-4">
|
||||
{/* 좌측: 비용 요약 */}
|
||||
<div className="glass-effect rounded-xl p-4 flex-1">
|
||||
<button
|
||||
onClick={() => setShowSummary(!showSummary)}
|
||||
className="flex items-center gap-2 text-amber-400 hover:text-amber-300 mb-3"
|
||||
>
|
||||
<DollarSign className="w-5 h-5" />
|
||||
<span className="font-medium">비용 요약</span>
|
||||
</button>
|
||||
|
||||
{showSummary && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(groupSummary).map(([group, data]) => (
|
||||
<div key={group} className="flex items-center justify-between text-sm border-b border-white/5 pb-2">
|
||||
<span className="text-white/70">{group}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-white/50 text-xs">{data.count}건</span>
|
||||
<span className="text-primary-400 font-medium">{data.amount.toLocaleString()}원</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 합계 */}
|
||||
<div className="glass-effect rounded-xl p-4 min-w-[300px]">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-white/70 text-sm">총 항목</span>
|
||||
<span className="text-white font-medium">{filteredParts.length}개</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-white/70 text-sm">총 금액</span>
|
||||
<span className="text-amber-400 font-bold text-lg">{totalAmount.toLocaleString()}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
User,
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
ClipboardList,
|
||||
Mail,
|
||||
Edit2,
|
||||
} from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { ProjectListItem, ProjectListResponse } from '@/types';
|
||||
import { ProjectDetailDialog } from '@/components/project';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// 상태별 색상 매핑
|
||||
const statusColors: Record<string, { text: string; bg: string }> = {
|
||||
@@ -29,6 +33,11 @@ export function Project() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<ProjectListItem | null>(null);
|
||||
const [showDetailDialog, setShowDetailDialog] = useState(false);
|
||||
const [expandedProject, setExpandedProject] = useState<number | null>(null);
|
||||
const [projectHistory, setProjectHistory] = useState<any[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [editingHistory, setEditingHistory] = useState<any | null>(null);
|
||||
const [editRemark, setEditRemark] = useState('');
|
||||
|
||||
// 필터 상태
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
@@ -37,13 +46,15 @@ export function Project() {
|
||||
const [selectedProcess, setSelectedProcess] = useState('전체');
|
||||
const [userFilter, setUserFilter] = useState('');
|
||||
const [currentUserName, setCurrentUserName] = useState('');
|
||||
const [userLevel, setUserLevel] = useState<number>(0);
|
||||
const [userCode, setUserCode] = useState<string>('');
|
||||
|
||||
// 상태 필터 체크박스
|
||||
const [statusChecks, setStatusChecks] = useState({
|
||||
검토: true,
|
||||
진행: true,
|
||||
대기: false,
|
||||
보류: false,
|
||||
보류: true,
|
||||
완료: true,
|
||||
'완료(보고)': false,
|
||||
취소: false,
|
||||
@@ -82,9 +93,12 @@ export function Project() {
|
||||
try {
|
||||
const loginStatus = await comms.checkLoginStatus();
|
||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||
const userName = (loginStatus.User as { NameK?: string }).NameK || loginStatus.User.Name || '';
|
||||
const user = loginStatus.User as { NameK?: string; Level?: number; Code?: string };
|
||||
const userName = user.NameK || loginStatus.User.Name || '';
|
||||
setCurrentUserName(userName);
|
||||
setUserFilter(userName);
|
||||
setUserLevel(user.Level || 0);
|
||||
setUserCode(user.Code || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('로그인 정보 로드 오류:', error);
|
||||
@@ -155,8 +169,11 @@ export function Project() {
|
||||
const filtered = projects.filter(
|
||||
(p) =>
|
||||
p.name?.toLowerCase().includes(key) ||
|
||||
p.userManager?.toLowerCase().includes(key) ||
|
||||
p.usermain?.toLowerCase().includes(key) ||
|
||||
p.name_champion?.toLowerCase().includes(key) ||
|
||||
p.name_design?.toLowerCase().includes(key) ||
|
||||
p.name_epanel?.toLowerCase().includes(key) ||
|
||||
p.name_software?.toLowerCase().includes(key) ||
|
||||
p.reqstaff?.toLowerCase().includes(key) ||
|
||||
p.orderno?.toLowerCase().includes(key) ||
|
||||
p.memo?.toLowerCase().includes(key)
|
||||
);
|
||||
@@ -186,6 +203,82 @@ export function Project() {
|
||||
setStatusChecks((prev) => ({ ...prev, [status]: !prev[status as keyof typeof prev] }));
|
||||
};
|
||||
|
||||
// 히스토리 토글 (편집 아이콘 클릭)
|
||||
const toggleHistory = async (projectIdx: number) => {
|
||||
if (expandedProject === projectIdx) {
|
||||
setExpandedProject(null);
|
||||
setProjectHistory([]);
|
||||
setEditingHistory(null);
|
||||
} else {
|
||||
setExpandedProject(projectIdx);
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const result = await comms.getProjectHistory(projectIdx);
|
||||
if (result.Success && result.Data) {
|
||||
setProjectHistory(result.Data as any[]);
|
||||
} else {
|
||||
setProjectHistory([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('히스토리 로드 오류:', error);
|
||||
setProjectHistory([]);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 히스토리 편집 시작
|
||||
const startEditHistory = (history: any) => {
|
||||
setEditingHistory(history);
|
||||
setEditRemark(history.remark || '');
|
||||
};
|
||||
|
||||
// 새 히스토리 추가 시작
|
||||
const startAddHistory = (projectIdx: number) => {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
setEditingHistory({ pidx: projectIdx, pdate: today, progress: 0, remark: '', isNew: true });
|
||||
setEditRemark('');
|
||||
};
|
||||
|
||||
// 히스토리 저장
|
||||
const saveHistory = async () => {
|
||||
if (!editingHistory) return;
|
||||
|
||||
try {
|
||||
const historyData = {
|
||||
idx: editingHistory.idx || 0,
|
||||
pidx: editingHistory.pidx,
|
||||
pdate: editingHistory.pdate,
|
||||
progress: editingHistory.progress || 0,
|
||||
remark: editRemark,
|
||||
};
|
||||
|
||||
const result = await comms.saveProjectHistory(historyData);
|
||||
|
||||
if (result.Success) {
|
||||
// 저장 성공 후 히스토리 다시 로드
|
||||
const historyResult = await comms.getProjectHistory(editingHistory.pidx);
|
||||
if (historyResult.Success && historyResult.Data) {
|
||||
setProjectHistory(historyResult.Data as any[]);
|
||||
}
|
||||
} else {
|
||||
alert(result.Message || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
setEditingHistory(null);
|
||||
setEditRemark('');
|
||||
} catch (error) {
|
||||
console.error('히스토리 저장 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 편집 취소
|
||||
const cancelEdit = () => {
|
||||
setEditingHistory(null);
|
||||
setEditRemark('');
|
||||
};
|
||||
|
||||
// 페이징 계산
|
||||
const totalPages = Math.ceil(filteredProjects.length / pageSize);
|
||||
const paginatedProjects = filteredProjects.slice(
|
||||
@@ -326,7 +419,7 @@ export function Project() {
|
||||
<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-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>
|
||||
@@ -351,33 +444,40 @@ export function Project() {
|
||||
) : (
|
||||
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'
|
||||
: '';
|
||||
const isExpanded = expandedProject === project.idx;
|
||||
|
||||
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>
|
||||
<>
|
||||
<tr
|
||||
key={project.idx}
|
||||
className={clsx(
|
||||
'border-b border-white/10 cursor-pointer hover:bg-white/5',
|
||||
isExpanded && 'bg-primary-900/30'
|
||||
)}
|
||||
onClick={() => toggleHistory(project.idx)}
|
||||
>
|
||||
<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}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleSelectProject(project);
|
||||
}}
|
||||
className="text-primary-300 hover:text-primary-200 transition-colors"
|
||||
title="편집"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="font-regular text-white/90">{project.name}</span>
|
||||
</div>
|
||||
</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>
|
||||
@@ -400,20 +500,115 @@ export function Project() {
|
||||
<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="자스민 열기"
|
||||
<div className="flex items-center gap-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>
|
||||
)}
|
||||
{(userLevel >= 9 || userCode === '395552') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const w = window as any;
|
||||
if (w.CefSharp) {
|
||||
w.CefSharp.BindObjectAsync('bridge').then(() => {
|
||||
w.bridge?.OpenMailHistory();
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-cyan-400 hover:text-cyan-300"
|
||||
title="메일내역"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`#/partlist?idx=${project.idx}&name=${encodeURIComponent(project.name)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-amber-400 hover:text-amber-300"
|
||||
title="파트리스트"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`history-${project.idx}`}>
|
||||
<td colSpan={8} className="px-3 py-2 bg-primary-950/50">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-semibold text-primary-300">주간 업무 내용</div>
|
||||
<button
|
||||
onClick={() => startAddHistory(project.idx)}
|
||||
className="text-xs px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded transition-colors"
|
||||
>
|
||||
+ 새 내용 등록
|
||||
</button>
|
||||
</div>
|
||||
{loadingHistory ? (
|
||||
<div className="text-white/50 text-sm">로딩 중...</div>
|
||||
) : editingHistory ? (
|
||||
<div className="bg-white/10 rounded p-3 space-y-3">
|
||||
<div className="flex gap-4 text-xs text-white/60">
|
||||
<span className="text-primary-400 font-semibold">{formatDate(editingHistory.pdate)}</span>
|
||||
<span>진행률: {editingHistory.progress || 0}%</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={editRemark}
|
||||
onChange={(e) => setEditRemark(e.target.value)}
|
||||
className="w-full h-32 px-3 py-2 bg-white/5 border border-white/10 rounded text-white text-sm resize-none"
|
||||
placeholder="업무 내용을 입력하세요..."
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="px-3 py-1 bg-white/5 hover:bg-white/10 text-white/70 rounded text-sm transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={saveHistory}
|
||||
className="px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded text-sm transition-colors"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : projectHistory.length > 0 ? (
|
||||
<div
|
||||
className="bg-white/5 rounded p-3 border-l-2 border-primary-500 cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={() => startEditHistory(projectHistory[0])}
|
||||
>
|
||||
<div className="flex gap-4 mb-2 text-xs">
|
||||
<span className="text-primary-400 font-semibold">{formatDate(projectHistory[0].pdate)}</span>
|
||||
<span className="text-white/60">진행률: {projectHistory[0].progress || 0}%</span>
|
||||
<span className="text-white/40">{projectHistory[0].wname || ''}</span>
|
||||
</div>
|
||||
{projectHistory[0].remark ? (
|
||||
<div className="text-sm text-white/80 whitespace-pre-wrap">{projectHistory[0].remark}</div>
|
||||
) : (
|
||||
<div className="text-sm text-white/40 italic">내용이 비어있습니다. 클릭하여 입력하세요.</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/50 text-sm text-center py-4">
|
||||
업무 내용이 없습니다. 새 내용을 등록하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -452,6 +647,8 @@ export function Project() {
|
||||
onClose={handleCloseDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const getStatusClass = (status: string): string => {
|
||||
|
||||
const getPriorityText = (seqno: number): string => {
|
||||
switch (seqno) {
|
||||
case -1: return '낮음';
|
||||
case 1: return '중요';
|
||||
case 2: return '매우 중요';
|
||||
case 3: return '긴급';
|
||||
@@ -46,6 +47,7 @@ const getPriorityText = (seqno: number): string => {
|
||||
|
||||
const getPriorityClass = (seqno: number): string => {
|
||||
switch (seqno) {
|
||||
case -1: return 'bg-white/5 text-white/40';
|
||||
case 1: return 'bg-primary-500/20 text-primary-300';
|
||||
case 2: return 'bg-warning-500/20 text-warning-300';
|
||||
case 3: return 'bg-danger-500/20 text-danger-300';
|
||||
@@ -561,9 +563,22 @@ function TodoModal({
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
{title}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isEdit && onComplete && currentStatus !== '5' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onComplete}
|
||||
disabled={processing}
|
||||
className="bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center disabled:opacity-50 text-sm"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
완료 처리
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
@@ -640,10 +655,11 @@ function TodoModal({
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
||||
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
|
||||
>
|
||||
<option value={0}>보통</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={3}>긴급</option>
|
||||
<option value={2}>매우 중요</option>
|
||||
<option value={1}>중요</option>
|
||||
<option value={0}>보통</option>
|
||||
<option value={-1}>낮음</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
@@ -661,42 +677,8 @@ function TodoModal({
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
|
||||
{/* 왼쪽: 삭제 버튼 (편집 모드일 때만) */}
|
||||
<div>
|
||||
{isEdit && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 취소, 완료, 수정 버튼 */}
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end">
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
{isEdit && onComplete && currentStatus !== '5' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onComplete}
|
||||
disabled={processing}
|
||||
className="bg-success-500 hover:bg-success-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
완료 처리
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
@@ -710,6 +692,17 @@ function TodoModal({
|
||||
)}
|
||||
{submitText}
|
||||
</button>
|
||||
{isEdit && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={processing}
|
||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface PurchaseItem {
|
||||
|
||||
// 상태 관련 타입
|
||||
export type TodoStatus = '0' | '1' | '2' | '3' | '5';
|
||||
export type TodoPriority = 0 | 1 | 2 | 3;
|
||||
export type TodoPriority = -1 | 0 | 1 | 2 | 3;
|
||||
|
||||
// 로그 타입
|
||||
export interface LogEntry {
|
||||
@@ -447,6 +447,7 @@ export interface MachineBridgeInterface {
|
||||
Project_GetProcesses(): Promise<string>;
|
||||
Project_GetList(statusFilter: string, category: string, process: string, userFilter: string, yearStart: string, yearEnd: string, dateType: string): Promise<string>;
|
||||
Project_GetHistory(projectIdx: number): Promise<string>;
|
||||
Project_SaveHistory(idx: number, pidx: number, pdate: string, progress: number, remark: string): Promise<string>;
|
||||
Project_GetDailyMemo(projectIdx: number): Promise<string>;
|
||||
|
||||
// Note API (메모장)
|
||||
@@ -467,10 +468,26 @@ export interface MachineBridgeInterface {
|
||||
|
||||
// Mail API (메일 발신 내역)
|
||||
Mail_GetList(startDate: string, endDate: string, searchKey: string): Promise<string>;
|
||||
Mail_AddData(cate: string, subject: string, fromlist: string, tolist: string, cc: string, bcc: string, body: string): Promise<string>;
|
||||
Mail_SendDirect(cate: string, subject: string, fromlist: string, tolist: string, cc: string, bcc: string, body: string): Promise<string>;
|
||||
Mail_SendOutlook(subject: string, tolist: string, cc: string, bcc: string, body: string): Promise<string>;
|
||||
|
||||
// Customs API (업체정보)
|
||||
Customs_GetList(searchKey: string): Promise<string>;
|
||||
Customs_GetDetail(idx: number): Promise<string>;
|
||||
|
||||
// License API (라이선스 관리)
|
||||
License_GetList(): Promise<string>;
|
||||
License_Add(name: string, version: string, meterialNo: string, supply: string, qty: number, uids: string, serialNo: string, remark: string, sdate: string, edate: string, manu: string, expire: boolean): Promise<string>;
|
||||
License_Update(idx: number, name: string, version: string, meterialNo: string, supply: string, qty: number, uids: string, serialNo: string, remark: string, sdate: string, edate: string, manu: string, expire: boolean): Promise<string>;
|
||||
License_Delete(idx: number): Promise<string>;
|
||||
License_OpenFolder(idx: number): Promise<string>;
|
||||
License_ExportCSV(filePath: string): Promise<string>;
|
||||
|
||||
// PartList API (파트리스트)
|
||||
PartList_GetList(projectIdx: number): Promise<string>;
|
||||
PartList_Save(idx: number, projectIdx: number, itemgroup: string, itemname: string, item: string, itemmodel: string, itemscale: string, itemunit: string, qty: number, price: number, itemsupply: string, itemsupplyidx: number, itemmanu: string, itemsid: string, option1: string, remark: string, no: number, qtybuy: number): Promise<string>;
|
||||
PartList_Delete(idx: number): Promise<string>;
|
||||
}
|
||||
|
||||
// 사용자 권한 정보 타입
|
||||
@@ -503,6 +520,8 @@ export interface AppVersionInfo {
|
||||
ProductName: string;
|
||||
ProductVersion: string;
|
||||
DisplayVersion: string;
|
||||
MaxVersion?: string;
|
||||
HasNewVersion?: boolean;
|
||||
}
|
||||
|
||||
// 사용자 전체 정보 저장용 타입
|
||||
@@ -895,3 +914,48 @@ export interface CustomItem {
|
||||
name2: string;
|
||||
gcode: string;
|
||||
}
|
||||
|
||||
// 라이선스 타입
|
||||
export interface LicenseItem {
|
||||
idx?: number;
|
||||
gcode?: string;
|
||||
expire?: boolean;
|
||||
name?: string;
|
||||
version?: string;
|
||||
meterialNo?: string;
|
||||
supply?: string;
|
||||
qty?: number;
|
||||
uids?: string;
|
||||
serialNo?: string;
|
||||
remark?: string;
|
||||
sdate?: string;
|
||||
edate?: string;
|
||||
manu?: string;
|
||||
wuid?: string;
|
||||
wdate?: string;
|
||||
}
|
||||
|
||||
// 파트리스트 타입 (ProjectsPart 테이블)
|
||||
export interface PartListItem {
|
||||
idx: number;
|
||||
Project: number;
|
||||
itemgroup?: string;
|
||||
itemname: string;
|
||||
item: string; // 자재번호
|
||||
itemmodel?: string;
|
||||
itemscale?: string;
|
||||
itemunit?: string;
|
||||
qty?: number;
|
||||
price?: number;
|
||||
amt?: number; // 계산된 금액 (qty * price)
|
||||
itemsupply?: string;
|
||||
itemsupplyidx?: number;
|
||||
itemmanu?: string;
|
||||
itemsid?: string;
|
||||
option1?: string;
|
||||
remark?: string;
|
||||
no?: number;
|
||||
qtybuy?: number;
|
||||
wuid?: string;
|
||||
wdate?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user