feat(WEB): 생산 현황판 공정 기반 동적 탭 전환

- 하드코딩된 공장 탭을 공정 마스터 데이터 기반 동적 탭으로 변경
- getProcessOptions() API 함수 추가 (/api/v1/processes/options)
- ProcessOption 타입 및 동적 TabOption 타입 추가
- WorkOrder 타입에 processCode, processName 필드 추가
- 탭 선택 시 process_code로 서버 사이드 필터링

변경 전: 전체/스크린공장/슬랫공장/절곡공장 (하드코딩)
변경 후: 전체 + 공정 마스터 데이터 기반 동적 탭
This commit is contained in:
2026-01-15 20:35:28 +09:00
parent e998cfa2f8
commit 07828b63f2
4 changed files with 149 additions and 42 deletions

Binary file not shown.

View File

@@ -2,6 +2,7 @@
* 생산 현황판 서버 액션
* API 연동 완료 (2025-12-26)
* serverFetch 마이그레이션 (2025-12-30)
* 공정 기반 동적 탭 전환 (2025-01-15)
*/
'use server';
@@ -9,14 +10,19 @@
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { WorkOrder, WorkerStatus, ProcessType, DashboardStats } from './types';
import type { WorkOrder, WorkerStatus, DashboardStats, ProcessOption } from './types';
// ===== API 타입 =====
interface WorkOrderApiItem {
id: number;
work_order_no: string;
project_name: string | null;
process_type: 'screen' | 'slat' | 'bending';
process_id: number | null;
process?: {
id: number;
process_code: string;
process_name: string;
};
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
scheduled_date: string | null;
memo: string | null;
@@ -67,7 +73,8 @@ function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
id: String(api.id),
orderNo: api.work_order_no,
productName,
process: api.process_type,
processCode: api.process?.process_code || '-',
processName: api.process?.process_name || '-',
client: api.sales_order?.client?.name || '-',
projectName: api.project_name || '-',
assignees: api.assignee ? [api.assignee.name] : [],
@@ -83,8 +90,78 @@ function transformToProductionFormat(api: WorkOrderApiItem): WorkOrder {
};
}
// ===== 공정 옵션 목록 조회 =====
export async function getProcessOptions(): Promise<{
success: boolean;
data: ProcessOption[];
error?: string;
}> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/options`;
console.log('[ProductionDashboardActions] GET processes:', url);
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error || !response) {
console.warn('[ProductionDashboardActions] GET processes error:', error?.message);
return {
success: false,
data: [],
error: error?.message || '공정 목록 조회에 실패했습니다.',
};
}
if (!response.ok) {
console.warn('[ProductionDashboardActions] GET processes error:', response.status);
return {
success: false,
data: [],
error: `API 오류: ${response.status}`,
};
}
const result = await response.json();
if (!result.success) {
return {
success: false,
data: [],
error: result.message || '공정 목록 조회에 실패했습니다.',
};
}
// API 응답: { success, data: [{ id, process_code, process_name, process_type, department }] }
const processes: ProcessOption[] = (result.data || []).map((p: {
id: number;
process_code: string;
process_name: string;
process_type?: string;
department?: string;
}) => ({
id: p.id,
code: p.process_code,
name: p.process_name,
type: p.process_type,
department: p.department,
}));
return {
success: true,
data: processes,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ProductionDashboardActions] getProcessOptions error:', error);
return {
success: false,
data: [],
error: '서버 오류가 발생했습니다.',
};
}
}
// ===== 대시보드 데이터 조회 =====
export async function getDashboardData(processType?: ProcessType): Promise<{
export async function getDashboardData(processCode?: string): Promise<{
success: boolean;
workOrders: WorkOrder[];
workerStatus: WorkerStatus[];
@@ -101,8 +178,8 @@ export async function getDashboardData(processType?: ProcessType): Promise<{
try {
// 작업지시 목록 조회
const params = new URLSearchParams({ per_page: '100' });
if (processType && processType !== 'all') {
params.set('process_type', processType);
if (processCode && processCode !== 'all') {
params.set('process_code', processCode);
}
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?${params.toString()}`;

View File

