This commit is contained in:
backuppc
2025-12-05 17:33:12 +09:00
parent 8e8d1f91b4
commit 77f1ddab80
92 changed files with 4878 additions and 20435 deletions

View File

@@ -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 - 개발용 */}

View File

@@ -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();

View File

@@ -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 ? '업무일지 수정' : '업무일지 등록'}

View File

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

View File

@@ -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 */}

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

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

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

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

View File

@@ -1 +1,2 @@
export { ProjectDetailDialog } from './ProjectDetailDialog';
export { PartListDialog } from './PartListDialog';

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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