feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가

자재관리:
- 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장
- 재고현황 컴포넌트 리팩토링

출고관리:
- 출하관리 생성/수정/목록/상세 개선
- 차량배차관리 상세/수정/목록 기능 보강

생산관리:
- 작업지시서 WIP 생산 모달 신규 추가
- 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가
- 작업자화면 기능 대폭 확장 (카드/목록 개선)
- 검사성적서 모달 개선

품질관리:
- 실적보고서 관리 페이지 신규 추가
- 검사관리 문서/타입/목데이터 개선

단가관리:
- 단가배포 페이지 및 컴포넌트 신규 추가
- 단가표 관리 페이지 및 컴포넌트 신규 추가

공통:
- 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard)
- 메뉴 폴링 훅 개선, 레이아웃 수정
- 모바일 줌/패닝 CSS 수정
- locale 유틸 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-04 12:46:19 +09:00
parent 17c16028b1
commit c1b63b850a
70 changed files with 6832 additions and 384 deletions

View File

@@ -104,6 +104,15 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
return '합격';
}, [items, judgmentCoverage.covered]);
// 측정값 변경 핸들러
const handleMeasuredValueChange = useCallback((flatIdx: number, value: string) => {
setItems(prev => {
const next = [...prev];
next[flatIdx] = { ...next[flatIdx], measuredValue: value };
return next;
});
}, []);
// 판정 클릭 핸들러
const handleJudgmentClick = useCallback((flatIdx: number, value: '적합' | '부적합') => {
setItems(prev => {
@@ -349,6 +358,20 @@ export function InspectionReportDocument({ data }: InspectionReportDocumentProps
if (measuredValueCoverage.covered.has(flatIdx)) {
return null;
}
// 편집 가능한 측정값 셀 → input 렌더
if (row.editable) {
return (
<td className="border border-gray-400 px-0.5 py-0.5 text-center">
<input
type="text"
value={row.measuredValue || ''}
onChange={(e) => handleMeasuredValueChange(flatIdx, e.target.value)}
className="w-full h-full px-1 py-0.5 text-center text-[11px] border border-gray-300 rounded-sm focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-200"
placeholder=""
/>
</td>
);
}
return (
<td className="border border-gray-400 px-2 py-1 text-center">
{row.measuredValue || ''}

View File

@@ -430,10 +430,10 @@ export const mockReportInspectionItems: ReportInspectionItem[] = [
// 3. 재질
{ no: 3, category: '재질', criteria: 'WY-SC780 인쇄상태 확인', method: '', frequency: '' },
// 4. 치수(오픈사이즈) (4개 세부항목) — 항목4만 병합: 체크검사/전수검사 (4행)
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '길이', criteria: '수주 치수 ± 30mm', method: '체크검사', frequency: '전수검사', methodSpan: 4, freqSpan: 4 },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '높이', criteria: '수주 치수 ± 30mm', method: '', frequency: '' },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '가이드레일 간격', criteria: '10 ± 5mm (측정부위 @ 높이 100 이하)', method: '', frequency: '' },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '하단막대 간격', criteria: '간격 (③+④)\n가이드레일과 하단마감재 측 사이 25mm 이내', method: '', frequency: '' },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '길이', criteria: '수주 치수 ± 30mm', method: '체크검사', frequency: '전수검사', methodSpan: 4, freqSpan: 4, editable: true },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '높이', criteria: '수주 치수 ± 30mm', method: '', frequency: '', editable: true },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '가이드레일 간격', criteria: '10 ± 5mm (측정부위 @ 높이 100 이하)', method: '', frequency: '', editable: true },
{ no: 4, category: '치수\n(오픈사이즈)', subCategory: '하단막대 간격', criteria: '간격 (③+④)\n가이드레일과 하단마감재 측 사이 25mm 이내', method: '', frequency: '', editable: true },
// 5. 작동테스트 — 판정 없음
{ no: 5, category: '작동테스트', subCategory: '개폐성능', criteria: '작동 유무 확인\n(일부 및 완전폐쇄)', method: '', frequency: '', hideJudgment: true },
// 6. 내화시험 (3개 세부항목) — "비차열\n차열성" 3행 병합, 항목 6+7+8+9 검사방법/주기/판정 모두 병합 (10행)

View File

@@ -211,6 +211,7 @@ export interface ReportInspectionItem {
freqSpan?: number; // 검사주기 셀 rowSpan (크로스그룹 병합)
judgmentSpan?: number; // 판정 셀 rowSpan (크로스그룹 병합, 예: 항목6+7+8+9)
hideJudgment?: boolean; // 판정 표시 안함 (빈 셀 렌더)
editable?: boolean; // 측정값 입력 가능 여부
}
// 제품검사성적서