@@ -20,16 +20,18 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import { toast } from 'sonner';
import { getDashboardData } from './actions';
import type { WorkOrder, WorkerStatus, ProcessType, DashboardStats } from './types';
import { getDashboardData, getProcessOptions } from './actions';
import type { WorkOrder, WorkerStatus, DashboardStats, ProcessOption, TabOption } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { TAB_OPTIONS, PROCESS_LABELS, STATUS_LABELS } from './types';
import { STATUS_LABELS } from './types';
export default function ProductionDashboard() {
const router = useRouter();
// ===== 상태 관리 =====
const [selectedTab, setSelectedTab] = useState<ProcessType | 'all'>('all');
const [selectedTab, setSelectedTab] = useState<string>('all');
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
const [tabOptions, setTabOptions] = useState<TabOption[]>([{ value: 'all', label: '전체' }]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workerStatus, setWorkerStatus] = useState<WorkerStatus[]>([]);
const [stats, setStats] = useState<DashboardStats>({
@@ -41,13 +43,45 @@ export default function ProductionDashboard() {
delayed: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
// ===== 데이터 로드 =====
// ===== 공정 목록 로드 (최초 1회) =====
useEffect(() => {
const loadProcesses = async () => {
setIsLoadingProcesses(true);
try {
const result = await getProcessOptions();
if (result.success) {
setProcessOptions(result.data);
// 동적 탭 옵션 생성: 전체 + 공정 목록
const dynamicTabs: TabOption[] = [
{ value: 'all', label: '전체' },
...result.data.map((p) => ({
value: p.code,
label: p.name,
})),
];
setTabOptions(dynamicTabs);
} else {
toast.error(result.error || '공정 목록 조회에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ProductionDashboard] loadProcesses error:', error);
toast.error('공정 목록 로드 중 오류가 발생했습니다.');
} finally {
setIsLoadingProcesses(false);
}
};
loadProcesses();
}, []);
// ===== 대시보드 데이터 로드 =====
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const processType = selectedTab === 'all' ? undefined : selectedTab;
const result = await getDashboardData(processType);
const processCode = selectedTab === 'all' ? undefined : selectedTab;
const result = await getDashboardData(processCode);
if (result.success) {
setWorkOrders(result.workOrders);
@@ -121,14 +155,18 @@ export default function ProductionDashboard() {
</div>
</div>
{/* */}
<Tabs value={selectedTab} onValueChange={(v) => setSelectedTab(v as ProcessType | 'all')}>
{/* 공정별 탭 (동적 생성) */}
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
<TabsList>
{TAB_OPTIONS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="px-6">
{tab.label}
</TabsTrigger>
))}
{isLoadingProcesses ? (
<TabsTrigger value="all" className="px-6"></TabsTrigger>
) : (
tabOptions.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="px-6">
{tab.label}
</TabsTrigger>
))
)}
</TabsList>
</Tabs>
@@ -320,7 +358,7 @@ function WorkOrderCard({ order, onClick, showDelay }: WorkOrderCardProps) {
</div>
<div className="text-right shrink-0">
<Badge variant="outline" className="text-xs">
{PROCESS_LABELS[order.process]}
{order.processName}
</Badge>
{showDelay && order.delayDays && (
<p className="text-xs text-orange-600 mt-1">+{order.delayDays} </p>

View File

@@ -1,15 +1,22 @@
// 작업 지시 상태
export type WorkOrderStatus = 'waiting' | 'inProgress' | 'completed';
// 공정 타입
export type ProcessType = 'screen' | 'slat' | 'bending' | 'all';
// 공정 옵션 (API에서 동적으로 조회)
export interface ProcessOption {
id: number;
code: string; // process_code (P-001, P-002, ...)
name: string; // process_name (슬랫, 스크린, 절곡, ...)
type?: string; // process_type (생산, 검수, ...)
department?: string; // department (경영본부, 개발팀, ...)
}
// 작업 지시
export interface WorkOrder {
id: string;
orderNo: string; // KD-WO-251216-01
productName: string; // 스크린 서터 (표준형) - 추가
process: ProcessType; // 스크린, 슬랫, 절곡
processCode: string; // 공정 코드 (P-001, P-002, ...)
processName: string; // 공정명 (슬랫, 스크린, 절곡, ...)
client: string; // 삼성물산(주)
projectName: string; // 강남 타워 신축현장
assignees: string[]; // 담당자 배열
@@ -43,27 +50,12 @@ export interface DashboardStats {
delayed: number; // 지연
}
// 탭 옵션
// 탭 옵션 (동적 생성용)
export interface TabOption {
value: ProcessType | 'all';
label: string;
value: string; // 'all' 또는 process_code
label: string; // '전체' 또는 process_name
}
export const TAB_OPTIONS: TabOption[] = [
{ value: 'all', label: '전체' },
{ value: 'screen', label: '스크린공장' },
{ value: 'slat', label: '슬랫공장' },
{ value: 'bending', label: '절곡공장' },
];
// 공정 타입 라벨
export const PROCESS_LABELS: Record<ProcessType, string> = {
screen: '스크린',
slat: '슬랫',
bending: '절곡',
all: '전체',
};
// 상태 라벨
export const STATUS_LABELS: Record<WorkOrderStatus, string> = {
waiting: '대기',