deploy: 2026-03-11 배포

- feat: MNG→SAM 자동 로그인 (auto-login 페이지, token-login API 프록시, auth-config)
- feat: QMS 품질감사 API 연동 (actions, hooks, Day1/Day2 컴포넌트 개선)
- feat: 공지 팝업 모달 (NoticePopupContainer, PopupManagement 설정 개선)
- feat: CEO 대시보드 캘린더 섹션 개선
- fix: 게시판 폼, 로그인 페이지, 작업자 화면, 청구서 관리 수정
- chore: AuthenticatedLayout, logout, userStorage 정리
This commit is contained in:
2026-03-11 02:06:51 +09:00
parent 2f00eac0f0
commit e871f6232f
32 changed files with 1588 additions and 568 deletions

View File

@@ -17,7 +17,7 @@ import { useDateRange } from '@/hooks';
import {
FileText,
Plus,
Save,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -51,6 +51,8 @@ import {
BILL_TYPE_FILTER_OPTIONS,
BILL_STATUS_COLORS,
BILL_STATUS_FILTER_OPTIONS,
RECEIVED_BILL_STATUS_OPTIONS,
ISSUED_BILL_STATUS_OPTIONS,
getBillStatusLabel,
} from './types';
import { getBills, deleteBill, updateBillStatus } from './actions';
@@ -84,6 +86,7 @@ export function BillManagementClient({
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [targetStatus, setTargetStatus] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const itemsPerPage = initialPagination.perPage;
@@ -262,15 +265,15 @@ export function BillManagementClient({
];
}, [data]);
// ===== 저장 핸들러 =====
const handleSave = useCallback(async () => {
// ===== 상태 변경 핸들러 =====
const handleStatusChange = useCallback(async () => {
if (selectedItems.size === 0) {
toast.warning('선택된 항목이 없습니다.');
return;
}
if (statusFilter === 'all') {
toast.warning('상태를 선택해주세요.');
if (!targetStatus) {
toast.warning('변경할 상태를 선택해주세요.');
return;
}
@@ -278,7 +281,7 @@ export function BillManagementClient({
let successCount = 0;
for (const id of selectedItems) {
const result = await updateBillStatus(id, statusFilter as BillStatus);
const result = await updateBillStatus(id, targetStatus as BillStatus);
if (result.success) {
successCount++;
}
@@ -286,14 +289,20 @@ export function BillManagementClient({
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}이 저장되었습니다.`);
toast.success(`${successCount}의 상태가 변경되었습니다.`);
loadData(currentPage);
setSelectedItems(new Set());
setTargetStatus('');
} else {
toast.error('저장에 실패했습니다.');
toast.error('상태 변경에 실패했습니다.');
}
setIsLoading(false);
}, [selectedItems, statusFilter, loadData, currentPage]);
}, [selectedItems, targetStatus, loadData, currentPage]);
// 구분에 따른 상태 옵션
const statusChangeOptions = useMemo(() => {
return billTypeFilter === 'issued' ? ISSUED_BILL_STATUS_OPTIONS : RECEIVED_BILL_STATUS_OPTIONS;
}, [billTypeFilter]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<BillRecord> = useMemo(
@@ -377,12 +386,30 @@ export function BillManagementClient({
icon: Plus,
},
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
headerActions: () => (
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-1" />
</Button>
// 선택 시 상태 변경 액션
selectionActions: () => (
<div className="flex items-center gap-2">
<Select value={targetStatus} onValueChange={setTargetStatus}>
<SelectTrigger className="min-w-[130px] w-auto h-8">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{statusChangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={handleStatusChange}
disabled={!targetStatus || isLoading}
>
<RefreshCw className="h-4 w-4 mr-1" />
</Button>
</div>
),
// 테이블 헤더 액션 (필터)
@@ -447,7 +474,9 @@ export function BillManagementClient({
router,
loadData,
currentPage,
handleSave,
handleStatusChange,
statusChangeOptions,
targetStatus,
renderTableRow,
renderMobileCard,
]

View File

@@ -119,12 +119,20 @@ export function LoginPage() {
name: data.user?.name || userId,
position: data.roles?.[0]?.description || '사용자',
userId: userId,
department: data.user?.department || null,
department_id: data.user?.department_id || null,
menu: transformedMenus, // 변환된 메뉴 구조 저장
roles: data.roles || [],
tenant: data.tenant || {},
};
localStorage.setItem('user', JSON.stringify(userData));
// 유저별 persist store를 새 유저 키로 rehydrate
const { useFavoritesStore } = await import('@/stores/favoritesStore');
const { useTableColumnStore } = await import('@/stores/useTableColumnStore');
useFavoritesStore.persist.rehydrate();
useTableColumnStore.persist.rehydrate();
// 메뉴 폴링 재시작 플래그 설정 (세션 만료 후 재로그인 시)
sessionStorage.setItem('auth_just_logged_in', 'true');

View File

@@ -61,13 +61,14 @@ interface BoardFormProps {
initialData?: Post;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
department: '개발팀',
position: '과장',
};
// 로그인 사용자 이름을 가져오는 헬퍼
function getLoggedInUserName(): string {
if (typeof window === 'undefined') return '';
try {
const userDataStr = localStorage.getItem('user');
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
} catch { return ''; }
}
// 상단 고정 최대 개수
const MAX_PINNED_COUNT = 5;
@@ -75,6 +76,12 @@ const MAX_PINNED_COUNT = 5;
export function BoardForm({ mode, initialData }: BoardFormProps) {
const router = useRouter();
// 로그인 사용자 이름
const [currentUserName, setCurrentUserName] = useState('');
useEffect(() => {
setCurrentUserName(getLoggedInUserName());
}, []);
// ===== 폼 상태 =====
const [boardCode, setBoardCode] = useState(initialData?.boardCode || '');
const [isPinned, setIsPinned] = useState(initialData?.isPinned ? 'true' : 'false');
@@ -330,7 +337,7 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
<div className="space-y-2">
<Label></Label>
<Input
value={CURRENT_USER.name}
value={currentUserName}
disabled
className="bg-gray-50"
/>

View File

@@ -117,8 +117,14 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
}));
};
// 작성자 (현재 로그인한 사용자 - mock)
const currentUser = '홍길동';
// 작성자 (로그인한 사용자)
const [currentUser, setCurrentUser] = useState('');
useEffect(() => {
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
if (userDataStr) {
try { setCurrentUser(JSON.parse(userDataStr).name || ''); } catch { /* ignore */ }
}
}, []);
// 등록일시
const registeredAt = mode === 'edit' && board ? formatDateTime(board.createdAt) : getCurrentDateTime();

View File

@@ -38,17 +38,35 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
schedule: 'blue',
order: 'green',
construction: 'purple',
bill: 'amber',
expected_expense: 'rose',
delivery: 'cyan',
shipment: 'teal',
issue: 'red',
other: 'gray',
holiday: 'red',
tax: 'orange',
};
// 일정 타입별 상세 페이지 라우트
const SCHEDULE_TYPE_ROUTES: Record<string, string> = {
bill: '/accounting/bills',
order: '/production/work-orders',
construction: '/construction/project/contract',
expected_expense: '/accounting/expected-expenses',
delivery: '/sales/order-management-sales',
shipment: '/outbound/shipments',
};
// 일정 타입별 라벨
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
order: '생산',
construction: '시공',
schedule: '일정',
bill: '어음',
expected_expense: '결제예정',
delivery: '납기',
shipment: '출고',
other: '기타',
};
@@ -57,6 +75,10 @@ const SCHEDULE_TYPE_BADGE_COLORS: Record<string, string> = {
order: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
construction: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
bill: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
expected_expense: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
delivery: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300',
shipment: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
@@ -88,6 +110,10 @@ const TASK_FILTER_OPTIONS: { value: ExtendedTaskFilterType; label: string }[] =
{ value: 'schedule', label: '일정' },
{ value: 'order', label: '발주' },
{ value: 'construction', label: '시공' },
{ value: 'bill', label: '어음' },
{ value: 'expected_expense', label: '결제예정' },
{ value: 'delivery', label: '납기' },
{ value: 'shipment', label: '출고' },
{ value: 'issue', label: '이슈' },
];
@@ -245,6 +271,19 @@ export function CalendarSection({
return parts.join(' | ');
};
// 일정 타입별 상세 페이지 링크 생성 (bill_123 → /ko/accounting/bills/123)
const getScheduleLink = (schedule: CalendarScheduleItem): string | null => {
const basePath = SCHEDULE_TYPE_ROUTES[schedule.type];
if (!basePath) return null;
// expected_expense는 목록 페이지만 존재 (상세 페이지 없음)
if (schedule.type === 'expected_expense') {
return `/ko${basePath}`;
}
const numericId = schedule.id.split('_').pop();
if (!numericId) return null;
return `/ko${basePath}/${numericId}`;
};
const handleDateClick = (date: Date) => {
setSelectedDate(date);
};
@@ -461,11 +500,18 @@ export function CalendarSection({
schedule: 'bg-blue-500',
order: 'bg-green-500',
construction: 'bg-purple-500',
bill: 'bg-amber-500',
expected_expense: 'bg-rose-500',
delivery: 'bg-cyan-500',
shipment: 'bg-teal-500',
issue: 'bg-red-400',
};
const dotColor = colorMap[evType] || 'bg-gray-400';
const title = evData?.name as string || evData?.title as string || ev.title;
const cleanTitle = title?.replace(/^[🔴🟠]\s*/, '') || '';
const mobileScheduleLink = isSelected && evType !== 'holiday' && evType !== 'tax' && evType !== 'issue'
? getScheduleLink(evData as unknown as CalendarScheduleItem)
: null;
return (
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
@@ -474,7 +520,18 @@ export function CalendarSection({
{SCHEDULE_TYPE_LABELS[evType] || ''}
</span>
)}
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
<span className={`${isSelected ? '' : 'truncate'} flex-1`}>{cleanTitle}</span>
{mobileScheduleLink && (
<span
className="flex items-center gap-0.5 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer shrink-0"
onClick={(e) => {
e.stopPropagation();
router.push(mobileScheduleLink);
}}
>
<ExternalLink className="h-3 w-3" />
</span>
)}
</div>
);
})}
@@ -569,21 +626,38 @@ export function CalendarSection({
);
})}
{selectedDateItems.schedules.map((schedule) => (
<div
key={schedule.id}
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onScheduleClick?.(schedule)}
>
<div className="flex items-start gap-2 mb-1">
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
</Badge>
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
{selectedDateItems.schedules.map((schedule) => {
const scheduleLink = getScheduleLink(schedule);
return (
<div
key={schedule.id}
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onScheduleClick?.(schedule)}
>
<div className="flex items-start gap-2 mb-1">
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
</Badge>
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{formatScheduleDetail(schedule)}</span>
{scheduleLink && (
<span
className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer shrink-0 ml-2"
onClick={(e) => {
e.stopPropagation();
router.push(scheduleLink);
}}
>
<ExternalLink className="h-3 w-3" />
</span>
)}
</div>
</div>
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
</div>
))}
);
})}
{selectedDateItems.issues.map((issue) => (
<div

View File

@@ -161,7 +161,8 @@ export interface CalendarScheduleItem {
startTime?: string; // "09:00"
endTime?: string; // "12:00"
isAllDay?: boolean;
type: 'schedule' | 'order' | 'construction' | 'other'; // 일정, 발주, 시공
type: 'schedule' | 'order' | 'construction' | 'other' | 'bill'
| 'expected_expense' | 'delivery' | 'shipment'; // 일정, 발주, 시공, 어음, 결제예정, 납기, 출고
department?: string; // 부서명
personName?: string; // 담당자명
color?: string;
@@ -174,7 +175,8 @@ export type CalendarViewType = 'week' | 'month';
export type CalendarDeptFilterType = 'all' | 'department' | 'personal';
// 캘린더 업무 필터 타입
export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'construction';
export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'construction' | 'bill'
| 'expected_expense' | 'delivery' | 'shipment';
// ===== 매출 현황 데이터 =====
export interface SalesMonthlyTrend {

View File

@@ -0,0 +1,92 @@
'use client';
import { useEffect, useState } from 'react';
import { NoticePopupModal, isPopupDismissedForToday } from './NoticePopupModal';
import { getActivePopups } from './actions';
import type { NoticePopupData } from './NoticePopupModal';
import type { Popup } from '@/components/settings/PopupManagement/types';
/**
* 활성 팝업을 자동으로 가져와 순차적으로 표시하는 컨테이너
* - AuthenticatedLayout에 마운트
* - 오늘 하루 안 보기 처리된 팝업은 건너뜀
* - 여러 개일 경우 하나 닫으면 다음 팝업 표시
*/
export default function NoticePopupContainer() {
const [popups, setPopups] = useState<NoticePopupData[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [open, setOpen] = useState(false);
useEffect(() => {
let cancelled = false;
async function fetchPopups() {
try {
// localStorage에서 사용자 부서 ID 조회 (부서별 팝업 필터링용)
const user = JSON.parse(localStorage.getItem('user') || '{}');
const activePopups = await getActivePopups(user.department_id ?? undefined);
if (cancelled) return;
// 날짜 범위 + 오늘 하루 안 보기 필터링
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const visiblePopups = activePopups
.filter((p) => {
// 기간 내 팝업만 (startDate~endDate)
if (p.startDate && today < p.startDate) return false;
if (p.endDate && today > p.endDate) return false;
// 오늘 하루 안 보기 처리된 팝업 제외
if (isPopupDismissedForToday(p.id)) return false;
return true;
})
.map((p) => ({
id: p.id,
title: p.title,
content: p.content,
}));
if (visiblePopups.length > 0) {
setPopups(visiblePopups);
setCurrentIndex(0);
setOpen(true);
}
} catch {
// 팝업 로드 실패 시 무시 (핵심 기능 아님)
}
}
fetchPopups();
return () => {
cancelled = true;
};
}, []);
const currentPopup = popups[currentIndex];
if (!currentPopup) return null;
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
// 다음 팝업이 있으면 표시
const nextIndex = currentIndex + 1;
if (nextIndex < popups.length) {
setCurrentIndex(nextIndex);
// 약간의 딜레이로 자연스러운 전환
setTimeout(() => setOpen(true), 200);
} else {
setOpen(false);
}
} else {
setOpen(true);
}
};
return (
<NoticePopupModal
popup={currentPopup}
open={open}
onOpenChange={handleOpenChange}
/>
);
}

View File

@@ -0,0 +1,31 @@
'use server';
/**
* 공지 팝업 서버 액션
*
* API Endpoints:
* - GET /api/v1/popups/active - 사용자용 활성 팝업 조회 (날짜+부서 필터 백엔드 처리)
*/
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import { type PopupApiData, transformApiToFrontend } from '@/components/settings/PopupManagement/utils';
import type { Popup } from '@/components/settings/PopupManagement/types';
/**
* 활성 팝업 목록 조회 (사용자용)
* - 백엔드 scopeActive(): status=active + 날짜 범위 내
* - 백엔드 scopeForUser(): 전사 OR 사용자 부서
* @param departmentId - 사용자 소속 부서 ID (부서별 팝업 필터용)
*/
export async function getActivePopups(departmentId?: number): Promise<Popup[]> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/popups/active', {
department_id: departmentId,
}),
transform: (data: PopupApiData[]) => data.map(transformApiToFrontend),
errorMessage: '활성 팝업 조회에 실패했습니다.',
});
return result.success ? (result.data ?? []) : [];
}

View File

@@ -82,173 +82,6 @@ const InspectionReportModal = dynamic(
() => import('../WorkOrders/documents').then(mod => ({ default: mod.InspectionReportModal })),
);
// ===== 목업 데이터 =====
const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
screen: [
{
id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'screen',
cuttingInfo: { width: 1210, sheets: 8 },
steps: [
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
{ id: 's1-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
{ id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' },
],
},
{
id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03',
width: 6400, height: 5200, quantity: 4, processType: 'screen',
cuttingInfo: { width: 1600, sheets: 4 },
steps: [
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
{ id: 's2-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
{ id: 's2-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
{
id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05',
width: 12000, height: 4500, quantity: 1, processType: 'screen',
cuttingInfo: { width: 2400, sheets: 5 },
steps: [
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
{ id: 's3-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
{ id: 's3-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
],
},
],
slat: [
{
id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01',
width: 8260, height: 8350, quantity: 2, processType: 'slat',
slatInfo: { length: 3910, slatCount: 40, jointBar: 4, glassQty: 2 },
steps: [
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
{ id: 'l1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
],
},
{
id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02',
width: 10500, height: 6200, quantity: 3, processType: 'slat',
slatInfo: { length: 5200, slatCount: 55, jointBar: 6, glassQty: 3 },
steps: [
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'l2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
{ id: 'l2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
],
bending: [
{
id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 6, processType: 'bending',
bendingInfo: {
common: {
kind: '혼합형 120X70', type: '혼합형',
lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }],
},
detailParts: [
{ partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' },
{ partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' },
],
},
steps: [
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
{ id: 'b1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
],
},
],
};
// 절곡 재공품 전용 목업 데이터 (토글로 전환)
const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
{
id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 6, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' },
steps: [
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 'bw1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
],
},
{
id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-',
width: 0, height: 0, quantity: 4, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' },
steps: [
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 'bw2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
],
},
{
id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-',
width: 0, height: 0, quantity: 10, processType: 'bending',
isWip: true,
wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' },
steps: [
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
{ id: 'bw3-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
],
},
];
// 슬랫 조인트바 전용 목업 데이터 (토글로 전환)
const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
{
id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01',
width: 0, height: 0, quantity: 8, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 },
steps: [
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
{ id: 'jb1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
materialInputs: [
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
],
},
{
id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02',
width: 0, height: 0, quantity: 12, processType: 'slat',
isJointBar: true,
slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 },
steps: [
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
{ id: 'jb2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
{ id: 'jb2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
],
},
];
// 사이드바 작업지시 목업 데이터
interface SidebarOrder {
id: string;
siteName: string;
@@ -259,28 +92,6 @@ interface SidebarOrder {
subType?: 'slat' | 'jointbar' | 'bending' | 'wip';
}
// 스크린: subType 없음 / 슬랫: slat|jointbar / 절곡: bending|wip
const MOCK_SIDEBAR_ORDERS: Record<ProcessTab, SidebarOrder[]> = {
screen: [
{ id: 'order-s1', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent' },
{ id: 'order-s2', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'priority' },
{ id: 'order-s3', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' },
{ id: 'order-s4', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' },
],
slat: [
{ id: 'order-l1', siteName: '현장명A', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent', subType: 'slat' },
{ id: 'order-l2', siteName: '현장명B', date: '2024-09-24', quantity: 3, shutterCount: 2, priority: 'priority', subType: 'jointbar' },
{ id: 'order-l3', siteName: '현장명C', date: '2024-09-24', quantity: 5, shutterCount: 4, priority: 'normal', subType: 'slat' },
{ id: 'order-l4', siteName: '현장명D', date: '2024-09-24', quantity: 4, shutterCount: 3, priority: 'normal', subType: 'jointbar' },
],
bending: [
{ id: 'order-b1', siteName: '현장명A', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent', subType: 'bending' },
{ id: 'order-b2', siteName: '현장명B', date: '2024-09-24', quantity: 3, shutterCount: 2, priority: 'priority', subType: 'wip' },
{ id: 'order-b3', siteName: '현장명C', date: '2024-09-24', quantity: 5, shutterCount: 4, priority: 'normal', subType: 'bending' },
{ id: 'order-b4', siteName: '현장명D', date: '2024-09-24', quantity: 4, shutterCount: 3, priority: 'normal', subType: 'wip' },
],
};
const SUB_TYPE_TAGS: Record<string, { label: string; className: string }> = {
slat: { label: '슬랫', className: 'bg-blue-100 text-blue-700' },
jointbar: { label: '조인트바', className: 'bg-purple-100 text-purple-700' },
@@ -563,7 +374,7 @@ export default function WorkerScreen() {
useEffect(() => {
if (isLoading) return;
const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]];
const allOrders: SidebarOrder[] = [...apiSidebarOrders];
// 현재 선택이 유효하면 자동 전환하지 않음 (데이터 새로고침 시 선택 유지)
if (selectedSidebarOrderId && allOrders.some((o) => o.id === selectedSidebarOrderId)) {
@@ -784,27 +595,7 @@ export default function WorkerScreen() {
});
}
// 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서)
// 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용
// 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용
const baseMockItems = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip')
? MOCK_ITEMS_BENDING_WIP
: (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar')
? MOCK_ITEMS_SLAT_JOINTBAR
: MOCK_ITEMS[activeProcessTabKey];
const mockItems = baseMockItems.map((item, i) => ({
...item,
itemNo: apiItems.length + i + 1,
steps: item.steps.map((step) => {
const stepKey = `${item.id}-${step.name}`;
return {
...step,
isCompleted: stepCompletionMap[stepKey] ?? step.isCompleted,
};
}),
}));
return [...apiItems, ...mockItems];
return apiItems;
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]);
// ===== 검사 범위(scope) 기반 검사 단계 활성화/비활성화 =====
@@ -956,21 +747,7 @@ export default function WorkerScreen() {
};
}
// 2. 목업 사이드바에서 찾기
const mockOrder = MOCK_SIDEBAR_ORDERS[activeProcessTabKey].find((o) => o.id === selectedSidebarOrderId);
if (mockOrder) {
return {
orderDate: mockOrder.date,
salesOrderNo: 'SO-2024-0001',
siteName: mockOrder.siteName,
client: '-',
salesManager: '-',
managerPhone: '-',
shippingDate: '-',
};
}
// 3. 폴백: 첫 번째 작업
// 2. 폴백: 첫 번째 작업
const first = filteredWorkOrders[0];
if (!first) return null;
return {
@@ -1427,9 +1204,6 @@ export default function WorkerScreen() {
toast.error('검사 데이터 저장 중 오류가 발생했습니다.');
}
} else if (inspectionStepName) {
// 목업 데이터는 메모리만 저장 + 로컬 완료 처리
setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(inspectionStepName)]: true }));
toast.success('중간검사가 완료되었습니다.');
}
}, [selectedOrder, workItems, getInspectionProcessType, inspectionStepName]);
@@ -1666,27 +1440,8 @@ export default function WorkerScreen() {
</Card>
) : (
<div className="space-y-4">
{(() => {
const apiCount = workItems.filter((i) => !i.id.startsWith('mock-')).length;
return apiCount > 0 ? (
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded inline-block">
({apiCount})
</span>
) : null;
})()}
{scopedWorkItems.map((item, index) => {
const isFirstMock = item.id.startsWith('mock-') &&
(index === 0 || !scopedWorkItems[index - 1]?.id.startsWith('mock-'));
return (
<div key={item.id}>
{isFirstMock && (
<div className="mb-3 pt-1 space-y-2">
{scopedWorkItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
</span>
</div>
)}
{scopedWorkItems.map((item) => (
<div key={item.id}>
<WorkItemCard
item={item}
onStepClick={handleStepClick}
@@ -1694,9 +1449,8 @@ export default function WorkerScreen() {
onDeleteMaterial={handleDeleteMaterial}
onInspectionClick={handleInspectionClick}
/>
</div>
);
})}
</div>
))}
</div>
)}
</div>
@@ -1863,8 +1617,6 @@ function SidebarContent({
onSelectOrder,
apiOrders,
}: SidebarContentProps) {
const mockOrders = MOCK_SIDEBAR_ORDERS[tab];
const renderOrders = (orders: SidebarOrder[]) => (
<>
{PRIORITY_GROUPS.map((group) => {
@@ -1914,29 +1666,10 @@ function SidebarContent({
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900"> </h3>
{/* API 실제 데이터 */}
{apiOrders.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-semibold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
({apiOrders.length})
</span>
{renderOrders(apiOrders)}
</div>
)}
{/* 구분선 */}
{apiOrders.length > 0 && mockOrders.length > 0 && (
<div className="border-t border-dashed border-gray-300 my-1" />
)}
{/* 목업 데이터 */}
{mockOrders.length > 0 && (
<div className="space-y-2">
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">
</span>
{renderOrders(mockOrders)}
</div>
{apiOrders.length > 0 ? (
renderOrders(apiOrders)
) : (
<p className="text-xs text-gray-400 text-center py-4"> .</p>
)}
</div>
);

View File

@@ -12,7 +12,7 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData } from './types';
import { getPopupById, createPopup, updatePopup, deletePopup } from './actions';
import { popupDetailConfig } from './popupDetailConfig';
import { popupDetailConfig, decodeTargetValue } from './popupDetailConfig';
import { toast } from 'sonner';
interface PopupDetailClientV2Props {
@@ -20,11 +20,14 @@ interface PopupDetailClientV2Props {
initialMode?: DetailMode;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
};
// 로그인 사용자 이름을 가져오는 헬퍼
function getLoggedInUserName(): string {
if (typeof window === 'undefined') return '';
try {
const userDataStr = localStorage.getItem('user');
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
} catch { return ''; }
}
export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) {
const router = useRouter();
@@ -99,8 +102,10 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const { targetType, departmentId } = decodeTargetValue((formData.target as string) || 'all');
const popupFormData: PopupFormData = {
target: (formData.target as PopupFormData['target']) || 'all',
target: targetType,
targetDepartmentId: departmentId ? String(departmentId) : undefined,
title: formData.title as string,
content: formData.content as string,
status: (formData.status as PopupFormData['status']) || 'inactive',
@@ -167,7 +172,7 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV
? ({
target: 'all',
status: 'inactive',
author: CURRENT_USER.name,
author: getLoggedInUserName(),
createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'),
startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),

View File

@@ -51,11 +51,14 @@ interface PopupFormProps {
initialData?: Popup;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
};
// 로그인 사용자 이름을 가져오는 헬퍼
function getLoggedInUserName(): string {
if (typeof window === 'undefined') return '';
try {
const userDataStr = localStorage.getItem('user');
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
} catch { return ''; }
}
export function PopupForm({ mode, initialData }: PopupFormProps) {
const router = useRouter();
@@ -268,7 +271,7 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
<div className="space-y-2">
<Label></Label>
<Input
value={initialData?.author || CURRENT_USER.name}
value={initialData?.author || getLoggedInUserName()}
disabled
className="bg-gray-50"
/>

View File

@@ -97,6 +97,19 @@ export async function deletePopup(id: string): Promise<ActionResult> {
});
}
/**
* 부서 목록 조회 (팝업 대상 선택용)
*/
export async function getDepartmentList(): Promise<{ id: number; name: string }[]> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/departments'),
transform: (data: { data: { id: number; name: string }[] }) =>
data.data.map((d) => ({ id: d.id, name: d.name })),
errorMessage: '부서 목록 조회에 실패했습니다.',
});
return result.data || [];
}
/**
* 팝업 일괄 삭제
*/

View File

@@ -7,8 +7,10 @@ import { Megaphone } from 'lucide-react';
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
import { RichTextEditor } from '@/components/board/RichTextEditor';
import { createElement } from 'react';
import { createElement, useState, useEffect } from 'react';
import { sanitizeHTML } from '@/lib/sanitize';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { getDepartmentList } from './actions';
// ===== 대상 옵션 =====
const TARGET_OPTIONS = [
@@ -22,18 +24,76 @@ const STATUS_OPTIONS = [
{ value: 'active', label: '사용함' },
];
/**
* target 값 인코딩/디코딩 헬퍼
* 'all' → target_type: all, target_id: null
* 'department:13' → target_type: department, target_id: 13
*/
export function encodeTargetValue(targetType: string, departmentId?: number | null): string {
if (targetType === 'department' && departmentId) {
return `department:${departmentId}`;
}
return targetType;
}
export function decodeTargetValue(value: string): { targetType: PopupTarget; departmentId: number | null } {
if (value.startsWith('department:')) {
const id = parseInt(value.split(':')[1]);
return { targetType: 'department', departmentId: isNaN(id) ? null : id };
}
if (value === 'department') {
return { targetType: 'department', departmentId: null };
}
return { targetType: 'all', departmentId: null };
}
// ===== 필드 정의 =====
export const popupFields: FieldDefinition[] = [
{
key: 'target',
label: '대상',
type: 'select',
type: 'custom',
required: true,
options: TARGET_OPTIONS,
placeholder: '대상을 선택해주세요',
validation: [
{ type: 'required', message: '대상을 선택해주세요.' },
{
type: 'custom',
message: '대상을 선택해주세요.',
validate: (value) => !!value && value !== '',
},
{
type: 'custom',
message: '부서를 선택해주세요.',
validate: (value) => {
const str = value as string;
if (str === 'department') return false; // 부서 미선택
return true;
},
},
],
renderField: ({ value, onChange, mode, disabled }) => {
const strValue = (value as string) || 'all';
const { targetType, departmentId } = decodeTargetValue(strValue);
if (mode === 'view') {
// view 모드에서는 formatValue로 처리
return null;
}
// Edit/Create 모드: 대상 타입 Select + 조건부 부서 Select
return createElement(TargetSelectorField, {
targetType,
departmentId,
onChange,
disabled: !!disabled,
});
},
formatValue: (value) => {
// view 모드에서 표시할 텍스트 — 실제 부서명은 PopupDetailClientV2에서 처리
const strValue = (value as string) || 'all';
if (strValue === 'all') return '전사';
if (strValue.startsWith('department:')) return '부서별'; // 부서명은 아래서 덮어씌움
return '부서별';
},
},
{
key: 'startDate',
@@ -92,13 +152,11 @@ export const popupFields: FieldDefinition[] = [
],
renderField: ({ value, onChange, mode, disabled }) => {
if (mode === 'view') {
// View 모드: HTML 렌더링
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: sanitizeHTML((value as string) || '') },
});
}
// Edit/Create 모드: RichTextEditor
return createElement(RichTextEditor, {
value: (value as string) || '',
onChange: onChange,
@@ -172,7 +230,7 @@ export const popupDetailConfig: DetailConfig<Popup> = {
},
},
transformInitialData: (data: Popup) => ({
target: data.target || 'all',
target: encodeTargetValue(data.target, data.targetId),
startDate: data.startDate || '',
endDate: data.endDate || '',
title: data.title || '',
@@ -181,12 +239,86 @@ export const popupDetailConfig: DetailConfig<Popup> = {
author: data.author || '',
createdAt: data.createdAt || '',
}),
transformSubmitData: (formData): Partial<PopupFormData> => ({
target: formData.target as PopupTarget,
title: formData.title as string,
content: formData.content as string,
status: formData.status as PopupStatus,
startDate: formData.startDate as string,
endDate: formData.endDate as string,
}),
transformSubmitData: (formData): Partial<PopupFormData> => {
const { targetType, departmentId } = decodeTargetValue(formData.target as string);
return {
target: targetType,
targetDepartmentId: departmentId ? String(departmentId) : undefined,
title: formData.title as string,
content: formData.content as string,
status: formData.status as PopupStatus,
startDate: formData.startDate as string,
endDate: formData.endDate as string,
};
},
};
// ===== 대상 선택 필드 컴포넌트 =====
interface TargetSelectorFieldProps {
targetType: string;
departmentId: number | null;
onChange: (value: unknown) => void;
disabled: boolean;
}
function TargetSelectorField({ targetType, departmentId, onChange, disabled }: TargetSelectorFieldProps) {
const [departments, setDepartments] = useState<{ id: number; name: string }[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (targetType === 'department' && departments.length === 0) {
setLoading(true);
getDepartmentList()
.then((list: { id: number; name: string }[]) => setDepartments(list))
.finally(() => setLoading(false));
}
}, [targetType]);
const handleTypeChange = (newType: string) => {
if (newType === 'all') {
onChange('all');
} else {
onChange('department');
}
};
const handleDepartmentChange = (deptId: string) => {
onChange(`department:${deptId}`);
};
return createElement('div', { className: 'space-y-2' },
// 대상 타입 Select
createElement(Select, {
value: targetType,
onValueChange: handleTypeChange,
disabled,
},
createElement(SelectTrigger, null,
createElement(SelectValue, { placeholder: '대상을 선택해주세요' })
),
createElement(SelectContent, null,
TARGET_OPTIONS.map(opt =>
createElement(SelectItem, { key: opt.value, value: opt.value }, opt.label)
)
)
),
// 부서별 선택 시 부서 Select 추가
targetType === 'department' && createElement(Select, {
value: departmentId ? String(departmentId) : undefined,
onValueChange: handleDepartmentChange,
disabled: disabled || loading,
},
createElement(SelectTrigger, null,
createElement(SelectValue, {
placeholder: loading ? '부서 목록 로딩 중...' : '부서를 선택해주세요',
})
),
createElement(SelectContent, null,
departments.map((dept: { id: number; name: string }) =>
createElement(SelectItem, { key: dept.id, value: String(dept.id) }, dept.name)
)
)
)
);
}

View File

@@ -12,6 +12,7 @@ export type PopupStatus = 'active' | 'inactive';
export interface Popup {
id: string;
target: PopupTarget;
targetId?: number | null; // 부서 ID (대상이 department인 경우)
targetName?: string; // 부서명 (대상이 department인 경우)
title: string;
content: string;

View File

@@ -48,6 +48,7 @@ export function transformApiToFrontend(apiData: PopupApiData): Popup {
return {
id: String(apiData.id),
target: apiData.target_type as PopupTarget,
targetId: apiData.target_id,
targetName: apiData.target_type === 'department'
? apiData.department?.name
: undefined,