fix(react): 매출관리, 직원폼, 작업지시서 컴포넌트 수정
- SalesManagement actions 및 index 개선 - EmployeeForm 수정 - WorkOrder Detail/Edit/List 컴포넌트 업데이트 - IntegratedDetailTemplate types 수정 - dashboard types 업데이트
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 매출 삭제 =====
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface ActionConfig {
|
||||
submitLabel?: string;
|
||||
/** 취소 버튼 텍스트 */
|
||||
cancelLabel?: string;
|
||||
/** 저장 버튼 표시 */
|
||||
showSave?: boolean;
|
||||
/** 삭제 버튼 표시 */
|
||||
showDelete?: boolean;
|
||||
/** 삭제 버튼 텍스트 */
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user