fix: React 프론트엔드 버그 수정 및 모바일 반응형 개선

- CORS: React 포트(5174) 허용 추가 (application.yml)
- ProjectController: /my 엔드포인트 userId 파라미터 선택사항으로 변경
- API 응답 안전 처리: toArray 유틸 추가, 모든 뷰에서 배열 보장
- DataTable emptyMessage: JSX → 문자열로 변경 (PrimeReact 호환)
- LoginView: Password → InputText type=password 변경
- 모바일 사이드바: Vue/React 양쪽 inline width 제거로 슬라이드 정상 동작
- 모바일 서브메뉴 텍스트 표시 CSS 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-30 21:35:53 +09:00
부모 cda5f9591e
커밋 85aaebe134
18개의 변경된 파일90개의 추가작업 그리고 42개의 파일을 삭제

파일 보기

@@ -0,0 +1,14 @@
/**
* API 응답에서 안전하게 배열을 추출합니다.
* - 응답이 배열이면 그대로 반환
* - 응답이 { items: [...] } 형태면 items 반환
* - 그 외 빈 배열 반환
*/
export function toArray<T>(data: unknown): T[] {
if (Array.isArray(data)) return data as T[];
if (data && typeof data === 'object' && 'items' in data) {
const items = (data as Record<string, unknown>).items;
if (Array.isArray(items)) return items as T[];
}
return [];
}

파일 보기

@@ -59,4 +59,28 @@
border: none;
background: transparent;
}
&__menu .p-panelmenu-panel {
.p-panelmenu-header-content,
.p-menuitem-content {
white-space: nowrap;
overflow: visible;
}
.p-panelmenu-content {
overflow: visible;
}
.p-menuitem-text {
display: inline !important;
}
}
// 모바일에서 서브메뉴 텍스트 표시 보장
&--mobile &__nav {
.p-menuitem-text,
.p-panelmenu-header-content span {
display: inline !important;
}
}
}

파일 보기