View File

@@ -0,0 +1,91 @@
'use client';
/**
* 메모 모달
* - 선택한 항목 정보 표시 (N건, 총 N개소)
* - 메모 textarea
* - 취소/작성 버튼 (일괄 적용)
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
interface MemoModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
totalLocations: number;
onSubmit: (memo: string) => void;
}
export function MemoModal({
isOpen,
onClose,
selectedCount,
totalLocations,
onSubmit,
}: MemoModalProps) {
const [memo, setMemo] = useState('');
const handleSubmit = () => {
onSubmit(memo);
setMemo('');
onClose();
};
const handleClose = () => {
setMemo('');
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 선택 항목 정보 */}
<div className="flex items-center gap-4 text-sm text-muted-foreground bg-muted/50 rounded-lg px-4 py-3">
<span>: <strong className="text-foreground">{selectedCount}</strong></span>
<span className="text-border">|</span>
<span> : <strong className="text-foreground">{totalLocations}</strong></span>
</div>
{/* 메모 입력 */}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Textarea
placeholder="메모를 입력하세요"
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={5}
className="resize-none"
/>
<p className="text-xs text-muted-foreground">
{selectedCount} .
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handleSubmit}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,638 @@
'use client';
/**
* 실적신고 목록 페이지
*
* 탭 2개 (above-stats 위치):
* - 분기별 실적신고: 연도+분기 버튼 필터, 검색, 통계 카드 4개, 액션 버튼, 테이블
* - 누락체크: 설명 박스 + 누락 목록 테이블
*
* UniversalListPage 공통 템플릿 사용
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
FileText,
ClipboardList,
CheckCircle2,
XCircle,
MapPin,
Check,
Undo2,
Send,
Pencil,
Download,
AlertTriangle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type StatCard,
type ListParams,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { MemoModal } from './MemoModal';
import {
getPerformanceReports,
getPerformanceReportStats,
getMissedReports,
confirmReports,
unconfirmReports,
distributeReports,
updateMemo,
} from './actions';
import { confirmStatusColorMap } from './mockData';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type {
PerformanceReportStats,
ReportListItem,
Quarter,
} from './types';
const ITEMS_PER_PAGE = 20;
export function PerformanceReportList() {
// ===== 통계 =====
const [statsData, setStatsData] = useState<PerformanceReportStats>({
totalCount: 0,
confirmedCount: 0,
unconfirmedCount: 0,
totalLocations: 0,
});
// ===== 연도/분기 필터 =====
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
const [quarter, setQuarter] = useState<Quarter | '전체'>('전체');
// ===== 메모 모달 =====
const [isMemoModalOpen, setIsMemoModalOpen] = useState(false);
const [memoSelectedIds, setMemoSelectedIds] = useState<string[]>([]);
const [memoTotalLocations, setMemoTotalLocations] = useState(0);
// ===== 리프레시 트리거 =====
const [refreshKey, setRefreshKey] = useState(0);
// ===== 현재 탭 추적 (headerActions에서 사용) =====
const [currentTab, setCurrentTab] = useState('quarterly');
// ===== 현재 데이터 추적 (메모 모달 개소 계산용) =====
const [currentData, setCurrentData] = useState<ReportListItem[]>([]);
// ===== 연도 옵션 =====
const yearOptions = useMemo(() => {
const years = [];
for (let y = currentYear; y >= currentYear - 5; y--) {
years.push(y);
}
return years;
}, [currentYear]);
// ===== 통계 로드 =====
useEffect(() => {
const loadStats = async () => {
try {
const result = await getPerformanceReportStats({ year, quarter });
if (result.success && result.data) {
setStatsData(result.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportList] loadStats error:', error);
}
};
loadStats();
}, [year, quarter, refreshKey]);
// ===== 분기 버튼 클릭 =====
const handleQuarterChange = useCallback((q: Quarter | '전체') => {
setQuarter(q);
}, []);
// ===== 액션 핸들러 =====
const handleConfirm = useCallback(async (
selectedItems: Set<string>,
onClearSelection: () => void,
onRefresh: () => void
) => {
if (selectedItems.size === 0) {
toast.error('확정할 항목을 선택해주세요.');
return;
}
try {
const result = await confirmReports(Array.from(selectedItems));
if (result.success) {
toast.success(`${selectedItems.size}건이 확정되었습니다.`);
onClearSelection();
onRefresh();
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '확정 처리에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, []);
const handleUnconfirm = useCallback(async (
selectedItems: Set<string>,
onClearSelection: () => void,
onRefresh: () => void
) => {
if (selectedItems.size === 0) {
toast.error('확정 해제할 항목을 선택해주세요.');
return;
}
try {
const result = await unconfirmReports(Array.from(selectedItems));
if (result.success) {
toast.success(`${selectedItems.size}건의 확정이 해제되었습니다.`);
onClearSelection();
onRefresh();
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '확정 해제에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, []);
const handleDistribute = useCallback(async (
selectedItems: Set<string>,
onClearSelection: () => void,
onRefresh: () => void
) => {
if (selectedItems.size === 0) {
toast.error('배포할 항목을 선택해주세요.');
return;
}
try {
const result = await distributeReports(Array.from(selectedItems));
if (result.success) {
toast.success(`${selectedItems.size}건이 배포되었습니다.`);
onClearSelection();
onRefresh();
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '배포에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, []);
const handleOpenMemoModal = useCallback((selectedItems: Set<string>) => {
const ids = Array.from(selectedItems);
if (ids.length === 0) {
toast.error('메모를 작성할 항목을 선택해주세요.');
return;
}
setMemoSelectedIds(ids);
const totalLoc = currentData
.filter((r) => selectedItems.has(r.id))
.reduce((sum, r) => sum + r.locationCount, 0);
setMemoTotalLocations(totalLoc);
setIsMemoModalOpen(true);
}, [currentData]);
const handleMemoSubmit = useCallback(async (memo: string) => {
if (memoSelectedIds.length === 0) return;
try {
const result = await updateMemo(memoSelectedIds, memo);
if (result.success) {
toast.success(`${memoSelectedIds.length}건에 메모가 적용되었습니다.`);
setRefreshKey((k) => k + 1);
} else {
toast.error(result.error || '메모 저장에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('서버 오류가 발생했습니다.');
}
}, [memoSelectedIds]);
const handleExcelDownload = useCallback(() => {
toast.info('확정건 엑셀 다운로드 기능은 API 연동 후 활성화됩니다.');
}, []);
// ===== 통계 카드 =====
const stats: StatCard[] = useMemo(
() => [
{
label: '전체',
value: statsData.totalCount,
icon: ClipboardList,
iconColor: 'text-gray-600',
},
{
label: '확정',
value: statsData.confirmedCount,
icon: CheckCircle2,
iconColor: 'text-green-600',
},
{
label: '미확정',
value: statsData.unconfirmedCount,
icon: XCircle,
iconColor: 'text-orange-600',
},
{
label: '총 개소',
value: statsData.totalLocations,
icon: MapPin,
iconColor: 'text-blue-600',
},
],
[statsData]
);
// ===== 연도/분기 필터 슬롯 (dateRangeSelector.extraActions) =====
const quarterFilterSlot = useMemo(
() => (
<div className="flex items-center gap-2 flex-wrap order-first">
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
<SelectTrigger className="w-[100px] h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1">
{(['전체', 'Q1', 'Q2', 'Q3', 'Q4'] as const).map((q) => (
<Button
key={q}
size="sm"
variant={quarter === q ? 'default' : 'outline'}
className="h-8 px-3 text-xs"
onClick={() => handleQuarterChange(q)}
>
{q === '전체' ? '전체' : `${q.replace('Q', '')}분기`}
</Button>
))}
</div>
</div>
),
[year, quarter, yearOptions, handleQuarterChange]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<ReportListItem> = useMemo(
() => ({
title: '실적신고 목록',
description: '분기별 실적신고 및 누락체크를 관리합니다',
icon: FileText,
basePath: '/quality/performance-reports',
idField: 'id',
detailMode: 'none' as const,
// API 액션
actions: {
getList: async (params?: ListParams) => {
try {
const tab = params?.tab || 'quarterly';
if (tab === 'missed') {
const result = await getMissedReports({
page: params?.page || 1,
size: params?.pageSize || ITEMS_PER_PAGE,
q: params?.search || undefined,
});
if (result.success) {
const mapped: ReportListItem[] = result.data.map((r) => ({
id: r.id,
qualityDocNumber: r.qualityDocNumber,
siteName: r.siteName,
client: r.client,
locationCount: r.locationCount,
memo: r.memo,
inspectionCompleteDate: r.inspectionCompleteDate,
}));
return {
success: true,
data: mapped,
totalCount: result.pagination.total,
totalPages: result.pagination.lastPage,
};
}
return { success: false, error: result.error };
}
// quarterly tab
const result = await getPerformanceReports({
page: params?.page || 1,
size: params?.pageSize || ITEMS_PER_PAGE,
q: params?.search || undefined,
year,
quarter,
});
if (result.success) {
// 통계 갱신
const statsResult = await getPerformanceReportStats({ year, quarter });
if (statsResult.success && statsResult.data) {
setStatsData(statsResult.data);
}
const mapped: ReportListItem[] = result.data.map((r) => ({
id: r.id,
qualityDocNumber: r.qualityDocNumber,
siteName: r.siteName,
client: r.client,
locationCount: r.locationCount,
memo: r.memo,
createdDate: r.createdDate,
requiredInfo: r.requiredInfo,
confirmStatus: r.confirmStatus,
confirmDate: r.confirmDate,
year: r.year,
quarter: r.quarter,
}));
return {
success: true,
data: mapped,
totalCount: result.pagination.total,
totalPages: result.pagination.lastPage,
};
}
return { success: false, error: result.error };
} catch (error) {
if (isNextRedirectError(error)) throw error;
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
}
},
},
// 탭
tabs: [
{ value: 'quarterly', label: '분기별 실적신고', count: 0 },
{ value: 'missed', label: '누락체크', count: 0 },
],
defaultTab: 'quarterly',
tabsPosition: 'above-stats',
// 탭별 컬럼
columnsPerTab: {
quarterly: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'qualityDocNumber', label: '품질관리서 번호', className: 'min-w-[130px]' },
{ key: 'createdDate', label: '작성일', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'client', label: '수주처', className: 'min-w-[80px]' },
{ key: 'locationCount', label: '개소', className: 'w-[60px] text-center' },
{ key: 'requiredInfo', label: '필수정보', className: 'w-[90px] text-center' },
{ key: 'confirmStatus', label: '확정상태', className: 'w-[80px] text-center' },
{ key: 'confirmDate', label: '확정일', className: 'w-[100px]' },
{ key: 'memo', label: '메모', className: 'min-w-[120px]' },
],
missed: [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'qualityDocNumber', label: '품질관리서 번호', className: 'min-w-[130px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'client', label: '수주처', className: 'min-w-[80px]' },
{ key: 'locationCount', label: '개소', className: 'w-[60px] text-center' },
{ key: 'inspectionCompleteDate', label: '제품검사완료일', className: 'w-[120px]' },
{ key: 'memo', label: '메모', className: 'min-w-[120px]' },
],
},
columns: [], // columnsPerTab 사용
// 날짜 범위 선택기 → 연도/분기 필터
dateRangeSelector: {
enabled: true,
hideDateInputs: true,
showPresets: false,
extraActions: quarterFilterSlot,
},
// 통계 (quarterly 탭만)
stats: currentTab === 'quarterly' ? stats : undefined,
// 검색
searchPlaceholder: '품질관리서 번호, 현장명, 수주처 검색...',
clientSideFiltering: false,
itemsPerPage: ITEMS_PER_PAGE,
// 체크박스 항상 표시
showCheckbox: true,
// 누락체크 탭: 경고 배너
beforeTableContent: currentTab === 'missed' ? (
<div className="flex items-start gap-3 p-4 rounded-lg border border-amber-200 bg-amber-50">
<AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800 space-y-1">
<p className="font-medium"> </p>
<p>
.
.
</p>
</div>
</div>
) : undefined,
// 헤더 액션 (선택 기반 버튼)
headerActions: ({ selectedItems, onClearSelection, onRefresh }) => {
if (currentTab !== 'quarterly') return null;
return (
<div className="flex items-center gap-2 flex-wrap">
{selectedItems.size > 0 && (
<>
<Button size="sm" onClick={() => handleConfirm(selectedItems, onClearSelection, onRefresh)}>
<Check className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleUnconfirm(selectedItems, onClearSelection, onRefresh)}>
<Undo2 className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleDistribute(selectedItems, onClearSelection, onRefresh)}>
<Send className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleOpenMemoModal(selectedItems)}>
<Pencil className="h-4 w-4 mr-1" />
</Button>
</>
)}
<Button size="sm" variant="outline" onClick={handleExcelDownload}>
<Download className="h-4 w-4 mr-1" />
</Button>
</div>
);
},
// 데이터 변경 콜백 (메모 모달용)
onDataChange: (data) => {
setCurrentData(data);
},
// 테이블 행 렌더링
renderTableRow: (
item: ReportListItem,
_index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<ReportListItem>
) => {
// quarterly 탭 렌더링
if (item.createdDate !== undefined) {
const isRequiredMissing = item.requiredInfo && item.requiredInfo !== '입력완료';
return (
<TableRow
key={item.id}
className={handlers.isSelected ? 'bg-blue-50' : ''}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.qualityDocNumber}</TableCell>
<TableCell>{item.createdDate}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="text-center">{item.locationCount}</TableCell>
<TableCell className="text-center">
{item.requiredInfo && (
<span className={isRequiredMissing ? 'text-red-600 font-medium' : 'text-green-600'}>
{item.requiredInfo}
</span>
)}
</TableCell>
<TableCell className="text-center">
{item.confirmStatus && (
<Badge className={`text-xs ${confirmStatusColorMap[item.confirmStatus]} border-0`}>
{item.confirmStatus}
</Badge>
)}
</TableCell>
<TableCell>{item.confirmDate || '-'}</TableCell>
<TableCell className="text-muted-foreground text-sm truncate max-w-[200px]">
{item.memo || '-'}
</TableCell>
</TableRow>
);
}
// missed 탭 렌더링
return (
<TableRow
key={item.id}
className={handlers.isSelected ? 'bg-blue-50' : ''}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={handlers.isSelected}
onCheckedChange={handlers.onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.qualityDocNumber}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="text-center">{item.locationCount}</TableCell>
<TableCell>{item.inspectionCompleteDate}</TableCell>
<TableCell className="text-muted-foreground text-sm truncate max-w-[200px]">
{item.memo || '-'}
</TableCell>
</TableRow>
);
},
// 모바일 카드 렌더링
renderMobileCard: (
item: ReportListItem,
_index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<ReportListItem>
) => {
const isQuarterly = item.createdDate !== undefined;
return (
<ListMobileCard
key={item.id}
id={item.id}
isSelected={handlers.isSelected}
onToggleSelection={handlers.onToggle}
headerBadges={
<>
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
<Badge variant="outline" className="text-xs">{item.qualityDocNumber}</Badge>
</>
}
title={item.siteName}
statusBadge={
isQuarterly && item.confirmStatus ? (
<Badge className={`text-xs ${confirmStatusColorMap[item.confirmStatus]} border-0`}>
{item.confirmStatus}
</Badge>
) : undefined
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="수주처" value={item.client} />
<InfoField label="개소" value={String(item.locationCount)} />
{isQuarterly ? (
<>
<InfoField label="작성일" value={item.createdDate || '-'} />
<InfoField label="필수정보" value={item.requiredInfo || '-'} />
<InfoField label="확정상태" value={item.confirmStatus || '-'} />
<InfoField label="확정일" value={item.confirmDate || '-'} />
</>
) : (
<InfoField label="제품검사완료일" value={item.inspectionCompleteDate || '-'} />
)}
<InfoField label="메모" value={item.memo || '-'} />
</div>
}
/>
);
},
}),
[stats, quarterFilterSlot, currentTab, year, quarter, refreshKey, handleConfirm, handleUnconfirm, handleDistribute, handleOpenMemoModal, handleExcelDownload]
);
return (
<>
<UniversalListPage
config={config}
onTabChange={(tab) => setCurrentTab(tab)}
/>
{/* 메모 모달 */}
<MemoModal
isOpen={isMemoModalOpen}
onClose={() => setIsMemoModalOpen(false)}
selectedCount={memoSelectedIds.length}
totalLocations={memoTotalLocations}
onSubmit={handleMemoSubmit}
/>
</>
);
}

