fix(react): 매출관리, 직원폼, 작업지시서 컴포넌트 수정

- SalesManagement actions 및 index 개선
- EmployeeForm 수정
- WorkOrder Detail/Edit/List 컴포넌트 업데이트
- IntegratedDetailTemplate types 수정
- dashboard types 업데이트
This commit is contained in:
2026-01-23 15:38:32 +09:00
parent e3043a3f0d
commit 662a0cc4ac
9 changed files with 177 additions and 48 deletions

View File

@@ -211,7 +211,15 @@ export async function toggleSaleIssuance(
? { taxInvoiceIssued: value }
: { transactionStatementIssued: value };
return updateSale(id, updateData);
try {
return await updateSale(id, updateData);
} catch (error) {
// 인증 만료 등으로 인한 리다이렉트 에러 → 페이지 이동 없이 에러 반환
if (isNextRedirectError(error)) {
return { success: false, error: '세션이 만료되었습니다. 다시 로그인해주세요.' };
}
throw error;
}
}
// ===== 매출 삭제 =====

View File

@@ -14,7 +14,7 @@
* - deleteConfirmMessage로 삭제 다이얼로그 처리
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import {
@@ -92,7 +92,6 @@ interface SalesManagementProps {
export function SalesManagement({ initialData, initialPagination }: SalesManagementProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [startDate, setStartDate] = useState('2025-01-01');
@@ -192,35 +191,31 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
router.push('/ko/accounting/sales?mode=new');
}, [router]);
// 토글 핸들러
const handleTaxInvoiceToggle = useCallback((itemId: string, checked: boolean) => {
// 토글 핸들러 (Optimistic UI: 먼저 UI 업데이트 → API 호출 → 실패 시 롤백)
const handleTaxInvoiceToggle = useCallback(async (itemId: string, checked: boolean) => {
setSalesData(prev => prev.map(item =>
item.id === itemId ? { ...item, taxInvoiceIssued: checked } : item
));
startTransition(async () => {
const result = await toggleSaleIssuance(itemId, 'taxInvoiceIssued', checked);
if (!result.success) {
setSalesData(prev => prev.map(item =>
item.id === itemId ? { ...item, taxInvoiceIssued: !checked } : item
));
toast.error(result.error || '세금계산서 발행 상태 변경에 실패했습니다.');
}
});
const result = await toggleSaleIssuance(itemId, 'taxInvoiceIssued', checked);
if (!result.success) {
setSalesData(prev => prev.map(item =>
item.id === itemId ? { ...item, taxInvoiceIssued: !checked } : item
));
toast.error(result.error || '세금계산서 발행 상태 변경에 실패했습니다.');
}
}, []);
const handleTransactionStatementToggle = useCallback((itemId: string, checked: boolean) => {
const handleTransactionStatementToggle = useCallback(async (itemId: string, checked: boolean) => {
setSalesData(prev => prev.map(item =>
item.id === itemId ? { ...item, transactionStatementIssued: checked } : item
));
startTransition(async () => {
const result = await toggleSaleIssuance(itemId, 'transactionStatementIssued', checked);
if (!result.success) {
setSalesData(prev => prev.map(item =>
item.id === itemId ? { ...item, transactionStatementIssued: !checked } : item
));
toast.error(result.error || '거래명세서 발행 상태 변경에 실패했습니다.');
}
});
const result = await toggleSaleIssuance(itemId, 'transactionStatementIssued', checked);
if (!result.success) {
setSalesData(prev => prev.map(item =>
item.id === itemId ? { ...item, transactionStatementIssued: !checked } : item
));
toast.error(result.error || '거래명세서 발행 상태 변경에 실패했습니다.');
}
}, []);
// 계정과목명 저장 핸들러
@@ -265,8 +260,8 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
getList: async () => {
return {
success: true,
data: initialData,
totalCount: initialData.length,
data: salesData,
totalCount: salesData.length,
};
},
deleteItem: async (id: string) => {
@@ -531,7 +526,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
),
}),
[
initialData,
salesData,
startDate,
endDate,
stats,
@@ -550,7 +545,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
return (
<>
<UniversalListPage config={config} initialData={initialData} />
<UniversalListPage config={config} initialData={salesData} />
{/* 계정과목명 저장 확인 다이얼로그 */}
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>

View File

@@ -46,6 +46,71 @@ import {
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
import { getProfileImageUrl } from './utils';
// 부서 트리 구조 타입
interface DepartmentTreeNode extends DepartmentItem {
depth: number;
children?: DepartmentTreeNode[];
}
// 플랫 리스트를 트리 구조로 변환
function buildDepartmentTree(departments: DepartmentItem[]): DepartmentTreeNode[] {
const map = new Map<number, DepartmentTreeNode>();
const roots: DepartmentTreeNode[] = [];
// 먼저 모든 노드를 맵에 저장
departments.forEach(dept => {
map.set(dept.id, { ...dept, depth: 0, children: [] });
});
// 부모-자식 관계 설정
departments.forEach(dept => {
const node = map.get(dept.id)!;
if (dept.parent_id && map.has(dept.parent_id)) {
const parent = map.get(dept.parent_id)!;
node.depth = parent.depth + 1;
parent.children = parent.children || [];
parent.children.push(node);
} else {
roots.push(node);
}
});
// 깊이 재계산 (재귀)
function updateDepth(nodes: DepartmentTreeNode[], depth: number) {
nodes.forEach(node => {
node.depth = depth;
if (node.children && node.children.length > 0) {
updateDepth(node.children, depth + 1);
}
});
}
updateDepth(roots, 0);
return roots;
}
// 트리를 플랫 리스트로 변환 (depth 정보 유지)
function flattenDepartmentTree(nodes: DepartmentTreeNode[]): DepartmentTreeNode[] {
const result: DepartmentTreeNode[] = [];
function traverse(nodeList: DepartmentTreeNode[]) {
nodeList.forEach(node => {
result.push(node);
if (node.children && node.children.length > 0) {
traverse(node.children);
}
});
}
traverse(nodes);
return result;
}
// 부서명 들여쓰기 포맷
function formatDepartmentName(name: string, depth: number): string {
if (depth === 0) return name;
const indent = '──'.repeat(depth);
return `${indent} ${name}`;
}
interface EmployeeFormProps {
mode: 'create' | 'edit' | 'view';
employee?: Employee;
@@ -134,7 +199,7 @@ export function EmployeeForm({
// 직급/직책/부서 목록
const [ranks, setRanks] = useState<PositionItem[]>([]);
const [titles, setTitles] = useState<PositionItem[]>([]);
const [departments, setDepartments] = useState<DepartmentItem[]>([]);
const [departments, setDepartments] = useState<DepartmentTreeNode[]>([]);
// localStorage에서 항목 설정 로드
useEffect(() => {
@@ -158,7 +223,10 @@ export function EmployeeForm({
]);
setRanks(rankList);
setTitles(titleList);
setDepartments(deptList);
// 부서를 트리 구조로 변환 후 플랫 리스트로 (depth 정보 유지)
const tree = buildDepartmentTree(deptList);
const flatTree = flattenDepartmentTree(tree);
setDepartments(flatTree);
};
loadData();
}, []);
@@ -346,19 +414,25 @@ export function EmployeeForm({
}
};
// 저장
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 저장 (IntegratedDetailTemplate 호환)
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// view 모드에서는 저장 불가
if (isViewMode) return;
if (isViewMode) {
return { success: false, error: '보기 모드에서는 저장할 수 없습니다.' };
}
// 유효성 검사
if (!validateForm()) {
return;
return { success: false, error: '입력 정보를 확인해주세요.' };
}
onSave?.(formData);
// onSave 호출 (페이지에서 처리)
if (onSave) {
onSave(formData);
return { success: true };
}
return { success: false, error: '저장 핸들러가 설정되지 않았습니다.' };
};
// 취소 (목록으로 이동)
@@ -708,7 +782,11 @@ export function EmployeeForm({
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept.id} value={String(dept.id)}>{dept.name}</SelectItem>
<SelectItem key={dept.id} value={String(dept.id)}>
<span style={{ fontFamily: 'monospace' }}>
{formatDepartmentName(dept.name, dept.depth)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
@@ -938,18 +1016,26 @@ export function EmployeeForm({
return employeeCreateConfig;
};
// onDelete 타입 변환 (IntegratedDetailTemplate에서 id를 받지만, 외부에서는 void 타입)
const handleDeleteWrapper = onDelete
? async () => {
onDelete();
return { success: true };
}
: undefined;
return (
<IntegratedDetailTemplate
config={getConfig()}
mode={mode}
isLoading={false}
isSubmitting={false}
onBack={handleCancel}
onCancel={handleCancel}
onSubmit={handleSubmit}
onEdit={onEdit}
onDelete={onDelete}
onDelete={handleDeleteWrapper}
renderForm={renderFormContent}
renderView={renderFormContent}
initialData={employee as unknown as Record<string, unknown>}
/>
);
}

View File

@@ -211,6 +211,11 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
const [updatingItemId, setUpdatingItemId] = useState<number | null>(null);
// 수정 모드로 이동 핸들러
const handleEdit = useCallback(() => {
router.push(`/production/work-orders/${orderId}?mode=edit`);
}, [router, orderId]);
// API에서 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
@@ -579,6 +584,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
itemId={orderId}
isLoading={isLoading}
headerActions={customHeaderActions}
onEdit={handleEdit}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>

View File

@@ -134,7 +134,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
}, [loadData]);
// 폼 제출
const handleSubmit = async () => {
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
// Validation 체크
const errors: ValidationErrors = {};
@@ -150,7 +150,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
return { success: false, error: '입력 정보를 확인해주세요.' };
}
// 에러 초기화
@@ -175,13 +175,14 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
if (result.success) {
toast.success('작업지시가 수정되었습니다.');
router.push(`/production/work-orders/${orderId}`);
return { success: true };
} else {
toast.error(result.error || '작업지시 수정에 실패했습니다.');
return { success: false, error: result.error || '작업지시 수정에 실패했습니다.' };
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderEdit] handleSubmit error:', error);
toast.error('작업지시 수정 중 오류가 발생했습니다.');
return { success: false, error: '작업지시 수정 중 오류가 발생했습니다.' };
} finally {
setIsSubmitting(false);
}

View File

@@ -42,6 +42,19 @@ type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' |
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
// 작업자 표시 포맷 (홍길동 외 2명)
function formatAssignees(item: WorkOrder): string {
if (item.assignees && item.assignees.length > 0) {
const primaryAssignee = item.assignees.find(a => a.isPrimary) || item.assignees[0];
const otherCount = item.assignees.length - 1;
if (otherCount > 0) {
return `${primaryAssignee.name}${otherCount}`;
}
return primaryAssignee.name;
}
return item.assignee || '-';
}
export function WorkOrderList() {
const router = useRouter();
@@ -247,7 +260,7 @@ export function WorkOrderList() {
</Badge>
</TableCell>
<TableCell className="text-center">{item.priority}</TableCell>
<TableCell>{item.assignee}</TableCell>
<TableCell>{formatAssignees(item)}</TableCell>
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
<TableCell>{item.shipmentDate}</TableCell>
</TableRow>
@@ -289,7 +302,7 @@ export function WorkOrderList() {
<InfoField label="공정" value={item.processName} />
<InfoField label="로트번호" value={item.lotNo} />
<InfoField label="발주처" value={item.client} />
<InfoField label="작업자" value={item.assignee || '-'} />
<InfoField label="작업자" value={formatAssignees(item)} />
<InfoField label="지시일" value={item.orderDate} />
<InfoField label="출고예정일" value={item.shipmentDate} />
<InfoField label="현장순위" value={item.priority} />

View File

@@ -115,6 +115,8 @@ export interface ActionConfig {
submitLabel?: string;
/** 취소 버튼 텍스트 */
cancelLabel?: string;
/** 저장 버튼 표시 */
showSave?: boolean;
/** 삭제 버튼 표시 */
showDelete?: boolean;
/** 삭제 버튼 텍스트 */

View File

@@ -556,9 +556,27 @@ export interface ExpectedExpenseFooterSummaryApiResponse {
item_count: number; // 건수
}
/** 지출예상 월별 추이 */
export interface ExpectedExpenseMonthlyTrendApiResponse {
month: string; // "2026-01"
label: string; // "1월"
amount: number; // 금액
}
/** 지출예상 거래처별 분포 */
export interface ExpectedExpenseVendorDistributionApiResponse {
name: string; // 거래처명
value: number; // 금액
count: number; // 건수
percentage: number; // 비율(%)
color: string; // 차트 색상
}
/** GET /api/v1/expected-expenses/dashboard-detail 응답 */
export interface ExpectedExpenseDashboardDetailApiResponse {
summary: ExpectedExpenseDashboardSummaryApiResponse;
monthly_trend: ExpectedExpenseMonthlyTrendApiResponse[];
vendor_distribution: ExpectedExpenseVendorDistributionApiResponse[];
items: ExpectedExpenseItemApiResponse[];
footer_summary: ExpectedExpenseFooterSummaryApiResponse;
}

File diff suppressed because one or more lines are too long