@@ -34,8 +34,6 @@ export default function AppSidebar({ visible, collapsed, mobile }: Props) {
}));
}, [currentUser, navigate]);
const sidebarWidth = collapsed ? `${LAYOUT.sidebarCollapsedWidth}px` : `${LAYOUT.sidebarWidth}px`;
const classNames = [
'app-sidebar',
visible && 'app-sidebar--visible',
@@ -45,8 +43,12 @@ export default function AppSidebar({ visible, collapsed, mobile }: Props) {
.filter(Boolean)
.join(' ');
const style = mobile
? undefined
: { width: collapsed ? `${LAYOUT.sidebarCollapsedWidth}px` : `${LAYOUT.sidebarWidth}px` };
return (
<aside className={classNames} style={{ width: sidebarWidth }}>
<aside className={classNames} style={style}>
<div className="app-sidebar__header">
<img src={logo} alt="WTM" className="app-sidebar__logo" />
{!collapsed && <span className="app-sidebar__title">WTM</span>}

파일 보기

@@ -77,12 +77,7 @@ export default function BaseCrudTable<T extends Record<string, any>>({
className="crud-table__datatable"
onRowSelect={(e) => onRowSelect?.(e.data as T)}
onPage={onPage}
emptyMessage={
<div className="crud-table__empty">
<i className="pi pi-inbox" style={{ fontSize: '2rem' }} />
<p>{emptyMessage}</p>
</div>
}
emptyMessage={emptyMessage}
>
{children}
</DataTable>

파일 보기

@@ -7,6 +7,7 @@ import BaseCrudTable from '@/core/components/BaseCrudTable';
import BasePageHeader from '@/core/components/BasePageHeader';
import { approvalService } from '../approval.service';
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
import { toArray } from '@/core/api/utils';
export default function ApprovalHistoryView() {
const [loading, setLoading] = useState(false);
@@ -23,7 +24,7 @@ export default function ApprovalHistoryView() {
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
approvalService.getHistory(params)
.then(({ data }) => setHistory((data as { items?: unknown[] }).items as Record<string, unknown>[] ?? data as Record<string, unknown>[]))
.then(({ data }) => setHistory(toArray<Record<string, unknown>>(data)))
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, [statusFilter, dateFrom, dateTo]);

파일 보기

@@ -7,6 +7,7 @@ import BasePageHeader from '@/core/components/BasePageHeader';
import { approvalService } from '../approval.service';
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
import type { Approval } from '../approval.types';
import { toArray } from '@/core/api/utils';
export default function ApprovalPendingView() {
const [loading, setLoading] = useState(false);
@@ -16,7 +17,7 @@ export default function ApprovalPendingView() {
function load() {
setLoading(true);
approvalService.getPending()
.then(({ data }) => setApprovals((data as { items?: Approval[] }).items ?? data as Approval[]))
.then(({ data }) => setApprovals(toArray<Approval>(data)))
.catch(() => setApprovals([]))
.finally(() => setLoading(false));
}

파일 보기

@@ -1,7 +1,6 @@
import { useState, type FormEvent } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { InputText } from 'primereact/inputtext';
import { Password } from 'primereact/password';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Message } from 'primereact/message';
@@ -55,13 +54,11 @@ export default function LoginView() {
<div className="form-field">
<label className="form-field__label"></label>
<Password
<InputText
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="비밀번호 입력"
feedback={false}
toggleMask
inputStyle={{ width: '100%' }}
/>
</div>

파일 보기

@@ -39,14 +39,16 @@ export default function DashboardView() {
setLoading(true);
dashboardService.getDashboard()
.then(({ data }) => {
setStats(data.stats ?? defaultStats);
setPendingApprovals(data.pendingApprovals ?? []);
const d = data as Record<string, unknown>;
setStats(Array.isArray(d.stats) ? d.stats as DashboardStat[] : defaultStats);
setPendingApprovals(Array.isArray(d.pendingApprovals) ? d.pendingApprovals as Record<string, unknown>[]
: Array.isArray(d.recentTimesheets) ? d.recentTimesheets as Record<string, unknown>[] : []);
setWeeklyHoursData({
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
datasets: [{
label: '시수',
backgroundColor: 'var(--p-primary-color)',
data: data.weeklyHours ?? [0, 0, 0, 0, 0, 0],
backgroundColor: '#3B82F6',
data: Array.isArray(d.weeklyHours) ? d.weeklyHours : [0, 0, 0, 0, 0, 0],
}],
});
})
@@ -54,7 +56,7 @@ export default function DashboardView() {
setStats(defaultStats);
setWeeklyHoursData({
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
datasets: [{ label: '시수', backgroundColor: 'var(--p-primary-color)', data: [0, 0, 0, 0, 0, 0] }],
datasets: [{ label: '시수', backgroundColor: '#3B82F6', data: [0, 0, 0, 0, 0, 0] }],
});
})
.finally(() => setLoading(false));
@@ -109,7 +111,7 @@ export default function DashboardView() {
<Card title="결재 대기 목록" className="dashboard-view__approvals-card">
<DataTable value={pendingApprovals} rows={5} paginator={pendingApprovals.length > 5} size="small" stripedRows
emptyMessage={<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--p-text-muted-color)' }}> .</div>}
emptyMessage="결재 대기 건이 없습니다."
>
<Column field="requesterName" header="요청자" />
<Column field="projectName" header="프로젝트" />

파일 보기

@@ -9,6 +9,7 @@ import ProjectFormDialog from '../components/ProjectFormDialog';
import { projectService } from '../project.service';
import { PROJECT_STATUS } from '@/core/constants/app.constants';
import type { Project } from '../project.types';
import { toArray } from '@/core/api/utils';
export default function ProjectListView() {
const [loading, setLoading] = useState(false);
@@ -20,7 +21,7 @@ export default function ProjectListView() {
function load() {
setLoading(true);
projectService.getAll()
.then(({ data }) => setProjects((data as { items?: Project[] }).items ?? data as Project[]))
.then(({ data }) => setProjects(toArray<Project>(data)))
.catch(() => setProjects([]))
.finally(() => setLoading(false));
}

파일 보기

@@ -9,6 +9,7 @@ import { Chart } from 'primereact/chart';
import BasePageHeader from '@/core/components/BasePageHeader';
import { reportService } from '../report.service';
import { projectService } from '@/modules/project/project.service';
import { toArray } from '@/core/api/utils';
export default function ReportView() {
const [projects, setProjects] = useState<{ id: number; name: string }[]>([]);
@@ -28,7 +29,7 @@ export default function ReportView() {
useEffect(() => {
projectService.getAll()
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string }[] ?? data as { id: number; name: string }[]))
.then(({ data }) => setProjects(toArray<{ id: number; name: string }>(data)))
.catch(() => setProjects([]));
}, []);
@@ -40,7 +41,7 @@ export default function ReportView() {
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
reportService.getProjectHours(params)
.then(({ data }) => setData(Array.isArray(data) ? data : (data as { items?: unknown[] }).items as Record<string, unknown>[] ?? []))
.then(({ data }) => setData(toArray<Record<string, unknown>>(data)))
.catch(() => setData([]))
.finally(() => setLoading(false));
}

파일 보기

@@ -9,6 +9,7 @@ import TealUploadDialog from '../components/TealUploadDialog';
import { tealService } from '../teal.service';
import { projectService } from '@/modules/project/project.service';
import type { TealEntry, TealVersion } from '../teal.types';
import { toArray } from '@/core/api/utils';
export default function TealListView() {
const [projects, setProjects] = useState<{ id: number; name: string; code: string }[]>([]);
@@ -21,14 +22,14 @@ export default function TealListView() {
useEffect(() => {
projectService.getAll()
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string; code: string }[] ?? data as { id: number; name: string; code: string }[]))
.then(({ data }) => setProjects(toArray<{ id: number; name: string; code: string }>(data)))
.catch(() => setProjects([]));
}, []);
useEffect(() => {
if (!selectedProjectId) { setVersions([]); setEntries([]); return; }
tealService.getVersions(selectedProjectId)
.then(({ data }) => setVersions(data as TealVersion[]))
.then(({ data }) => setVersions(toArray<TealVersion>(data)))
.catch(() => setVersions([]));
}, [selectedProjectId]);
@@ -36,7 +37,7 @@ export default function TealListView() {
if (!selectedProjectId || !selectedVersionId) { setEntries([]); return; }
setLoading(true);
tealService.getActive(selectedProjectId)
.then(({ data }) => setEntries(data as TealEntry[]))
.then(({ data }) => setEntries(toArray<TealEntry>(data)))
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [selectedProjectId, selectedVersionId]);
@@ -46,7 +47,7 @@ export default function TealListView() {
await tealService.upload(selectedProjectId, file, effectiveDate);
setUploadVisible(false);
const { data } = await tealService.getVersions(selectedProjectId);
setVersions(data as TealVersion[]);
setVersions(toArray<TealVersion>(data));
}
return (

파일 보기

@@ -8,6 +8,7 @@ import BaseCrudTable from '@/core/components/BaseCrudTable';
import BasePageHeader from '@/core/components/BasePageHeader';
import { timesheetService } from '../timesheet.service';
import { TIMESHEET_STATUS } from '@/core/constants/app.constants';
import { toArray } from '@/core/api/utils';
export default function TimesheetHistoryView() {
const [loading, setLoading] = useState(false);
@@ -25,7 +26,7 @@ export default function TimesheetHistoryView() {
if (dateTo) params.to = dateTo.toISOString().slice(0, 10);
timesheetService.getHistory(params)
.then(({ data }) => setHistory((data as { items?: unknown[] }).items as Record<string, unknown>[] ?? data as Record<string, unknown>[]))
.then(({ data }) => setHistory(toArray<Record<string, unknown>>(data)))
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, [statusFilter, dateFrom, dateTo]);

파일 보기

@@ -10,6 +10,7 @@ import BasePageHeader from '@/core/components/BasePageHeader';
import TimesheetEntryRow from '../components/TimesheetEntryRow';
import { timesheetService } from '../timesheet.service';
import { projectService } from '@/modules/project/project.service';
import { toArray } from '@/core/api/utils';
import { TIMESHEET_RULES, TIMESHEET_STATUS, ENTRY_TYPES } from '@/core/constants/app.constants';
import type { Timesheet, TimesheetEntry, EntryType } from '../timesheet.types';
import './TimesheetWeekView.scss';
@@ -126,7 +127,7 @@ export default function TimesheetWeekView() {
useEffect(() => {
projectService.getMy()
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string }[] ?? data as { id: number; name: string }[]))
.then(({ data }) => setProjects(toArray<{ id: number; name: string }>(data)))
.catch(() => setProjects([]));
}, []);

파일 보기

@@ -7,6 +7,7 @@ import BasePageHeader from '@/core/components/BasePageHeader';
import UserFormDialog from '../components/UserFormDialog';
import { userService } from '../user.service';
import type { User } from '../user.types';
import { toArray } from '@/core/api/utils';
const ROLE_SEVERITY: Record<string, string> = {
SA: 'danger', PM: 'warning', PCM: 'info', DL: 'success', PTK: 'secondary', USER: 'contrast',
@@ -21,7 +22,7 @@ export default function UserListView() {
function load() {
setLoading(true);
userService.getAll()
.then(({ data }) => setUsers((data as { items?: User[] }).items ?? data as User[]))
.then(({ data }) => setUsers(toArray<User>(data)))
.catch(() => setUsers([]))
.finally(() => setLoading(false));
}

파일 보기

@@ -11,6 +11,7 @@ import WbsUploadDialog from '../components/WbsUploadDialog';
import { wbsService } from '../wbs.service';
import { projectService } from '@/modules/project/project.service';
import type { WbsVersion, WbsNode } from '../wbs.types';
import { toArray } from '@/core/api/utils';
function nodesToTreeNodes(nodes: WbsNode[]): TreeNode[] {
return nodes.map((n) => ({
@@ -31,14 +32,14 @@ export default function WbsTreeView() {
useEffect(() => {
projectService.getAll()
.then(({ data }) => setProjects((data as { items?: unknown[] }).items as { id: number; name: string; code: string }[] ?? data as { id: number; name: string; code: string }[]))
.then(({ data }) => setProjects(toArray<{ id: number; name: string; code: string }>(data)))
.catch(() => setProjects([]));
}, []);
useEffect(() => {
if (!selectedProjectId) { setVersions([]); return; }
wbsService.getVersions(selectedProjectId)
.then(({ data }) => setVersions(data as WbsVersion[]))
.then(({ data }) => setVersions(toArray<WbsVersion>(data)))
.catch(() => setVersions([]));
}, [selectedProjectId]);
@@ -46,7 +47,7 @@ export default function WbsTreeView() {
if (!selectedProjectId || !selectedVersion) { setTreeNodes([]); return; }
setLoading(true);
wbsService.getVersionNodes(selectedProjectId, selectedVersion)
.then(({ data }) => setTreeNodes(nodesToTreeNodes(data as WbsNode[])))
.then(({ data }) => setTreeNodes(nodesToTreeNodes(toArray<WbsNode>(data))))
.catch(() => setTreeNodes([]))
.finally(() => setLoading(false));
}, [selectedProjectId, selectedVersion]);
@@ -56,7 +57,7 @@ export default function WbsTreeView() {
await wbsService.upload(selectedProjectId, file, effectiveDate);
setUploadVisible(false);
const { data } = await wbsService.getVersions(selectedProjectId);
setVersions(data as WbsVersion[]);
setVersions(toArray<WbsVersion>(data));
}
return (