feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 || ''}
|
||||
|
||||
@@ -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행)
|
||||
|
||||
@@ -211,6 +211,7 @@ export interface ReportInspectionItem {
|
||||
freqSpan?: number; // 검사주기 셀 rowSpan (크로스그룹 병합)
|
||||
judgmentSpan?: number; // 판정 셀 rowSpan (크로스그룹 병합, 예: 항목6+7+8+9)
|
||||
hideJudgment?: boolean; // 판정 표시 안함 (빈 셀 렌더)
|
||||
editable?: boolean; // 측정값 입력 가능 여부
|
||||
}
|
||||
|
||||
// 제품검사성적서
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
410
src/components/quality/PerformanceReportManagement/actions.ts
Normal file
410
src/components/quality/PerformanceReportManagement/actions.ts
Normal 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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
19
src/components/quality/PerformanceReportManagement/index.ts
Normal file
19
src/components/quality/PerformanceReportManagement/index.ts
Normal 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';
|
||||
195
src/components/quality/PerformanceReportManagement/mockData.ts
Normal file
195
src/components/quality/PerformanceReportManagement/mockData.ts
Normal 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,
|
||||
};
|
||||
67
src/components/quality/PerformanceReportManagement/types.ts
Normal file
67
src/components/quality/PerformanceReportManagement/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user