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

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

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

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

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

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

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

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

View File

@@ -1,5 +1,8 @@
export type PermissionAction = 'view' | 'create' | 'update' | 'delete' | 'approve' | 'export';
export const PERMISSION_ACTIONS: PermissionAction[] =
['view', 'create', 'update', 'delete', 'approve', 'export'];
/** flat 변환된 권한 맵 (프론트엔드 사용) */
export interface PermissionMap {
[url: string]: {
@@ -18,3 +21,14 @@ export interface UsePermissionReturn {
isLoading: boolean;
matchedUrl: string | null;
}
export const ALL_ALLOWED: UsePermissionReturn = {
canView: true, canCreate: true, canUpdate: true,
canDelete: true, canApprove: true, canExport: true,
isLoading: false, matchedUrl: null,
};
export const ALL_DENIED_PERMS: Record<PermissionAction, false> = {
view: false, create: false, update: false,
delete: false, approve: false, export: false,
};

View File

@@ -1,4 +1,6 @@
import type { PermissionMap, PermissionAction } from './types';
import { PERMISSION_ACTIONS } from './types';
import type { PermissionMap } from './types';
import { stripLocalePrefix } from '@/lib/utils/locale';
interface SerializableMenuItem {
id: string;
@@ -38,14 +40,13 @@ export function convertMatrixToPermissionMap(
menuIdToUrl: Record<string, string>
): PermissionMap {
const map: PermissionMap = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
for (const [menuId, perms] of Object.entries(permissions)) {
const url = menuIdToUrl[menuId];
if (!url) continue; // URL 매핑 없는 메뉴 스킵
map[url] = {};
for (const action of actions) {
for (const action of PERMISSION_ACTIONS) {
// API는 허용된 권한만 포함, 누락된 action = 비허용(false)
map[url][action] = perms[action] === true;
}
@@ -66,8 +67,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
for (const url of allUrls) {
merged[url] = {};
const actions: PermissionAction[] = ['view', 'create', 'update', 'delete', 'approve', 'export'];
for (const action of actions) {
for (const action of PERMISSION_ACTIONS) {
const values = maps
.map(m => m[url]?.[action])
.filter((v): v is boolean => v !== undefined);
@@ -84,7 +84,7 @@ export function mergePermissionMaps(maps: PermissionMap[]): PermissionMap {
* Longest prefix match: 현재 경로에서 가장 길게 매칭되는 권한 URL 찾기
*/
export function findMatchingUrl(currentPath: string, permissionMap: PermissionMap): string | null {
const pathWithoutLocale = currentPath.replace(/^\/(ko|en|ja)(\/|$)/, '/');
const pathWithoutLocale = stripLocalePrefix(currentPath);
if (permissionMap[pathWithoutLocale]) {
return pathWithoutLocale;
@@ -100,12 +100,3 @@ export function findMatchingUrl(currentPath: string, permissionMap: PermissionMa
return null;
}
/**
* CRUD 라우트에서 현재 액션 추론
*/
export function inferActionFromPath(path: string): PermissionAction {
if (path.endsWith('/new') || path.endsWith('/create')) return 'create';
if (path.endsWith('/edit')) return 'update';
return 'view';
}

14
src/lib/utils/locale.ts Normal file
View File

@@ -0,0 +1,14 @@
import { locales } from '@/i18n/config';
const LOCALE_PREFIX_RE = new RegExp(`^\\/(${locales.join('|')})(\/|$)`);
const LOCALE_PREFIX_SLASH_RE = new RegExp(`^\\/(${locales.join('|')})\\/`);
/** URL에서 locale prefix 제거 (/ko/hr/... → /hr/...) */
export function stripLocalePrefix(path: string): string {
return path.replace(LOCALE_PREFIX_RE, '/');
}
/** path 내부의 locale prefix만 제거 (슬래시 필수) */
export function stripLocaleSlashPrefix(path: string): string {
return path.replace(LOCALE_PREFIX_SLASH_RE, '/');
}