View File

@@ -0,0 +1,410 @@
'use server';
/**
* 실적신고관리 Server Actions
*
* API Endpoints:
* - GET /api/v1/performance-reports - 분기별 실적신고 목록
* - GET /api/v1/performance-reports/stats - 통계
* - GET /api/v1/performance-reports/missed - 누락체크 목록
* - PATCH /api/v1/performance-reports/confirm - 선택 확정
* - PATCH /api/v1/performance-reports/unconfirm - 확정 해제
* - POST /api/v1/performance-reports/distribute - 배포
* - PATCH /api/v1/performance-reports/memo - 메모 일괄 적용
*/
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type {
PerformanceReport,
PerformanceReportStats,
MissedReport,
Quarter,
} from './types';
import {
mockPerformanceReports,
mockPerformanceReportStats,
mockMissedReports,
} from './mockData';
// 개발환경 Mock 데이터 fallback 플래그
const USE_MOCK_FALLBACK = true;
// ===== 페이지네이션 =====
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
const API_BASE = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/performance-reports`;
// ===== 분기별 실적신고 목록 조회 =====
export async function getPerformanceReports(params?: {
page?: number;
size?: number;
q?: string;
year?: number;
quarter?: Quarter | '전체';
}): Promise<{
success: boolean;
data: PerformanceReport[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('per_page', String(params.size));
if (params?.q) searchParams.set('q', params.q);
if (params?.year) searchParams.set('year', String(params.year));
if (params?.quarter && params.quarter !== '전체') {
searchParams.set('quarter', params.quarter);
}
const queryString = searchParams.toString();
const url = `${API_BASE}${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response || !response.ok) {
if (USE_MOCK_FALLBACK) {
console.warn('[PerformanceReportActions] API 실패, Mock 데이터 사용');
let filtered = [...mockPerformanceReports];
if (params?.year) {
filtered = filtered.filter(i => i.year === params.year);
}
if (params?.quarter && params.quarter !== '전체') {
filtered = filtered.filter(i => i.quarter === params.quarter);
}
if (params?.q) {
const q = params.q.toLowerCase();
filtered = filtered.filter(i =>
i.siteName.toLowerCase().includes(q) ||
i.client.toLowerCase().includes(q) ||
i.qualityDocNumber.toLowerCase().includes(q)
);
}
const page = params?.page || 1;
const size = params?.size || 20;
const start = (page - 1) * size;
const paged = filtered.slice(start, start + size);
return {
success: true,
data: paged,
pagination: { currentPage: page, lastPage: Math.ceil(filtered.length / size), perPage: size, total: filtered.length },
};
}
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
return { success: false, data: [], pagination: defaultPagination, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], pagination: defaultPagination, error: result.message || '목록 조회 실패' };
}
return {
success: true,
data: result.data?.items || [],
pagination: {
currentPage: result.data?.current_page || 1,
lastPage: result.data?.last_page || 1,
perPage: result.data?.per_page || 20,
total: result.data?.total || 0,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] getPerformanceReports error:', error);
if (USE_MOCK_FALLBACK) {
return {
success: true,
data: mockPerformanceReports,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockPerformanceReports.length },
};
}
return { success: false, data: [], pagination: defaultPagination, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 통계 조회 =====
export async function getPerformanceReportStats(params?: {
year?: number;
quarter?: Quarter | '전체';
}): Promise<{
success: boolean;
data?: PerformanceReportStats;
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
if (params?.year) searchParams.set('year', String(params.year));
if (params?.quarter && params.quarter !== '전체') {
searchParams.set('quarter', params.quarter);
}
const queryString = searchParams.toString();
const url = `${API_BASE}/stats${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response || !response.ok) {
if (USE_MOCK_FALLBACK) {
console.warn('[PerformanceReportActions] Stats API 실패, Mock 데이터 사용');
return { success: true, data: mockPerformanceReportStats };
}
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
return { success: false, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
}
const result = await response.json();
if (!result.success) {
return { success: false, error: result.message || '통계 조회 실패' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] getPerformanceReportStats error:', error);
if (USE_MOCK_FALLBACK) return { success: true, data: mockPerformanceReportStats };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 누락체크 목록 조회 =====
export async function getMissedReports(params?: {
page?: number;
size?: number;
q?: string;
}): Promise<{
success: boolean;
data: MissedReport[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
const defaultPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
try {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.size) searchParams.set('per_page', String(params.size));
if (params?.q) searchParams.set('q', params.q);
const queryString = searchParams.toString();
const url = `${API_BASE}/missed${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response || !response.ok) {
if (USE_MOCK_FALLBACK) {
console.warn('[PerformanceReportActions] Missed API 실패, Mock 데이터 사용');
let filtered = [...mockMissedReports];
if (params?.q) {
const q = params.q.toLowerCase();
filtered = filtered.filter(i =>
i.siteName.toLowerCase().includes(q) ||
i.client.toLowerCase().includes(q) ||
i.qualityDocNumber.toLowerCase().includes(q)
);
}
const page = params?.page || 1;
const size = params?.size || 20;
const start = (page - 1) * size;
const paged = filtered.slice(start, start + size);
return {
success: true,
data: paged,
pagination: { currentPage: page, lastPage: Math.ceil(filtered.length / size), perPage: size, total: filtered.length },
};
}
const errMsg = error ? error.message : `API 오류: ${response?.status || 'no response'}`;
return { success: false, data: [], pagination: defaultPagination, error: errMsg, __authError: error?.code === 'UNAUTHORIZED' };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], pagination: defaultPagination, error: result.message || '누락체크 조회 실패' };
}
return {
success: true,
data: result.data?.items || [],
pagination: {
currentPage: result.data?.current_page || 1,
lastPage: result.data?.last_page || 1,
perPage: result.data?.per_page || 20,
total: result.data?.total || 0,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] getMissedReports error:', error);
if (USE_MOCK_FALLBACK) {
return {
success: true,
data: mockMissedReports,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockMissedReports.length },
};
}
return { success: false, data: [], pagination: defaultPagination, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 선택 확정 =====
export async function confirmReports(ids: string[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/confirm`, {
method: 'PATCH',
body: JSON.stringify({ ids }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '확정 처리에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '확정 처리에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] confirmReports error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 확정 해제 =====
export async function unconfirmReports(ids: string[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/unconfirm`, {
method: 'PATCH',
body: JSON.stringify({ ids }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '확정 해제에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '확정 해제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] unconfirmReports error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 배포 =====
export async function distributeReports(ids: string[]): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/distribute`, {
method: 'POST',
body: JSON.stringify({ ids }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '배포에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '배포에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] distributeReports error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 메모 일괄 적용 =====
export async function updateMemo(ids: string[], memo: string): Promise<{
success: boolean;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(`${API_BASE}/memo`, {
method: 'PATCH',
body: JSON.stringify({ ids, memo }),
});
if (error) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '메모 저장에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: result.message || '메모 저장에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PerformanceReportActions] updateMemo error:', error);
if (USE_MOCK_FALLBACK) return { success: true };
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,19 @@
/**
* 실적신고관리 컴포넌트 및 타입 export
*/
export * from './types';
export * from './mockData';
export { PerformanceReportList } from './PerformanceReportList';
export { MemoModal } from './MemoModal';
// Server Actions (API 연동)
export {
getPerformanceReports,
getPerformanceReportStats,
getMissedReports,
confirmReports,
unconfirmReports,
distributeReports,
updateMemo,
} from './actions';

View File

@@ -0,0 +1,195 @@
// 실적신고관리 Mock 데이터
import type {
PerformanceReport,
MissedReport,
PerformanceReportStats,
ConfirmStatus,
} from './types';
// ===== 상태별 색상 매핑 =====
export const confirmStatusColorMap: Record<ConfirmStatus, string> = {
: 'bg-green-100 text-green-800',
: 'bg-gray-100 text-gray-800',
};
// ===== Mock 데이터 - 분기별 실적신고 =====
export const mockPerformanceReports: PerformanceReport[] = [
{
id: '1',
qualityDocNumber: 'QD-2026-001',
createdDate: '2026-01-05',
siteName: '강남 센트럴 파크',
client: '삼성물산',
locationCount: 45,
requiredInfo: '입력완료',
confirmStatus: '확정',
confirmDate: '2026-01-10',
memo: '3차 검사 완료',
year: 2026,
quarter: 'Q1',
},
{
id: '2',
qualityDocNumber: 'QD-2026-002',
createdDate: '2026-01-08',
siteName: '서초 리버사이드',
client: '현대건설',
locationCount: 32,
requiredInfo: '입력완료',
confirmStatus: '확정',
confirmDate: '2026-01-12',
memo: '',
year: 2026,
quarter: 'Q1',
},
{
id: '3',
qualityDocNumber: 'QD-2026-003',
createdDate: '2026-01-12',
siteName: '판교 테크노밸리 2단지',
client: '대우건설',
locationCount: 78,
requiredInfo: '2건 누락',
confirmStatus: '미확정',
confirmDate: '',
memo: '추가 검사 필요',
year: 2026,
quarter: 'Q1',
},
{
id: '4',
qualityDocNumber: 'QD-2026-004',
createdDate: '2026-01-15',
siteName: '용산 파크타워',
client: 'GS건설',
locationCount: 56,
requiredInfo: '1건 누락',
confirmStatus: '미확정',
confirmDate: '',
memo: '',
year: 2026,
quarter: 'Q1',
},
{
id: '5',
qualityDocNumber: 'QD-2026-005',
createdDate: '2026-01-20',
siteName: '마포 리버뷰',
client: '포스코건설',
locationCount: 23,
requiredInfo: '입력완료',
confirmStatus: '확정',
confirmDate: '2026-01-25',
memo: '최종 검사 완료',
year: 2026,
quarter: 'Q1',
},
{
id: '6',
qualityDocNumber: 'QD-2026-006',
createdDate: '2026-01-25',
siteName: '송파 헬리오시티',
client: '롯데건설',
locationCount: 91,
requiredInfo: '미입력',
confirmStatus: '미확정',
confirmDate: '',
memo: '',
year: 2026,
quarter: 'Q1',
},
{
id: '7',
qualityDocNumber: 'QD-2026-007',
createdDate: '2026-01-28',
siteName: '잠실 엘스',
client: '삼성물산',
locationCount: 67,
requiredInfo: '입력완료',
confirmStatus: '미확정',
confirmDate: '',
memo: '공사 일시 중단',
year: 2026,
quarter: 'Q1',
},
];
// ===== Mock 데이터 - 누락체크 =====
export const mockMissedReports: MissedReport[] = [
{
id: 'm1',
qualityDocNumber: 'QD-2025-089',
siteName: '강동 그린파크',
client: '현대건설',
locationCount: 34,
inspectionCompleteDate: '2025-12-15',
memo: '',
},
{
id: 'm2',
qualityDocNumber: 'QD-2025-092',
siteName: '성북 힐스테이트',
client: 'GS건설',
locationCount: 28,
inspectionCompleteDate: '2025-12-20',
memo: '확인 필요',
},
{
id: 'm3',
qualityDocNumber: 'QD-2025-095',
siteName: '노원 래미안',
client: '삼성물산',
locationCount: 52,
inspectionCompleteDate: '2025-12-28',
memo: '',
},
{
id: 'm4',
qualityDocNumber: 'QD-2026-008',
siteName: '동작 자이',
client: 'GS건설',
locationCount: 19,
inspectionCompleteDate: '2026-01-05',
memo: '',
},
{
id: 'm5',
qualityDocNumber: 'QD-2026-009',
siteName: '관악 e편한세상',
client: '대림건설',
locationCount: 41,
inspectionCompleteDate: '2026-01-10',
memo: '서류 미비',
},
{
id: 'm6',
qualityDocNumber: 'QD-2026-010',
siteName: '은평 뉴타운',
client: '대우건설',
locationCount: 63,
inspectionCompleteDate: '2026-01-18',
memo: '',
},
{
id: 'm7',
qualityDocNumber: 'QD-2026-011',
siteName: '광진 현대프리미엄',
client: '현대건설',
locationCount: 37,
inspectionCompleteDate: '2026-01-22',
memo: '',
},
];
// ===== Mock 통계 =====
export const mockPerformanceReportStats: PerformanceReportStats = {
totalCount: 7,
confirmedCount: 3,
unconfirmedCount: 4,
totalLocations: 392,
};

View File

@@ -0,0 +1,67 @@
// 실적신고관리 타입 정의
// ===== 기본 열거 타입 =====
// 분기
export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
// 확정 상태
export type ConfirmStatus = '확정' | '미확정';
// ===== 메인 데이터 =====
// 분기별 실적신고 항목
export interface PerformanceReport {
id: string;
qualityDocNumber: string; // 품질관리서 번호
createdDate: string; // 작성일
siteName: string; // 현장명
client: string; // 수주처
locationCount: number; // 개소
requiredInfo: string; // 필수정보
confirmStatus: ConfirmStatus; // 확정 상태
confirmDate: string; // 확정일
memo: string; // 메모
year: number; // 연도
quarter: Quarter; // 분기
}
// 누락체크 항목
export interface MissedReport {
id: string;
qualityDocNumber: string; // 품질관리서 번호
siteName: string; // 현장명
client: string; // 수주처
locationCount: number; // 개소
inspectionCompleteDate: string; // 제품검사완료일
memo: string; // 메모
}
// ===== 통계 =====
export interface PerformanceReportStats {
totalCount: number; // 전체
confirmedCount: number; // 확정
unconfirmedCount: number; // 미확정
totalLocations: number; // 총 개소
}
// ===== 리스트 통합 아이템 (UniversalListPage 용) =====
export interface ReportListItem {
id: string;
qualityDocNumber: string;
siteName: string;
client: string;
locationCount: number;
memo: string;
// 분기별 실적신고 전용
createdDate?: string;
requiredInfo?: string;
confirmStatus?: ConfirmStatus;
confirmDate?: string;
year?: number;
quarter?: Quarter;
// 누락체크 전용
inspectionCompleteDate?: string;
}