feat(WEB): 회계/HR/주문관리 모듈 개선 및 알림설정 리팩토링
- 회계: 거래처, 매입/매출, 입출금 상세 페이지 개선 - HR: 직원 관리 및 출퇴근 설정 기능 수정 - 주문관리: 상세폼 구조 분리 (cards, dialogs, hooks, tables) - 알림설정: 컴포넌트 구조 단순화 및 리팩토링 - 캘린더: 헤더 및 일정 타입 개선 - 출고관리: 액션 및 타입 정의 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -334,7 +334,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : '삭제'}
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -345,7 +345,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : (isNewMode ? '등록' : '저장')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -917,7 +917,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
className="flex-1 bg-white"
|
||||
rows={2}
|
||||
/>
|
||||
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
|
||||
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
@@ -988,7 +988,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
Loader2,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -274,6 +275,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -283,7 +285,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -295,7 +297,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{isNewMode ? '등록' : '저장'}
|
||||
|
||||
@@ -422,7 +422,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
@@ -472,7 +472,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
</Select>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장중...' : '저장'}
|
||||
</Button>
|
||||
|
||||
@@ -364,7 +364,7 @@ export function CardTransactionInquiry({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -487,7 +487,7 @@ export function CardTransactionInquiry({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Banknote,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -180,6 +181,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -190,7 +192,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -202,7 +204,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '처리중...' : isNewMode ? '등록' : '저장'}
|
||||
|
||||
@@ -495,7 +495,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -584,7 +584,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { FileText, Plus, X, Eye, Receipt } from 'lucide-react';
|
||||
import { FileText, Plus, X, Eye, Receipt, List } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
@@ -293,6 +293,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -302,7 +303,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -312,7 +313,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
{isSaving ? '저장 중...' : isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -539,7 +539,7 @@ export function PurchaseManagement() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -622,7 +622,7 @@ export function PurchaseManagement() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -39,6 +39,7 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables {
|
||||
category: cat.category,
|
||||
amounts: cat.amounts,
|
||||
})),
|
||||
memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, useTransition } from 'react';
|
||||
import { Download, FileText, Save, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, useTransition, Fragment } from 'react';
|
||||
import { Download, FileText, Save, Loader2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -68,6 +68,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
overdueVendorCount: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(!initialData.length);
|
||||
const [expandedMemos, setExpandedMemos] = useState<Set<string>>(new Set());
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -125,6 +126,19 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
));
|
||||
}, []);
|
||||
|
||||
// ===== 메모 펼치기/접기 토글 =====
|
||||
const toggleMemoExpand = useCallback((vendorId: string) => {
|
||||
setExpandedMemos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(vendorId)) {
|
||||
newSet.delete(vendorId);
|
||||
} else {
|
||||
newSet.add(vendorId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 엑셀 다운로드 핸들러 =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await exportReceivablesExcel({
|
||||
@@ -213,8 +227,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
return totals;
|
||||
}, [filteredData]);
|
||||
|
||||
// ===== 카테고리 순서 =====
|
||||
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable', 'memo'];
|
||||
// ===== 카테고리 순서 (메모 제외 - 별도 렌더링) =====
|
||||
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -275,7 +289,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isPending || changedItems.length === 0}
|
||||
className="bg-orange-500 hover:bg-orange-600 disabled:opacity-50"
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -345,78 +359,131 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((vendor) => (
|
||||
categoryOrder.map((category, catIndex) => {
|
||||
const categoryData = vendor.categories.find(c => c.category === category);
|
||||
if (!categoryData) return null;
|
||||
filteredData.map((vendor) => {
|
||||
const isOverdueRow = vendor.isOverdue;
|
||||
const isHighlighted = highlightVendorId === vendor.id;
|
||||
const rowBgClass = isHighlighted
|
||||
? 'bg-yellow-100'
|
||||
: isOverdueRow
|
||||
? 'bg-red-50'
|
||||
: 'bg-white';
|
||||
const isExpanded = expandedMemos.has(vendor.id);
|
||||
const hasMemo = vendor.memo && vendor.memo.trim().length > 0;
|
||||
|
||||
const isOverdueRow = vendor.isOverdue;
|
||||
const isHighlighted = highlightVendorId === vendor.id;
|
||||
// 하이라이트 > 연체 > 기본 순으로 배경색 결정
|
||||
const rowBgClass = isHighlighted
|
||||
? 'bg-yellow-100'
|
||||
: isOverdueRow
|
||||
? 'bg-red-50'
|
||||
: 'bg-white';
|
||||
return (
|
||||
<Fragment key={vendor.id}>
|
||||
{/* 카테고리 행들 (매출, 입금, 어음, 미수금) */}
|
||||
{categoryOrder.map((category, catIndex) => {
|
||||
const categoryData = vendor.categories.find(c => c.category === category);
|
||||
if (!categoryData) return null;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={`${vendor.id}-${category}`}
|
||||
ref={catIndex === 0 && isHighlighted ? highlightRowRef : undefined}
|
||||
className={`${catIndex === 0 ? 'border-t-2 border-gray-300' : ''} ${isHighlighted ? 'ring-2 ring-yellow-400 ring-inset' : ''}`}
|
||||
>
|
||||
{/* 거래처명 + 연체 토글 - 왼쪽 고정 */}
|
||||
{catIndex === 0 && (
|
||||
<TableCell
|
||||
rowSpan={5}
|
||||
className={`font-medium border-r border-gray-200 align-top pt-3 sticky left-0 z-10 ${rowBgClass}`}
|
||||
return (
|
||||
<TableRow
|
||||
key={`${vendor.id}-${category}`}
|
||||
ref={catIndex === 0 && isHighlighted ? highlightRowRef : undefined}
|
||||
className={`${catIndex === 0 ? 'border-t-2 border-gray-300' : ''} ${isHighlighted ? 'ring-2 ring-yellow-400 ring-inset' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm">{vendor.vendorName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={vendor.isOverdue}
|
||||
onCheckedChange={(checked) => handleOverdueToggle(vendor.id, checked)}
|
||||
className="data-[state=checked]:bg-red-500"
|
||||
/>
|
||||
{vendor.isOverdue && (
|
||||
<span className="text-xs text-red-500 font-medium">연체</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{/* 거래처명 + 연체 토글 - 왼쪽 고정 */}
|
||||
{catIndex === 0 && (
|
||||
<TableCell
|
||||
rowSpan={5}
|
||||
className={`font-medium border-r border-gray-200 align-top pt-3 sticky left-0 z-10 ${rowBgClass}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm">{vendor.vendorName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={vendor.isOverdue}
|
||||
onCheckedChange={(checked) => handleOverdueToggle(vendor.id, checked)}
|
||||
className="data-[state=checked]:bg-red-500"
|
||||
/>
|
||||
{vendor.isOverdue && (
|
||||
<span className="text-xs text-red-500 font-medium">연체</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 구분 - 왼쪽 고정 */}
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</TableCell>
|
||||
|
||||
{/* 월별 금액 - 스크롤 영역 */}
|
||||
{MONTH_KEYS.map((monthKey, monthIndex) => {
|
||||
const amount = categoryData.amounts[monthKey] || 0;
|
||||
const isOverdue = isOverdueCell(vendor, monthIndex);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={monthKey}
|
||||
className={`text-right text-sm border-r border-gray-200 ${
|
||||
isOverdue ? 'bg-red-100 text-red-700' : ''
|
||||
}`}
|
||||
>
|
||||
{formatAmount(amount)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 합계 - 오른쪽 고정 */}
|
||||
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
|
||||
{formatAmount(categoryData.amounts.total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 메모 행 - 별도 렌더링 */}
|
||||
<TableRow key={`${vendor.id}-memo`}>
|
||||
{/* 구분 - 왼쪽 고정 */}
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
메모
|
||||
</TableCell>
|
||||
|
||||
{/* 월별 금액 - 스크롤 영역 */}
|
||||
{MONTH_KEYS.map((monthKey, monthIndex) => {
|
||||
const amount = categoryData.amounts[monthKey] || 0;
|
||||
const isOverdue = isOverdueCell(vendor, monthIndex);
|
||||
{/* 메모 내용 - 월별 컬럼 + 합계 컬럼 병합 */}
|
||||
<TableCell colSpan={13} className="p-2">
|
||||
{hasMemo ? (
|
||||
<div className="relative">
|
||||
{/* 메모 내용 */}
|
||||
<div
|
||||
className={`text-sm text-gray-700 whitespace-pre-wrap ${
|
||||
isExpanded
|
||||
? 'max-h-40 overflow-y-auto'
|
||||
: 'max-h-10 overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
{vendor.memo}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={monthKey}
|
||||
className={`text-right text-sm border-r border-gray-200 ${
|
||||
isOverdue ? 'bg-red-100 text-red-700' : ''
|
||||
}`}
|
||||
>
|
||||
{formatAmount(amount)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 합계 - 오른쪽 고정 */}
|
||||
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
|
||||
{formatAmount(categoryData.amounts.total)}
|
||||
{/* 펼치기/접기 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMemoExpand(vendor.id)}
|
||||
className="mt-1 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
접기
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
더보기
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
))
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface VendorReceivables {
|
||||
isOverdue: boolean; // 연체 토글 상태
|
||||
overdueMonths: number[]; // 연체 월 (1-12)
|
||||
categories: CategoryData[];
|
||||
memo?: string; // 거래처별 메모 (단일 텍스트)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Send,
|
||||
FileText,
|
||||
Loader2,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -310,6 +311,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -319,7 +321,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -329,7 +331,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600">
|
||||
{isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -582,7 +582,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -641,7 +641,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { FileText, Download, Pencil, Loader2 } from 'lucide-react';
|
||||
import { FileText, Download, Pencil, Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -171,6 +171,7 @@ export function VendorLedgerDetail({
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +300,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
ㄷ <Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -311,7 +311,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
{isSaving ? '처리중...' : isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -631,7 +631,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
className="flex-1 bg-white"
|
||||
rows={2}
|
||||
/>
|
||||
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
|
||||
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
@@ -707,7 +707,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
<AlertDialogCancel disabled={isSaving}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? '처리중...' : '확인'}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Plus, X, Loader2 } from 'lucide-react';
|
||||
import { Building2, Plus, X, Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -267,12 +267,13 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -283,7 +284,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
@@ -548,7 +549,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
className="flex-1 bg-white"
|
||||
rows={2}
|
||||
/>
|
||||
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
|
||||
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
@@ -625,7 +626,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
|
||||
@@ -19,8 +19,6 @@ import type {
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
VendorCategory,
|
||||
CLIENT_TYPE_TO_CATEGORY,
|
||||
CATEGORY_TO_CLIENT_TYPE,
|
||||
BadDebtStatus,
|
||||
} from './types';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
// 클라이언트 컴포넌트
|
||||
export { VendorManagementClient } from './VendorManagementClient';
|
||||
export { VendorManagementClient as VendorManagement } from './VendorManagementClient';
|
||||
|
||||
// 상세/수정 컴포넌트
|
||||
export { VendorDetail } from './VendorDetail';
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
// ===== API 응답 타입 =====
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// ===== API 데이터 타입 (백엔드에서 오는 원본 데이터) =====
|
||||
export interface ClientApiData {
|
||||
id: number;
|
||||
client_code: string;
|
||||
name: string;
|
||||
client_type: 'SALES' | 'PURCHASE' | 'BOTH';
|
||||
business_no: string;
|
||||
contact_person: string;
|
||||
phone: string;
|
||||
mobile: string;
|
||||
fax: string;
|
||||
email: string;
|
||||
address: string;
|
||||
manager_name: string;
|
||||
manager_tel: string;
|
||||
system_manager: string;
|
||||
purchase_payment_day: string;
|
||||
sales_payment_day: string;
|
||||
business_type: string;
|
||||
business_item: string;
|
||||
bad_debt: boolean;
|
||||
memo: string;
|
||||
is_active: boolean;
|
||||
account_id: string;
|
||||
outstanding_amount: number;
|
||||
bad_debt_total: number;
|
||||
has_bad_debt: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ===== 거래처 구분 =====
|
||||
export type VendorCategory = 'sales' | 'purchase' | 'both';
|
||||
|
||||
// ===== API client_type ↔ VendorCategory 변환 =====
|
||||
export const CLIENT_TYPE_TO_CATEGORY: Record<string, VendorCategory> = {
|
||||
SALES: 'sales',
|
||||
PURCHASE: 'purchase',
|
||||
BOTH: 'both',
|
||||
};
|
||||
|
||||
export const CATEGORY_TO_CLIENT_TYPE: Record<VendorCategory, string> = {
|
||||
sales: 'SALES',
|
||||
purchase: 'PURCHASE',
|
||||
both: 'BOTH',
|
||||
};
|
||||
|
||||
export const VENDOR_CATEGORY_LABELS: Record<VendorCategory, string> = {
|
||||
sales: '매출',
|
||||
purchase: '매입',
|
||||
@@ -198,6 +256,7 @@ export interface Vendor {
|
||||
overdueAmount: number; // 연체금액
|
||||
overdueDays: number; // 연체일수
|
||||
unpaidAmount: number; // 미지급
|
||||
badDebtAmount: number; // 악성채권 금액
|
||||
badDebtStatus: BadDebtStatus; // 악성채권 상태
|
||||
overdueToggle: boolean; // 연체 토글
|
||||
badDebtToggle: boolean; // 악성채권 토글
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Banknote,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -180,6 +181,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -190,7 +192,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -202,7 +204,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '처리중...' : isNewMode ? '등록' : '저장'}
|
||||
|
||||
@@ -489,7 +489,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -578,7 +578,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -155,12 +155,10 @@ export async function checkIn(
|
||||
*/
|
||||
export async function checkOut(
|
||||
data: CheckOutRequest
|
||||
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-out`, {
|
||||
const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-out`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
user_id: data.userId,
|
||||
check_out: data.checkOut,
|
||||
@@ -195,11 +193,11 @@ export async function checkOut(
|
||||
success: false,
|
||||
error: result.message || '퇴근 기록에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[checkOut] Error:', error);
|
||||
} catch (err) {
|
||||
console.error('[checkOut] Error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '퇴근 기록에 실패했습니다.',
|
||||
error: err instanceof Error ? err.message : '퇴근 기록에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -187,6 +187,7 @@ export default function BiddingDetailForm({
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Loader2 } from 'lucide-react';
|
||||
import { FileText, Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -560,7 +560,7 @@ export default function EstimateDetailForm({
|
||||
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
|
||||
전자결재
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -569,6 +569,7 @@ export default function EstimateDetailForm({
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -578,7 +579,7 @@ export default function EstimateDetailForm({
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
@@ -718,7 +719,7 @@ export default function EstimateDetailForm({
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function EstimateDetailTableSection({
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
onClick={onApplyAdjustedPrice}
|
||||
>
|
||||
조정 단가 적용
|
||||
|
||||
@@ -73,7 +73,7 @@ export function PriceAdjustmentSection({
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
onClick={onApplyAll}
|
||||
>
|
||||
전체 적용
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, Plus, X } from 'lucide-react';
|
||||
import { Package, Plus, X, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -281,6 +281,7 @@ export default function ItemDetailClient({
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Hammer } from 'lucide-react';
|
||||
import { Hammer, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -230,6 +230,7 @@ export default function LaborDetailClient({
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -791,7 +791,6 @@ export default function OrderManagementListClient({
|
||||
// 달력 섹션 추가
|
||||
beforeTableContent={
|
||||
<div className="w-full flex-shrink-0 mb-6">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">발주 스케줄</h3>
|
||||
<ScheduleCalendar
|
||||
events={calendarEvents}
|
||||
badges={calendarBadges}
|
||||
@@ -800,6 +799,7 @@ export default function OrderManagementListClient({
|
||||
onDateClick={handleCalendarDateClick}
|
||||
onEventClick={handleCalendarEventClick}
|
||||
onMonthChange={handleCalendarMonthChange}
|
||||
titleSlot="발주 스케줄"
|
||||
filterSlot={calendarFilterSlot}
|
||||
maxEventsPerDay={3}
|
||||
weekStartsOn={0}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { OrderDetailFormData, OrderDetailCategory } from '../types';
|
||||
import { MOCK_PARTNERS, MOCK_CONSTRUCTION_PM } from '../types';
|
||||
|
||||
interface ContractInfoCardProps {
|
||||
formData: OrderDetailFormData;
|
||||
isViewMode: boolean;
|
||||
isEditMode: boolean;
|
||||
onFieldChange: (
|
||||
field: keyof OrderDetailFormData,
|
||||
value: string | string[] | OrderDetailCategory[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function ContractInfoCard({
|
||||
formData,
|
||||
isViewMode,
|
||||
isEditMode,
|
||||
onFieldChange,
|
||||
}: ContractInfoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Select
|
||||
value={formData.partnerId}
|
||||
onValueChange={(value) => {
|
||||
onFieldChange('partnerId', value);
|
||||
const partner = MOCK_PARTNERS.find((p) => p.value === value);
|
||||
if (partner) {
|
||||
onFieldChange('partnerName', partner.label);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_PARTNERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 현장명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={formData.siteName}
|
||||
onChange={(e) => onFieldChange('siteName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 계약번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약번호</Label>
|
||||
<Input
|
||||
value={formData.contractNumber}
|
||||
onChange={(e) => onFieldChange('contractNumber', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공사PM */}
|
||||
<div className="space-y-2">
|
||||
<Label>공사PM</Label>
|
||||
<Select
|
||||
value={formData.constructionPMId}
|
||||
onValueChange={(value) => {
|
||||
onFieldChange('constructionPMId', value);
|
||||
const pm = MOCK_CONSTRUCTION_PM.find((p) => p.value === value);
|
||||
if (pm) {
|
||||
onFieldChange('constructionPM', pm.label);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_CONSTRUCTION_PM.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 공사담당자 */}
|
||||
<div className="space-y-2 md:col-span-2 lg:col-span-4">
|
||||
<Label>공사담당자</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.constructionManagers.map((manager, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={manager}
|
||||
onChange={(e) => {
|
||||
const newManagers = [...formData.constructionManagers];
|
||||
newManagers[index] = e.target.value;
|
||||
onFieldChange('constructionManagers', newManagers);
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
className="w-24"
|
||||
/>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
const newManagers = formData.constructionManagers.filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
onFieldChange('constructionManagers', newManagers);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onFieldChange('constructionManagers', [
|
||||
...formData.constructionManagers,
|
||||
'',
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { OrderDetailFormData, OrderStatus, OrderType } from '../types';
|
||||
import {
|
||||
ORDER_STATUS_OPTIONS,
|
||||
ORDER_TYPE_OPTIONS,
|
||||
MOCK_ORDER_MANAGERS,
|
||||
MOCK_ORDER_COMPANIES,
|
||||
} from '../types';
|
||||
|
||||
interface OrderInfoCardProps {
|
||||
formData: OrderDetailFormData;
|
||||
isViewMode: boolean;
|
||||
onFieldChange: (field: keyof OrderDetailFormData, value: string) => void;
|
||||
}
|
||||
|
||||
export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">발주 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 발주번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발주번호</Label>
|
||||
<Input
|
||||
value={formData.orderNumber}
|
||||
onChange={(e) => onFieldChange('orderNumber', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 발주일 (발주처) */}
|
||||
<div className="space-y-2">
|
||||
<Label>발주일</Label>
|
||||
<Select
|
||||
value={formData.orderCompanyId}
|
||||
onValueChange={(value) => onFieldChange('orderCompanyId', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="회사명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ORDER_COMPANIES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label>구분</Label>
|
||||
<Select
|
||||
value={formData.orderType}
|
||||
onValueChange={(value) => onFieldChange('orderType', value as OrderType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => onFieldChange('status', value as OrderStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 발주담당자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발주담당자</Label>
|
||||
<Select
|
||||
value={formData.orderManager}
|
||||
onValueChange={(value) => onFieldChange('orderManager', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ORDER_MANAGERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.label}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 화물도착지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>화물도착지</Label>
|
||||
<Input
|
||||
value={formData.deliveryAddress}
|
||||
onChange={(e) => onFieldChange('deliveryAddress', e.target.value)}
|
||||
placeholder="주소명"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface OrderMemoCardProps {
|
||||
memo: string;
|
||||
isViewMode: boolean;
|
||||
onMemoChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function OrderMemoCard({ memo, isViewMode, onMemoChange }: OrderMemoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={memo}
|
||||
onChange={(e) => onMemoChange(e.target.value)}
|
||||
disabled={isViewMode}
|
||||
rows={4}
|
||||
placeholder="비고를 입력하세요"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScheduleCalendar, ScheduleEvent } from '@/components/common/ScheduleCalendar';
|
||||
|
||||
interface OrderScheduleCardProps {
|
||||
events: ScheduleEvent[];
|
||||
currentDate: Date;
|
||||
selectedDate: Date | null;
|
||||
onDateClick: (date: Date) => void;
|
||||
onMonthChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
export function OrderScheduleCard({
|
||||
events,
|
||||
currentDate,
|
||||
selectedDate,
|
||||
onDateClick,
|
||||
onMonthChange,
|
||||
}: OrderScheduleCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">발주 스케줄</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScheduleCalendar
|
||||
events={events}
|
||||
badges={[]}
|
||||
currentDate={currentDate}
|
||||
selectedDate={selectedDate}
|
||||
onDateClick={onDateClick}
|
||||
onEventClick={() => {}}
|
||||
onMonthChange={onMonthChange}
|
||||
maxEventsPerDay={3}
|
||||
weekStartsOn={0}
|
||||
isLoading={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface OrderDialogsProps {
|
||||
// 저장 다이얼로그
|
||||
showSaveDialog: boolean;
|
||||
onSaveDialogChange: (open: boolean) => void;
|
||||
onConfirmSave: () => void;
|
||||
// 삭제 다이얼로그
|
||||
showDeleteDialog: boolean;
|
||||
onDeleteDialogChange: (open: boolean) => void;
|
||||
onConfirmDelete: () => void;
|
||||
// 카테고리 삭제 다이얼로그
|
||||
showCategoryDeleteDialog: string | null;
|
||||
onCategoryDeleteDialogChange: (categoryId: string | null) => void;
|
||||
onConfirmDeleteCategory: () => void;
|
||||
// 공통
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function OrderDialogs({
|
||||
showSaveDialog,
|
||||
onSaveDialogChange,
|
||||
onConfirmSave,
|
||||
showDeleteDialog,
|
||||
onDeleteDialogChange,
|
||||
onConfirmDelete,
|
||||
showCategoryDeleteDialog,
|
||||
onCategoryDeleteDialogChange,
|
||||
onConfirmDeleteCategory,
|
||||
isLoading,
|
||||
}: OrderDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={onSaveDialogChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>저장 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>변경사항을 저장하시겠습니까?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirmSave} disabled={isLoading}>
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={onDeleteDialogChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>발주 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 발주를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirmDelete}
|
||||
disabled={isLoading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 카테고리 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog
|
||||
open={!!showCategoryDeleteDialog}
|
||||
onOpenChange={() => onCategoryDeleteDialogChange(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>발주 상세 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 발주 상세를 삭제하시겠습니까? 해당 카테고리의 모든 품목이 삭제됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirmDeleteCategory}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
OrderDetail,
|
||||
OrderDetailFormData,
|
||||
OrderDetailItem,
|
||||
OrderDetailCategory,
|
||||
OrderStatus,
|
||||
OrderType,
|
||||
} from '../types';
|
||||
import {
|
||||
MOCK_PARTNERS,
|
||||
MOCK_CONSTRUCTION_PM,
|
||||
MOCK_CATEGORIES,
|
||||
getEmptyOrderDetailItem,
|
||||
getEmptyOrderDetailCategory,
|
||||
getEmptyOrderDetailFormData,
|
||||
orderDetailToFormData,
|
||||
} from '../types';
|
||||
import { updateOrder, deleteOrder, duplicateOrder } from '../actions';
|
||||
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar';
|
||||
|
||||
interface UseOrderDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
orderId: string;
|
||||
initialData?: OrderDetail;
|
||||
}
|
||||
|
||||
interface UseOrderDetailFormReturn {
|
||||
// Mode flags
|
||||
isViewMode: boolean;
|
||||
isEditMode: boolean;
|
||||
|
||||
// Form data
|
||||
formData: OrderDetailFormData;
|
||||
|
||||
// Loading state
|
||||
isLoading: boolean;
|
||||
|
||||
// Dialog states
|
||||
showDeleteDialog: boolean;
|
||||
setShowDeleteDialog: (show: boolean) => void;
|
||||
showSaveDialog: boolean;
|
||||
setShowSaveDialog: (show: boolean) => void;
|
||||
showCategoryDeleteDialog: string | null;
|
||||
setShowCategoryDeleteDialog: (categoryId: string | null) => void;
|
||||
|
||||
// Modal states
|
||||
showDocumentModal: boolean;
|
||||
setShowDocumentModal: (show: boolean) => void;
|
||||
|
||||
// Selection states
|
||||
selectedItems: Map<string, Set<string>>;
|
||||
addCounts: Map<string, number>;
|
||||
setAddCounts: React.Dispatch<React.SetStateAction<Map<string, number>>>;
|
||||
categoryFilters: Map<string, string>;
|
||||
|
||||
// Calendar states
|
||||
calendarDate: Date;
|
||||
selectedCalendarDate: Date | null;
|
||||
calendarEvents: ScheduleEvent[];
|
||||
|
||||
// Navigation handlers
|
||||
handleBack: () => void;
|
||||
handleEdit: () => void;
|
||||
handleCancel: () => void;
|
||||
|
||||
// Form handlers
|
||||
handleFieldChange: (
|
||||
field: keyof OrderDetailFormData,
|
||||
value: string | number | boolean | string[] | OrderDetailCategory[]
|
||||
) => void;
|
||||
|
||||
// CRUD handlers
|
||||
handleSave: () => void;
|
||||
handleConfirmSave: () => Promise<void>;
|
||||
handleDelete: () => void;
|
||||
handleConfirmDelete: () => Promise<void>;
|
||||
handleDuplicate: () => Promise<void>;
|
||||
handleViewDocument: () => void;
|
||||
|
||||
// Category handlers
|
||||
handleAddCategory: () => void;
|
||||
handleDeleteCategory: (categoryId: string) => void;
|
||||
handleConfirmDeleteCategory: () => void;
|
||||
handleCategoryChange: (
|
||||
categoryId: string,
|
||||
field: keyof OrderDetailCategory,
|
||||
value: string
|
||||
) => void;
|
||||
|
||||
// Item handlers
|
||||
handleAddItems: (categoryId: string, count: number) => void;
|
||||
handleDeleteSelectedItems: (categoryId: string) => void;
|
||||
handleDeleteAllItems: (categoryId: string) => void;
|
||||
handleItemChange: (
|
||||
categoryId: string,
|
||||
itemId: string,
|
||||
field: keyof OrderDetailItem,
|
||||
value: string | number
|
||||
) => void;
|
||||
|
||||
// Selection handlers
|
||||
handleToggleSelection: (categoryId: string, itemId: string) => void;
|
||||
handleToggleSelectAll: (categoryId: string, items: OrderDetailItem[]) => void;
|
||||
|
||||
// Calendar handlers
|
||||
handleCalendarDateClick: (date: Date) => void;
|
||||
handleCalendarMonthChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
export function useOrderDetailForm({
|
||||
mode,
|
||||
orderId,
|
||||
initialData,
|
||||
}: UseOrderDetailFormProps): UseOrderDetailFormReturn {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// Form data
|
||||
const [formData, setFormData] = useState<OrderDetailFormData>(
|
||||
initialData ? orderDetailToFormData(initialData) : getEmptyOrderDetailFormData()
|
||||
);
|
||||
|
||||
// Loading state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Dialog states
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [showCategoryDeleteDialog, setShowCategoryDeleteDialog] = useState<string | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||
|
||||
// Category table selection states
|
||||
const [selectedItems, setSelectedItems] = useState<Map<string, Set<string>>>(new Map());
|
||||
|
||||
// Category add counts
|
||||
const [addCounts, setAddCounts] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Category filters
|
||||
const [categoryFilters, setCategoryFilters] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Calendar states
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||
|
||||
// ============================================
|
||||
// Navigation handlers
|
||||
// ============================================
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/order/order-management');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/order/order-management/${orderId}/edit`);
|
||||
}, [router, orderId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/juil/order/order-management/${orderId}`);
|
||||
}, [router, orderId]);
|
||||
|
||||
// ============================================
|
||||
// Form field handlers
|
||||
// ============================================
|
||||
const handleFieldChange = useCallback(
|
||||
(
|
||||
field: keyof OrderDetailFormData,
|
||||
value: string | number | boolean | string[] | OrderDetailCategory[]
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Save handlers
|
||||
// ============================================
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateOrder(orderId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/juil/order/order-management/${orderId}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, orderId, formData]);
|
||||
|
||||
// ============================================
|
||||
// Delete handlers
|
||||
// ============================================
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteOrder(orderId);
|
||||
if (result.success) {
|
||||
toast.success('발주가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/juil/order/order-management');
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, orderId]);
|
||||
|
||||
// ============================================
|
||||
// Duplicate handler
|
||||
// ============================================
|
||||
const handleDuplicate = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await duplicateOrder(orderId);
|
||||
if (result.success && result.newId) {
|
||||
toast.success('발주가 복제되었습니다.');
|
||||
router.push(`/ko/juil/order/order-management/${result.newId}/edit`);
|
||||
} else {
|
||||
toast.error(result.error || '복제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '복제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, orderId]);
|
||||
|
||||
// ============================================
|
||||
// Document modal handler
|
||||
// ============================================
|
||||
const handleViewDocument = useCallback(() => {
|
||||
setShowDocumentModal(true);
|
||||
}, []);
|
||||
|
||||
// ============================================
|
||||
// Category handlers
|
||||
// ============================================
|
||||
const handleAddCategory = useCallback(() => {
|
||||
const newCategory = getEmptyOrderDetailCategory();
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderCategories: [...prev.orderCategories, newCategory],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDeleteCategory = useCallback((categoryId: string) => {
|
||||
setShowCategoryDeleteDialog(categoryId);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDeleteCategory = useCallback(() => {
|
||||
if (showCategoryDeleteDialog) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderCategories: prev.orderCategories.filter(
|
||||
(cat) => cat.id !== showCategoryDeleteDialog
|
||||
),
|
||||
}));
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(showCategoryDeleteDialog);
|
||||
return newMap;
|
||||
});
|
||||
setShowCategoryDeleteDialog(null);
|
||||
}
|
||||
}, [showCategoryDeleteDialog]);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(categoryId: string, field: keyof OrderDetailCategory, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderCategories: prev.orderCategories.map((cat) =>
|
||||
cat.id === categoryId ? { ...cat, [field]: value } : cat
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Item handlers
|
||||
// ============================================
|
||||
const handleAddItems = useCallback((categoryId: string, count: number) => {
|
||||
if (count <= 0) {
|
||||
toast.warning('추가할 개수를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
const newItems: OrderDetailItem[] = Array.from({ length: count }, () => ({
|
||||
...getEmptyOrderDetailItem(),
|
||||
id: String(Date.now() + Math.random()),
|
||||
}));
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderCategories: prev.orderCategories.map((cat) =>
|
||||
cat.id === categoryId ? { ...cat, items: [...cat.items, ...newItems] } : cat
|
||||
),
|
||||
}));
|
||||
setAddCounts((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(categoryId, 1);
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteSelectedItems = useCallback(
|
||||
(categoryId: string) => {
|
||||
const selected = selectedItems.get(categoryId);
|
||||
if (!selected || selected.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderCategories: prev.orderCategories.map((cat) =>
|
||||
cat.id === categoryId
|
||||
? { ...cat, items: cat.items.filter((item) => !selected.has(item.id)) }
|
||||
: cat
|
||||
),
|
||||
}));
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(categoryId, new Set());
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
[selectedItems]
|
||||
);
|
||||
|
||||
const handleDeleteAllItems = useCallback((categoryId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderCategories: prev.orderCategories.map((cat) =>
|
||||
cat.id === categoryId ? { ...cat, items: [] } : cat
|
||||
),
|
||||
}));
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(categoryId, new Set());
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleItemChange = useCallback(
|
||||
(
|
||||
categoryId: string,
|
||||
itemId: string,
|
||||
field: keyof OrderDetailItem,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderCategories: prev.orderCategories.map((cat) =>
|
||||
cat.id === categoryId
|
||||
? {
|
||||
...cat,
|
||||
items: cat.items.map((item) =>
|
||||
item.id === itemId ? { ...item, [field]: value } : item
|
||||
),
|
||||
}
|
||||
: cat
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Selection handlers
|
||||
// ============================================
|
||||
const handleToggleSelection = useCallback((categoryId: string, itemId: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const categorySet = newMap.get(categoryId) || new Set();
|
||||
const newSet = new Set(categorySet);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
newMap.set(categoryId, newSet);
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(
|
||||
(categoryId: string, items: OrderDetailItem[]) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const categorySet = newMap.get(categoryId) || new Set();
|
||||
if (categorySet.size === items.length) {
|
||||
newMap.set(categoryId, new Set());
|
||||
} else {
|
||||
newMap.set(categoryId, new Set(items.map((item) => item.id)));
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Calendar handlers
|
||||
// ============================================
|
||||
const calendarEvents: ScheduleEvent[] = useMemo(() => {
|
||||
if (!initialData?.scheduleEvents) return [];
|
||||
return initialData.scheduleEvents.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
color: event.color,
|
||||
status: 'order_complete' as const,
|
||||
data: event,
|
||||
}));
|
||||
}, [initialData?.scheduleEvents]);
|
||||
|
||||
const handleCalendarDateClick = useCallback((date: Date) => {
|
||||
setSelectedCalendarDate((prev) => {
|
||||
if (prev && prev.getTime() === date.getTime()) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCalendarMonthChange = useCallback((date: Date) => {
|
||||
setCalendarDate(date);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Mode flags
|
||||
isViewMode,
|
||||
isEditMode,
|
||||
|
||||
// Form data
|
||||
formData,
|
||||
|
||||
// Loading state
|
||||
isLoading,
|
||||
|
||||
// Dialog states
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
showCategoryDeleteDialog,
|
||||
setShowCategoryDeleteDialog,
|
||||
|
||||
// Modal states
|
||||
showDocumentModal,
|
||||
setShowDocumentModal,
|
||||
|
||||
// Selection states
|
||||
selectedItems,
|
||||
addCounts,
|
||||
setAddCounts,
|
||||
categoryFilters,
|
||||
|
||||
// Calendar states
|
||||
calendarDate,
|
||||
selectedCalendarDate,
|
||||
calendarEvents,
|
||||
|
||||
// Navigation handlers
|
||||
handleBack,
|
||||
handleEdit,
|
||||
handleCancel,
|
||||
|
||||
// Form handlers
|
||||
handleFieldChange,
|
||||
|
||||
// CRUD handlers
|
||||
handleSave,
|
||||
handleConfirmSave,
|
||||
handleDelete,
|
||||
handleConfirmDelete,
|
||||
handleDuplicate,
|
||||
handleViewDocument,
|
||||
|
||||
// Category handlers
|
||||
handleAddCategory,
|
||||
handleDeleteCategory,
|
||||
handleConfirmDeleteCategory,
|
||||
handleCategoryChange,
|
||||
|
||||
// Item handlers
|
||||
handleAddItems,
|
||||
handleDeleteSelectedItems,
|
||||
handleDeleteAllItems,
|
||||
handleItemChange,
|
||||
|
||||
// Selection handlers
|
||||
handleToggleSelection,
|
||||
handleToggleSelectAll,
|
||||
|
||||
// Calendar handlers
|
||||
handleCalendarDateClick,
|
||||
handleCalendarMonthChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Trash2, Image as ImageIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { OrderDetailCategory, OrderDetailItem, OrderStatus } from '../types';
|
||||
import {
|
||||
ORDER_STATUS_OPTIONS,
|
||||
ORDER_STATUS_LABELS,
|
||||
ORDER_STATUS_STYLES,
|
||||
MOCK_WORK_TEAM_LEADERS,
|
||||
MOCK_CATEGORIES,
|
||||
MOCK_ITEM_NAMES,
|
||||
} from '../types';
|
||||
|
||||
interface OrderDetailItemTableProps {
|
||||
category: OrderDetailCategory;
|
||||
isEditMode: boolean;
|
||||
isViewMode: boolean;
|
||||
selectedItems: Set<string>;
|
||||
addCount: number;
|
||||
onAddCountChange: (count: number) => void;
|
||||
onAddItems: (count: number) => void;
|
||||
onDeleteSelectedItems: () => void;
|
||||
onDeleteAllItems: () => void;
|
||||
onCategoryChange: (field: keyof OrderDetailCategory, value: string) => void;
|
||||
onItemChange: (itemId: string, field: keyof OrderDetailItem, value: string | number) => void;
|
||||
onToggleSelection: (itemId: string) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
}
|
||||
|
||||
export function OrderDetailItemTable({
|
||||
category,
|
||||
isEditMode,
|
||||
isViewMode,
|
||||
selectedItems,
|
||||
addCount,
|
||||
onAddCountChange,
|
||||
onAddItems,
|
||||
onDeleteSelectedItems,
|
||||
onDeleteAllItems,
|
||||
onCategoryChange,
|
||||
onItemChange,
|
||||
onToggleSelection,
|
||||
onToggleSelectAll,
|
||||
}: OrderDetailItemTableProps) {
|
||||
const renderStatusBadge = (status: OrderStatus) => {
|
||||
return (
|
||||
<Badge className={ORDER_STATUS_STYLES[status]}>{ORDER_STATUS_LABELS[status]}</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
{/* 왼쪽: 발주 상세, N건 선택, 삭제 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<CardTitle className="text-lg">발주 상세</CardTitle>
|
||||
{isEditMode && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedItems.size}건 선택
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onDeleteSelectedItems}
|
||||
disabled={selectedItems.size === 0}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 오른쪽 끝: 숫자, 추가, 카테고리, 🗑️ */}
|
||||
{isEditMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={addCount}
|
||||
onChange={(e) => onAddCountChange(parseInt(e.target.value) || 1)}
|
||||
className="w-16 h-8"
|
||||
/>
|
||||
<Button variant="default" size="sm" onClick={() => onAddItems(addCount)}>
|
||||
추가
|
||||
</Button>
|
||||
<Select
|
||||
value={category.categoryId || 'none'}
|
||||
onValueChange={(value) => {
|
||||
onCategoryChange('categoryId', value);
|
||||
const cat = MOCK_CATEGORIES.find((c) => c.value === value);
|
||||
if (cat) {
|
||||
onCategoryChange('categoryName', cat.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-8">
|
||||
<SelectValue placeholder="카테고리명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_CATEGORIES.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onDeleteAllItems}
|
||||
className="text-destructive h-8 w-8"
|
||||
title="전체 삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{isEditMode && (
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
category.items.length > 0 &&
|
||||
selectedItems.size === category.items.length
|
||||
}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="w-[50px] text-center">번호</TableHead>
|
||||
<TableHead className="w-[100px]">작업반장</TableHead>
|
||||
<TableHead className="w-[110px]">시공투입일</TableHead>
|
||||
<TableHead className="w-[110px]">시공완료일</TableHead>
|
||||
<TableHead className="w-[120px]">명칭</TableHead>
|
||||
<TableHead className="w-[120px]">제품</TableHead>
|
||||
<TableHead className="w-[70px] text-right">가로</TableHead>
|
||||
<TableHead className="w-[70px] text-right">세로</TableHead>
|
||||
<TableHead className="w-[150px]">항목명</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
<TableHead className="w-[100px]">비고</TableHead>
|
||||
<TableHead className="w-[60px] text-center">이미지</TableHead>
|
||||
<TableHead className="w-[110px]">발주일</TableHead>
|
||||
<TableHead className="w-[110px]">계획납품일</TableHead>
|
||||
<TableHead className="w-[110px]">실제납품일</TableHead>
|
||||
<TableHead className="w-[100px]">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{category.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isEditMode ? 18 : 17}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
등록된 발주 품목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
category.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
{isEditMode && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
onCheckedChange={() => onToggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Select
|
||||
value={item.workTeamLeader || 'none'}
|
||||
onValueChange={(value) =>
|
||||
onItemChange(item.id, 'workTeamLeader', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_WORK_TEAM_LEADERS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
item.workTeamLeader || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={item.constructionStartDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'constructionStartDate', e.target.value)
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.constructionStartDate || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={item.constructionEndDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'constructionEndDate', e.target.value)
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.constructionEndDate || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={item.name}
|
||||
onChange={(e) => onItemChange(item.id, 'name', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.name || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={item.product}
|
||||
onChange={(e) => onItemChange(item.id, 'product', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.product || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={item.width}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'width', parseInt(e.target.value) || 0)
|
||||
}
|
||||
className="h-8 text-right"
|
||||
/>
|
||||
) : (
|
||||
item.width || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={item.height}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'height', parseInt(e.target.value) || 0)
|
||||
}
|
||||
className="h-8 text-right"
|
||||
/>
|
||||
) : (
|
||||
item.height || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Select
|
||||
value={item.itemName || 'none'}
|
||||
onValueChange={(value) => onItemChange(item.id, 'itemName', value)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ITEM_NAMES.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
item.itemName || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'quantity', parseInt(e.target.value) || 0)
|
||||
}
|
||||
className="h-8 text-right"
|
||||
/>
|
||||
) : (
|
||||
item.quantity
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={item.unit}
|
||||
onChange={(e) => onItemChange(item.id, 'unit', e.target.value)}
|
||||
className="h-8 w-14"
|
||||
/>
|
||||
) : (
|
||||
item.unit || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={item.remark}
|
||||
onChange={(e) => onItemChange(item.id, 'remark', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.remark || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.imageUrl ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={item.orderDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'orderDate', e.target.value)
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.orderDate || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={item.plannedDeliveryDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'plannedDeliveryDate', e.target.value)
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.plannedDeliveryDate || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
value={item.actualDeliveryDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'actualDeliveryDate', e.target.value)
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
item.actualDeliveryDate || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Select
|
||||
value={item.status}
|
||||
onValueChange={(value) =>
|
||||
onItemChange(item.id, 'status', value as OrderStatus)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_STATUS_OPTIONS.filter((o) => o.value !== 'all').map(
|
||||
(opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
renderStatusBadge(item.status)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
{/* 합계 행 */}
|
||||
{category.items.length > 0 && (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell colSpan={isEditMode ? 10 : 9} className="text-right">
|
||||
합계
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{category.items.reduce((sum, item) => sum + item.quantity, 0)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={isEditMode ? 7 : 7}></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Plus, X, Loader2, Upload, FileText, Image as ImageIcon, Download } from 'lucide-react';
|
||||
import { Building2, Plus, X, Loader2, Upload, FileText, Image as ImageIcon, Download, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -360,9 +360,10 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
ㅇ <Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -372,6 +373,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -381,7 +383,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
@@ -394,7 +396,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
@@ -723,7 +725,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddMemo}
|
||||
className="bg-orange-500 hover:bg-orange-600 self-end"
|
||||
className="bg-blue-500 hover:bg-blue-600 self-end"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
@@ -875,7 +877,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { DollarSign, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -283,6 +283,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
{isViewMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(true)}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download } from 'lucide-react';
|
||||
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -326,9 +326,10 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -338,6 +339,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -347,7 +349,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
@@ -360,7 +362,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
@@ -725,7 +727,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Upload, Mic, X, FileText, Download } from 'lucide-react';
|
||||
import { Building2, Upload, Mic, X, FileText, Download, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -228,6 +228,7 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
|
||||
!isEditMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => router.push('/ko/juil/order/site-management')}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEditClick}>수정</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck, Upload, X, FileText, Download } from 'lucide-react';
|
||||
import { ClipboardCheck, Upload, X, FileText, Download, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -279,6 +279,7 @@ export default function StructureReviewDetailForm({
|
||||
isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleGoToList}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleDeleteClick}>
|
||||
|
||||
@@ -18,6 +18,7 @@ export function CalendarHeader({
|
||||
onPrevMonth,
|
||||
onNextMonth,
|
||||
onViewChange,
|
||||
titleSlot,
|
||||
filterSlot,
|
||||
}: CalendarHeaderProps) {
|
||||
const views: { value: CalendarView; label: string }[] = [
|
||||
@@ -27,29 +28,34 @@ export function CalendarHeader({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-3 border-b">
|
||||
{/* 좌측: 년월 네비게이션 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
||||
onClick={onPrevMonth}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 좌측: 타이틀 + 년월 네비게이션 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{titleSlot && (
|
||||
<span className="text-base font-semibold text-foreground">{titleSlot}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
||||
onClick={onPrevMonth}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-lg font-bold min-w-[120px] text-center">
|
||||
{formatYearMonth(currentDate)}
|
||||
</span>
|
||||
<span className="text-lg font-bold min-w-[120px] text-center">
|
||||
{formatYearMonth(currentDate)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
||||
onClick={onNextMonth}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 hover:bg-primary/10"
|
||||
onClick={onNextMonth}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 뷰 전환 + 필터 */}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function ScheduleCalendar({
|
||||
onEventClick,
|
||||
onMonthChange,
|
||||
onViewChange,
|
||||
titleSlot,
|
||||
filterSlot,
|
||||
maxEventsPerDay = 3,
|
||||
weekStartsOn = 0,
|
||||
@@ -115,6 +116,7 @@ export function ScheduleCalendar({
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
onViewChange={handleViewChange}
|
||||
titleSlot={titleSlot}
|
||||
filterSlot={filterSlot}
|
||||
/>
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface ScheduleCalendarProps {
|
||||
onMonthChange?: (date: Date) => void;
|
||||
/** 뷰 모드 변경 핸들러 */
|
||||
onViewChange?: (view: CalendarView) => void;
|
||||
/** 타이틀 영역 (년월 네비게이션 왼쪽) */
|
||||
titleSlot?: React.ReactNode;
|
||||
/** 필터 영역 (slot) */
|
||||
filterSlot?: React.ReactNode;
|
||||
/** 최대 표시 이벤트 수 (초과 시 +N 표시) */
|
||||
@@ -82,6 +84,8 @@ export interface CalendarHeaderProps {
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
onViewChange: (view: CalendarView) => void;
|
||||
/** 타이틀 영역 (년월 네비게이션 왼쪽) */
|
||||
titleSlot?: React.ReactNode;
|
||||
filterSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,15 @@ function transformFrontendToApi(data: AttendanceFormData): Record<string, unknow
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 분을 휴게시간 문자열로 변환 (예: 90 -> "1:30")
|
||||
*/
|
||||
function formatMinutesToBreakTime(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분을 시간 문자열로 변환 (예: 210 -> "3시간 30분")
|
||||
*/
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
EMPLOYEE_STATUS_LABELS,
|
||||
DEFAULT_FIELD_SETTINGS,
|
||||
} from './types';
|
||||
import { getPositions, getDepartments, type PositionItem, type DepartmentItem } from './actions';
|
||||
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
|
||||
import { getProfileImageUrl } from './utils';
|
||||
|
||||
interface EmployeeFormProps {
|
||||
@@ -464,8 +464,10 @@ export function EmployeeForm({
|
||||
if (file) {
|
||||
// 미리보기 즉시 표시
|
||||
handleChange('profileImage', URL.createObjectURL(file));
|
||||
// 서버에 업로드
|
||||
const result = await uploadProfileImage(file);
|
||||
// 서버에 업로드 (FormData로 감싸서 전송)
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const result = await uploadProfileImage(formData);
|
||||
if (result.success && result.data?.url) {
|
||||
handleChange('profileImage', result.data.url);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
|
||||
import type { Employee, EmployeeFormData, EmployeeStats } from './types';
|
||||
import { transformApiToFrontend, transformFrontendToApi, type EmployeeApiData } from './utils';
|
||||
|
||||
@@ -365,7 +365,7 @@ export interface PositionItem {
|
||||
*/
|
||||
export async function getPositions(type?: 'rank' | 'title'): Promise<PositionItem[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const headers = await getServerApiHeaders();
|
||||
const searchParams = new URLSearchParams();
|
||||
if (type) {
|
||||
searchParams.set('type', type);
|
||||
@@ -414,7 +414,7 @@ export interface DepartmentItem {
|
||||
*/
|
||||
export async function getDepartments(): Promise<DepartmentItem[]> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const headers = await getServerApiHeaders();
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments`,
|
||||
@@ -449,7 +449,7 @@ export async function getDepartments(): Promise<DepartmentItem[]> {
|
||||
// 파일 업로드
|
||||
// ============================================
|
||||
|
||||
export async function uploadProfileImage(formData: FormData): Promise<{
|
||||
export async function uploadProfileImage(inputFormData: FormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { url: string; path: string };
|
||||
error?: string;
|
||||
@@ -464,9 +464,8 @@ export async function uploadProfileImage(formData: FormData): Promise<{
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('directory', 'employees/profiles');
|
||||
// 디렉토리 정보 추가
|
||||
inputFormData.append('directory', 'employees/profiles');
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`,
|
||||
@@ -476,7 +475,7 @@ export async function uploadProfileImage(formData: FormData): Promise<{
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: formData,
|
||||
body: inputFormData,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ function generateLotNo(): string {
|
||||
|
||||
// 기본 검사 항목
|
||||
const defaultInspectionItems: InspectionCheckItem[] = [
|
||||
{ id: '1', name: '겉모양', specification: '외관 이상 없음', method: '육안', judgment: null, remark: '' },
|
||||
{ id: '2', name: '두께', specification: 't 1.0', method: '계측', judgment: null, remark: '' },
|
||||
{ id: '3', name: '폭', specification: 'W 1,000mm', method: '계측', judgment: null, remark: '' },
|
||||
{ id: '4', name: '길이', specification: 'L 2,000mm', method: '계측', judgment: null, remark: '' },
|
||||
{ id: '1', name: '겉모양', specification: '외관 이상 없음', method: '육안', judgment: '', remark: '' },
|
||||
{ id: '2', name: '두께', specification: 't 1.0', method: '계측', judgment: '', remark: '' },
|
||||
{ id: '3', name: '폭', specification: 'W 1,000mm', method: '계측', judgment: '', remark: '' },
|
||||
{ id: '4', name: '길이', specification: 'L 2,000mm', method: '계측', judgment: '', remark: '' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Package, AlertCircle, Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -109,6 +109,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
<h1 className="text-xl font-semibold">재고 상세</h1>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
@@ -140,6 +141,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ interface StatCardsProps {
|
||||
|
||||
export function StatCards({ stats }: StatCardsProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-3 md:gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const isClickable = !!stat.onClick;
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
PRIORITY_STYLES,
|
||||
DELIVERY_METHOD_LABELS,
|
||||
} from './types';
|
||||
import type { ShipmentItem, ShipmentStatus, ShipmentStats } from './types';
|
||||
import type { ShipmentItem, ShipmentStatus, ShipmentStats, ShipmentStatusStats } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
@@ -55,7 +55,7 @@ export function ShipmentList() {
|
||||
// API 데이터 상태
|
||||
const [items, setItems] = useState<ShipmentItem[]>([]);
|
||||
const [shipmentStats, setShipmentStats] = useState<ShipmentStats | null>(null);
|
||||
const [statusStats, setStatusStats] = useState<Record<string, { label: string; count: number }>>({});
|
||||
const [statusStats, setStatusStats] = useState<ShipmentStatusStats>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
ShipmentDetail,
|
||||
ShipmentProduct,
|
||||
ShipmentStats,
|
||||
ShipmentStatusStats,
|
||||
ShipmentStatus,
|
||||
ShipmentPriority,
|
||||
DeliveryMethod,
|
||||
@@ -184,15 +185,38 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 (통계용) =====
|
||||
function transformApiToStats(data: ShipmentApiStatsResponse): ShipmentStats {
|
||||
function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: number }): ShipmentStats {
|
||||
return {
|
||||
todayShipmentCount: data.today_shipment_count,
|
||||
scheduledCount: data.scheduled_count,
|
||||
shippingCount: data.shipping_count,
|
||||
urgentCount: data.urgent_count,
|
||||
totalCount: data.total_count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 (상태별 통계용) =====
|
||||
const STATUS_TAB_LABELS: Record<string, string> = {
|
||||
all: '전체',
|
||||
scheduled: '출고예정',
|
||||
ready: '출하대기',
|
||||
shipping: '배송중',
|
||||
completed: '배송완료',
|
||||
};
|
||||
|
||||
function transformApiToStatsByStatus(data: ShipmentApiStatsByStatusResponse): ShipmentStatusStats {
|
||||
const result: ShipmentStatusStats = {};
|
||||
for (const [key, count] of Object.entries(data)) {
|
||||
if (key !== 'all') { // all은 탭에서 제외 (전체 탭은 별도 처리)
|
||||
result[key] = {
|
||||
label: STATUS_TAB_LABELS[key] || key,
|
||||
count: count as number,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 (등록용) =====
|
||||
function transformCreateFormToApi(
|
||||
data: ShipmentCreateFormData
|
||||
@@ -400,7 +424,7 @@ export async function getShipmentStats(): Promise<{
|
||||
// ===== 상태별 통계 조회 (탭용) =====
|
||||
export async function getShipmentStatsByStatus(): Promise<{
|
||||
success: boolean;
|
||||
data?: ShipmentApiStatsByStatusResponse;
|
||||
data?: ShipmentStatusStats;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
@@ -428,7 +452,7 @@ export async function getShipmentStatsByStatus(): Promise<{
|
||||
return { success: false, error: result.message || '상태별 통계 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
return { success: true, data: transformApiToStatsByStatus(result.data) };
|
||||
} catch (error) {
|
||||
console.error('[ShipmentActions] getShipmentStatsByStatus error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
|
||||
@@ -18,6 +18,7 @@ export const mockStats: ShipmentStats = {
|
||||
scheduledCount: 5,
|
||||
shippingCount: 1,
|
||||
urgentCount: 4,
|
||||
totalCount: 20,
|
||||
};
|
||||
|
||||
// 필터 탭
|
||||
|
||||
@@ -156,6 +156,15 @@ export interface ShipmentStats {
|
||||
scheduledCount: number; // 출고 대기
|
||||
shippingCount: number; // 배송중
|
||||
urgentCount: number; // 긴급 출하
|
||||
totalCount: number; // 전체 건수
|
||||
}
|
||||
|
||||
// 상태별 통계 (탭용)
|
||||
export interface ShipmentStatusStats {
|
||||
[key: string]: {
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
|
||||
@@ -75,10 +75,8 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
useEffect(() => {
|
||||
const loadDepartments = async () => {
|
||||
setIsDepartmentsLoading(true);
|
||||
const result = await getDepartmentOptions();
|
||||
if (result.success && result.data) {
|
||||
setDepartmentOptions(result.data);
|
||||
}
|
||||
const departments = await getDepartmentOptions();
|
||||
setDepartmentOptions(departments);
|
||||
setIsDepartmentsLoading(false);
|
||||
};
|
||||
loadDepartments();
|
||||
@@ -256,8 +254,8 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departmentOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={opt.name}>
|
||||
{opt.name}
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -83,14 +83,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
// 품목 목록 로드 (debounced)
|
||||
const loadItems = useCallback(async (q?: string, itemType?: string) => {
|
||||
setIsItemsLoading(true);
|
||||
const result = await getItemList({
|
||||
const items = await getItemList({
|
||||
q: q || undefined,
|
||||
itemType: itemType === 'all' ? undefined : itemType,
|
||||
size: 100,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
setItemList(result.data);
|
||||
}
|
||||
setItemList(items);
|
||||
setIsItemsLoading(false);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -449,3 +449,110 @@ export async function getProcessStats(): Promise<{
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 부서 옵션 타입 및 함수
|
||||
// ============================================================================
|
||||
|
||||
export interface DepartmentOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 조회
|
||||
*/
|
||||
export async function getDepartmentOptions(): Promise<DepartmentOption[]> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response?.ok) {
|
||||
// 기본 부서 옵션 반환
|
||||
return [
|
||||
{ value: '생산부', label: '생산부' },
|
||||
{ value: '품질관리부', label: '품질관리부' },
|
||||
{ value: '물류부', label: '물류부' },
|
||||
{ value: '영업부', label: '영업부' },
|
||||
];
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
return result.data.map((dept: { id: number; name: string }) => ({
|
||||
value: dept.name,
|
||||
label: dept.name,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('[getDepartmentOptions] Error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 품목 옵션 타입 및 함수
|
||||
// ============================================================================
|
||||
|
||||
export interface ItemOption {
|
||||
value: string;
|
||||
label: string;
|
||||
code: string;
|
||||
id: string;
|
||||
fullName: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface GetItemListParams {
|
||||
q?: string;
|
||||
itemType?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 목록 조회 (분류 규칙용)
|
||||
*/
|
||||
export async function getItemList(params?: GetItemListParams): Promise<ItemOption[]> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('size', String(params?.size || 1000));
|
||||
if (params?.q) searchParams.set('q', params.q);
|
||||
if (params?.itemType) searchParams.set('item_type', params.itemType);
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response?.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data?.data) {
|
||||
return result.data.data.map((item: { id: number; item_name: string; item_code?: string; item_type?: string }) => ({
|
||||
value: String(item.id),
|
||||
label: item.item_name,
|
||||
code: item.item_code || '',
|
||||
id: String(item.id),
|
||||
fullName: item.item_name,
|
||||
type: item.item_type || '',
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('[getItemList] Error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export { QuoteManagementClient } from './QuoteManagementClient';
|
||||
|
||||
// 기존 컴포넌트
|
||||
export { default as QuoteDocument } from './QuoteDocument';
|
||||
export { QuoteDocument } from './QuoteDocument';
|
||||
export { QuoteRegistration, INITIAL_QUOTE_FORM } from './QuoteRegistration';
|
||||
export { QuoteCalculationReport } from './QuoteCalculationReport';
|
||||
export { PurchaseOrderDocument } from './PurchaseOrderDocument';
|
||||
|
||||
@@ -59,7 +59,7 @@ export function AccountManagement() {
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
@@ -69,7 +69,7 @@ export function AccountManagement() {
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
|
||||
|
||||
// API 데이터
|
||||
@@ -98,7 +98,7 @@ export function AccountManagement() {
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: number) => {
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
@@ -132,7 +132,7 @@ export function AccountManagement() {
|
||||
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(filteredData.map(item => item.id)));
|
||||
setSelectedItems(new Set(filteredData.map(item => String(item.id))));
|
||||
}
|
||||
}, [selectedItems.size, filteredData]);
|
||||
|
||||
@@ -145,7 +145,7 @@ export function AccountManagement() {
|
||||
router.push(`/ko/settings/accounts/${item.id}?mode=edit`);
|
||||
}, [router]);
|
||||
|
||||
const handleDeleteClick = useCallback((id: number) => {
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
@@ -155,10 +155,10 @@ export function AccountManagement() {
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteBankAccount(deleteTargetId);
|
||||
const result = await deleteBankAccount(Number(deleteTargetId));
|
||||
if (result.success) {
|
||||
toast.success('계좌가 삭제되었습니다.');
|
||||
setData(prev => prev.filter(item => item.id !== deleteTargetId));
|
||||
setData(prev => prev.filter(item => String(item.id) !== deleteTargetId));
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
@@ -183,7 +183,7 @@ export function AccountManagement() {
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleConfirmBulkDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedItems);
|
||||
const ids = Array.from(selectedItems).map(id => Number(id));
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteBankAccounts(ids);
|
||||
@@ -192,7 +192,7 @@ export function AccountManagement() {
|
||||
if (result.error) {
|
||||
toast.warning(result.error);
|
||||
}
|
||||
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
|
||||
setData(prev => prev.filter(item => !selectedItems.has(String(item.id))));
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '계좌 삭제에 실패했습니다.');
|
||||
@@ -222,7 +222,8 @@ export function AccountManagement() {
|
||||
|
||||
// ===== 테이블 행 렌더링 =====
|
||||
const renderTableRow = useCallback((item: Account, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
const itemIdStr = String(item.id);
|
||||
const isSelected = selectedItems.has(itemIdStr);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -231,7 +232,7 @@ export function AccountManagement() {
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(itemIdStr)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-center">{globalIndex}</TableCell>
|
||||
<TableCell>{BANK_LABELS[item.bankCode] || item.bankCode}</TableCell>
|
||||
@@ -257,7 +258,7 @@ export function AccountManagement() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClick(item.id)}
|
||||
onClick={() => handleDeleteClick(itemIdStr)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
@@ -279,7 +280,7 @@ export function AccountManagement() {
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
id={String(item.id)}
|
||||
title={item.accountName}
|
||||
headerBadges={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -321,7 +322,7 @@ export function AccountManagement() {
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteClick(item.id); }}
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteClick(String(item.id)); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
@@ -358,7 +359,7 @@ export function AccountManagement() {
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item: Account) => item.id}
|
||||
getItemId={(item: Account) => String(item.id)}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 알림설정 페이지 클라이언트 컴포넌트
|
||||
*
|
||||
* 각 알림 유형별로 ON/OFF 토글과 이메일 알림 체크박스를 제공합니다.
|
||||
* 클라이언트에서 프록시를 통해 API 호출 (토큰 갱신 자동 처리)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Bell, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'sonner';
|
||||
import type { NotificationSettings, NotificationItem } from './types';
|
||||
import { DEFAULT_NOTIFICATION_SETTINGS } from './types';
|
||||
|
||||
// ===== 알림 항목 컴포넌트 =====
|
||||
interface NotificationItemRowProps {
|
||||
label: string;
|
||||
item: NotificationItem;
|
||||
onChange: (item: NotificationItem) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function NotificationItemRow({ label, item, onChange, disabled }: NotificationItemRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<span className="text-sm min-w-[160px]">{label}</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={item.email}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, email: checked === true })
|
||||
}
|
||||
disabled={disabled || !item.enabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">이메일</span>
|
||||
</label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, enabled: checked, email: checked ? item.email : false })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 알림 섹션 컴포넌트 =====
|
||||
interface NotificationSectionProps {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function NotificationSection({ title, enabled, onEnabledChange, children }: NotificationSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between px-6 pt-6 pb-3">
|
||||
<CardTitle className="text-base font-medium">{title}</CardTitle>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={onEnabledChange}
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="pt-0">
|
||||
<div className="pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== API 응답과 기본값 병합 =====
|
||||
function mergeWithDefaults(apiData: Partial<NotificationSettings>): NotificationSettings {
|
||||
return {
|
||||
notice: {
|
||||
enabled: apiData.notice?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.notice.enabled,
|
||||
notice: apiData.notice?.notice ?? DEFAULT_NOTIFICATION_SETTINGS.notice.notice,
|
||||
event: apiData.notice?.event ?? DEFAULT_NOTIFICATION_SETTINGS.notice.event,
|
||||
},
|
||||
schedule: {
|
||||
enabled: apiData.schedule?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.enabled,
|
||||
vatReport: apiData.schedule?.vatReport ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.vatReport,
|
||||
incomeTaxReport: apiData.schedule?.incomeTaxReport ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.incomeTaxReport,
|
||||
},
|
||||
vendor: {
|
||||
enabled: apiData.vendor?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.enabled,
|
||||
newVendor: apiData.vendor?.newVendor ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.newVendor,
|
||||
creditRating: apiData.vendor?.creditRating ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.creditRating,
|
||||
},
|
||||
attendance: {
|
||||
enabled: apiData.attendance?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.enabled,
|
||||
annualLeave: apiData.attendance?.annualLeave ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.annualLeave,
|
||||
clockIn: apiData.attendance?.clockIn ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.clockIn,
|
||||
late: apiData.attendance?.late ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.late,
|
||||
absent: apiData.attendance?.absent ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.absent,
|
||||
},
|
||||
order: {
|
||||
enabled: apiData.order?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.order.enabled,
|
||||
salesOrder: apiData.order?.salesOrder ?? DEFAULT_NOTIFICATION_SETTINGS.order.salesOrder,
|
||||
purchaseOrder: apiData.order?.purchaseOrder ?? DEFAULT_NOTIFICATION_SETTINGS.order.purchaseOrder,
|
||||
},
|
||||
approval: {
|
||||
enabled: apiData.approval?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.approval.enabled,
|
||||
approvalRequest: apiData.approval?.approvalRequest ?? DEFAULT_NOTIFICATION_SETTINGS.approval.approvalRequest,
|
||||
draftApproved: apiData.approval?.draftApproved ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftApproved,
|
||||
draftRejected: apiData.approval?.draftRejected ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftRejected,
|
||||
draftCompleted: apiData.approval?.draftCompleted ?? DEFAULT_NOTIFICATION_SETTINGS.approval.draftCompleted,
|
||||
},
|
||||
production: {
|
||||
enabled: apiData.production?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.production.enabled,
|
||||
safetyStock: apiData.production?.safetyStock ?? DEFAULT_NOTIFICATION_SETTINGS.production.safetyStock,
|
||||
productionComplete: apiData.production?.productionComplete ?? DEFAULT_NOTIFICATION_SETTINGS.production.productionComplete,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function NotificationSettingsClient() {
|
||||
const [settings, setSettings] = useState<NotificationSettings>(DEFAULT_NOTIFICATION_SETTINGS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// ===== 데이터 로드 (프록시 패턴) =====
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/proxy/settings/notifications', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
setSettings(mergeWithDefaults(result.data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationSettings] Load error:', error);
|
||||
toast.error('알림 설정을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// ===== 공지 알림 핸들러 =====
|
||||
const handleNoticeEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
notice: {
|
||||
...prev.notice,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
notice: { ...prev.notice.notice, enabled: false, email: false },
|
||||
event: { ...prev.notice.event, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleNoticeItemChange = useCallback((key: 'notice' | 'event', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
notice: { ...prev.notice, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 일정 알림 핸들러 =====
|
||||
const handleScheduleEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
schedule: {
|
||||
...prev.schedule,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
vatReport: { ...prev.schedule.vatReport, enabled: false, email: false },
|
||||
incomeTaxReport: { ...prev.schedule.incomeTaxReport, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleScheduleItemChange = useCallback((key: 'vatReport' | 'incomeTaxReport', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
schedule: { ...prev.schedule, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 거래처 알림 핸들러 =====
|
||||
const handleVendorEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
vendor: {
|
||||
...prev.vendor,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
newVendor: { ...prev.vendor.newVendor, enabled: false, email: false },
|
||||
creditRating: { ...prev.vendor.creditRating, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleVendorItemChange = useCallback((key: 'newVendor' | 'creditRating', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
vendor: { ...prev.vendor, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 근태 알림 핸들러 =====
|
||||
const handleAttendanceEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
attendance: {
|
||||
...prev.attendance,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
annualLeave: { ...prev.attendance.annualLeave, enabled: false, email: false },
|
||||
clockIn: { ...prev.attendance.clockIn, enabled: false, email: false },
|
||||
late: { ...prev.attendance.late, enabled: false, email: false },
|
||||
absent: { ...prev.attendance.absent, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAttendanceItemChange = useCallback((
|
||||
key: 'annualLeave' | 'clockIn' | 'late' | 'absent',
|
||||
item: NotificationItem
|
||||
) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
attendance: { ...prev.attendance, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 수주/발주 알림 핸들러 =====
|
||||
const handleOrderEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
order: {
|
||||
...prev.order,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
salesOrder: { ...prev.order.salesOrder, enabled: false, email: false },
|
||||
purchaseOrder: { ...prev.order.purchaseOrder, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleOrderItemChange = useCallback((key: 'salesOrder' | 'purchaseOrder', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
order: { ...prev.order, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 전자결재 알림 핸들러 =====
|
||||
const handleApprovalEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
approval: {
|
||||
...prev.approval,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
approvalRequest: { ...prev.approval.approvalRequest, enabled: false, email: false },
|
||||
draftApproved: { ...prev.approval.draftApproved, enabled: false, email: false },
|
||||
draftRejected: { ...prev.approval.draftRejected, enabled: false, email: false },
|
||||
draftCompleted: { ...prev.approval.draftCompleted, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleApprovalItemChange = useCallback((
|
||||
key: 'approvalRequest' | 'draftApproved' | 'draftRejected' | 'draftCompleted',
|
||||
item: NotificationItem
|
||||
) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
approval: { ...prev.approval, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 생산 알림 핸들러 =====
|
||||
const handleProductionEnabledChange = useCallback((enabled: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
production: {
|
||||
...prev.production,
|
||||
enabled,
|
||||
...(enabled ? {} : {
|
||||
safetyStock: { ...prev.production.safetyStock, enabled: false, email: false },
|
||||
productionComplete: { ...prev.production.productionComplete, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleProductionItemChange = useCallback((
|
||||
key: 'safetyStock' | 'productionComplete',
|
||||
item: NotificationItem
|
||||
) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
production: { ...prev.production, [key]: item },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 저장 (프록시 패턴) =====
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/proxy/settings/notifications', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
toast.success('알림 설정이 저장되었습니다.');
|
||||
if (result.data) {
|
||||
setSettings(mergeWithDefaults(result.data));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationSettings] Save error:', error);
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// ===== 로딩 UI =====
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="알림설정"
|
||||
description="알림 설정을 관리합니다."
|
||||
icon={Bell}
|
||||
/>
|
||||
<ContentLoadingSpinner text="알림 설정을 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="알림설정"
|
||||
description="알림 설정을 관리합니다."
|
||||
icon={Bell}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 공지 알림 */}
|
||||
<NotificationSection
|
||||
title="공지 알림"
|
||||
enabled={settings.notice.enabled}
|
||||
onEnabledChange={handleNoticeEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="공지사항 알림"
|
||||
item={settings.notice.notice}
|
||||
onChange={(item) => handleNoticeItemChange('notice', item)}
|
||||
disabled={!settings.notice.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="이벤트 알림"
|
||||
item={settings.notice.event}
|
||||
onChange={(item) => handleNoticeItemChange('event', item)}
|
||||
disabled={!settings.notice.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 일정 알림 */}
|
||||
<NotificationSection
|
||||
title="일정 알림"
|
||||
enabled={settings.schedule.enabled}
|
||||
onEnabledChange={handleScheduleEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="부가세 신고 알림"
|
||||
item={settings.schedule.vatReport}
|
||||
onChange={(item) => handleScheduleItemChange('vatReport', item)}
|
||||
disabled={!settings.schedule.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="종합소득세 신고 알림"
|
||||
item={settings.schedule.incomeTaxReport}
|
||||
onChange={(item) => handleScheduleItemChange('incomeTaxReport', item)}
|
||||
disabled={!settings.schedule.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 거래처 알림 */}
|
||||
<NotificationSection
|
||||
title="거래처 알림"
|
||||
enabled={settings.vendor.enabled}
|
||||
onEnabledChange={handleVendorEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="신규 업체 등록 알림"
|
||||
item={settings.vendor.newVendor}
|
||||
onChange={(item) => handleVendorItemChange('newVendor', item)}
|
||||
disabled={!settings.vendor.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="신용등급 등록 알림"
|
||||
item={settings.vendor.creditRating}
|
||||
onChange={(item) => handleVendorItemChange('creditRating', item)}
|
||||
disabled={!settings.vendor.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 근태 알림 */}
|
||||
<NotificationSection
|
||||
title="근태 알림"
|
||||
enabled={settings.attendance.enabled}
|
||||
onEnabledChange={handleAttendanceEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="연차 알림"
|
||||
item={settings.attendance.annualLeave}
|
||||
onChange={(item) => handleAttendanceItemChange('annualLeave', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="출근 알림"
|
||||
item={settings.attendance.clockIn}
|
||||
onChange={(item) => handleAttendanceItemChange('clockIn', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="지각 알림"
|
||||
item={settings.attendance.late}
|
||||
onChange={(item) => handleAttendanceItemChange('late', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="결근 알림"
|
||||
item={settings.attendance.absent}
|
||||
onChange={(item) => handleAttendanceItemChange('absent', item)}
|
||||
disabled={!settings.attendance.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 수주/발주 알림 */}
|
||||
<NotificationSection
|
||||
title="수주/발주 알림"
|
||||
enabled={settings.order.enabled}
|
||||
onEnabledChange={handleOrderEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="수주 등록 알림"
|
||||
item={settings.order.salesOrder}
|
||||
onChange={(item) => handleOrderItemChange('salesOrder', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="발주 알림"
|
||||
item={settings.order.purchaseOrder}
|
||||
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 전자결재 알림 */}
|
||||
<NotificationSection
|
||||
title="전자결재 알림"
|
||||
enabled={settings.approval.enabled}
|
||||
onEnabledChange={handleApprovalEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="결재요청 알림"
|
||||
item={settings.approval.approvalRequest}
|
||||
onChange={(item) => handleApprovalItemChange('approvalRequest', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="기안 > 승인 알림"
|
||||
item={settings.approval.draftApproved}
|
||||
onChange={(item) => handleApprovalItemChange('draftApproved', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="기안 > 반려 알림"
|
||||
item={settings.approval.draftRejected}
|
||||
onChange={(item) => handleApprovalItemChange('draftRejected', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="기안 > 완료 알림"
|
||||
item={settings.approval.draftCompleted}
|
||||
onChange={(item) => handleApprovalItemChange('draftCompleted', item)}
|
||||
disabled={!settings.approval.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 생산 알림 */}
|
||||
<NotificationSection
|
||||
title="생산 알림"
|
||||
enabled={settings.production.enabled}
|
||||
onEnabledChange={handleProductionEnabledChange}
|
||||
>
|
||||
<NotificationItemRow
|
||||
label="안전재고 알림"
|
||||
item={settings.production.safetyStock}
|
||||
onChange={(item) => handleProductionItemChange('safetyStock', item)}
|
||||
disabled={!settings.production.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="생산완료 알림"
|
||||
item={settings.production.productionComplete}
|
||||
onChange={(item) => handleProductionItemChange('productionComplete', item)}
|
||||
disabled={!settings.production.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={handleSave} size="lg" disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -109,11 +109,53 @@ export async function saveNotificationSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
// ===== API → Frontend 변환 (기본값과 병합) =====
|
||||
function transformApiToFrontend(apiData: Record<string, unknown>): NotificationSettings {
|
||||
// API 응답이 이미 프론트엔드 형식과 동일하다고 가정
|
||||
// 필요시 snake_case → camelCase 변환
|
||||
return apiData as NotificationSettings;
|
||||
// API 응답에 soundType이 없을 수 있으므로 기본값과 병합
|
||||
const data = apiData as Partial<NotificationSettings>;
|
||||
|
||||
return {
|
||||
notice: {
|
||||
enabled: data.notice?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.notice.enabled,
|
||||
notice: { ...DEFAULT_NOTIFICATION_SETTINGS.notice.notice, ...data.notice?.notice },
|
||||
event: { ...DEFAULT_NOTIFICATION_SETTINGS.notice.event, ...data.notice?.event },
|
||||
},
|
||||
schedule: {
|
||||
enabled: data.schedule?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.schedule.enabled,
|
||||
vatReport: { ...DEFAULT_NOTIFICATION_SETTINGS.schedule.vatReport, ...data.schedule?.vatReport },
|
||||
incomeTaxReport: { ...DEFAULT_NOTIFICATION_SETTINGS.schedule.incomeTaxReport, ...data.schedule?.incomeTaxReport },
|
||||
},
|
||||
vendor: {
|
||||
enabled: data.vendor?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.vendor.enabled,
|
||||
newVendor: { ...DEFAULT_NOTIFICATION_SETTINGS.vendor.newVendor, ...data.vendor?.newVendor },
|
||||
creditRating: { ...DEFAULT_NOTIFICATION_SETTINGS.vendor.creditRating, ...data.vendor?.creditRating },
|
||||
},
|
||||
attendance: {
|
||||
enabled: data.attendance?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.attendance.enabled,
|
||||
annualLeave: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.annualLeave, ...data.attendance?.annualLeave },
|
||||
clockIn: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.clockIn, ...data.attendance?.clockIn },
|
||||
late: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.late, ...data.attendance?.late },
|
||||
absent: { ...DEFAULT_NOTIFICATION_SETTINGS.attendance.absent, ...data.attendance?.absent },
|
||||
},
|
||||
order: {
|
||||
enabled: data.order?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.order.enabled,
|
||||
salesOrder: { ...DEFAULT_NOTIFICATION_SETTINGS.order.salesOrder, ...data.order?.salesOrder },
|
||||
purchaseOrder: { ...DEFAULT_NOTIFICATION_SETTINGS.order.purchaseOrder, ...data.order?.purchaseOrder },
|
||||
approvalRequest: { ...DEFAULT_NOTIFICATION_SETTINGS.order.approvalRequest, ...data.order?.approvalRequest },
|
||||
},
|
||||
approval: {
|
||||
enabled: data.approval?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.approval.enabled,
|
||||
approvalRequest: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.approvalRequest, ...data.approval?.approvalRequest },
|
||||
draftApproved: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftApproved, ...data.approval?.draftApproved },
|
||||
draftRejected: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftRejected, ...data.approval?.draftRejected },
|
||||
draftCompleted: { ...DEFAULT_NOTIFICATION_SETTINGS.approval.draftCompleted, ...data.approval?.draftCompleted },
|
||||
},
|
||||
production: {
|
||||
enabled: data.production?.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.production.enabled,
|
||||
safetyStock: { ...DEFAULT_NOTIFICATION_SETTINGS.production.safetyStock, ...data.production?.safetyStock },
|
||||
productionComplete: { ...DEFAULT_NOTIFICATION_SETTINGS.production.productionComplete, ...data.production?.productionComplete },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 =====
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { NotificationSettingsClient } from './NotificationSettingsClient';
|
||||
export * from './types';
|
||||
@@ -3,21 +3,39 @@
|
||||
/**
|
||||
* 알림설정 페이지
|
||||
*
|
||||
* 각 알림 유형별로 ON/OFF 토글과 이메일 알림 체크박스를 제공합니다.
|
||||
* 각 알림 유형별로 ON/OFF 토글, 알림 소리 선택, 이메일 알림 체크박스를 제공합니다.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Bell, Save } from 'lucide-react';
|
||||
import { Bell, Save, Play } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import type { NotificationSettings, NotificationItem } from './types';
|
||||
import type { NotificationSettings, NotificationItem, SoundType } from './types';
|
||||
import { SOUND_OPTIONS } from './types';
|
||||
import { saveNotificationSettings } from './actions';
|
||||
|
||||
// 미리듣기 함수
|
||||
function playPreviewSound(soundType: SoundType) {
|
||||
if (soundType === 'mute') {
|
||||
toast.info('무음으로 설정되어 있습니다.');
|
||||
return;
|
||||
}
|
||||
const soundName = soundType === 'default' ? '기본 알림음' : 'SAM 보이스';
|
||||
toast.info(`${soundName} 미리듣기`);
|
||||
}
|
||||
|
||||
// 알림 항목 컴포넌트
|
||||
interface NotificationItemRowProps {
|
||||
label: string;
|
||||
@@ -27,28 +45,74 @@ interface NotificationItemRowProps {
|
||||
}
|
||||
|
||||
function NotificationItemRow({ label, item, onChange, disabled }: NotificationItemRowProps) {
|
||||
const isDisabled = disabled || !item.enabled;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<span className="text-sm min-w-[160px]">{label}</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={item.email}
|
||||
<div className="flex items-center justify-between py-4 border-b last:border-b-0">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, email: checked === true })
|
||||
onChange({
|
||||
...item,
|
||||
enabled: checked,
|
||||
email: checked ? item.email : false
|
||||
})
|
||||
}
|
||||
disabled={disabled || !item.enabled}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">이메일</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 알림 소리 선택 */}
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<span className="text-sm text-muted-foreground min-w-[80px]">알림 소리 선택</span>
|
||||
<Select
|
||||
value={item.soundType}
|
||||
onValueChange={(value: SoundType) =>
|
||||
onChange({ ...item, soundType: value })
|
||||
}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOUND_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => playPreviewSound(item.soundType)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 추가 알림 선택 */}
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<span className="text-sm text-muted-foreground min-w-[80px]">추가 알림 선택</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={item.email}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, email: checked === true })
|
||||
}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">이메일</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...item, enabled: checked, email: checked ? item.email : false })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -195,12 +259,13 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
|
||||
...(enabled ? {} : {
|
||||
salesOrder: { ...prev.order.salesOrder, enabled: false, email: false },
|
||||
purchaseOrder: { ...prev.order.purchaseOrder, enabled: false, email: false },
|
||||
approvalRequest: { ...prev.order.approvalRequest, enabled: false, email: false },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOrderItemChange = (key: 'salesOrder' | 'purchaseOrder', item: NotificationItem) => {
|
||||
const handleOrderItemChange = (key: 'salesOrder' | 'purchaseOrder' | 'approvalRequest', item: NotificationItem) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
order: { ...prev.order, [key]: item },
|
||||
@@ -388,6 +453,12 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett
|
||||
onChange={(item) => handleOrderItemChange('purchaseOrder', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
<NotificationItemRow
|
||||
label="결재요청 알림"
|
||||
item={settings.order.approvalRequest}
|
||||
onChange={(item) => handleOrderItemChange('approvalRequest', item)}
|
||||
disabled={!settings.order.enabled}
|
||||
/>
|
||||
</NotificationSection>
|
||||
|
||||
{/* 전자결재 알림 */}
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
/**
|
||||
* 알림 설정 타입 정의
|
||||
*
|
||||
* ========================================
|
||||
* [2026-01-05] 백엔드 API 수정 필요 사항
|
||||
* ========================================
|
||||
*
|
||||
* 1. NotificationItem에 soundType 필드 추가
|
||||
* - 기존: { enabled: boolean, email: boolean }
|
||||
* - 변경: { enabled: boolean, email: boolean, soundType: 'default' | 'sam_voice' | 'mute' }
|
||||
*
|
||||
* 2. OrderNotificationSettings에 approvalRequest 항목 추가
|
||||
* - 기존: { salesOrder, purchaseOrder }
|
||||
* - 변경: { salesOrder, purchaseOrder, approvalRequest }
|
||||
*
|
||||
* 3. API 응답 예시:
|
||||
* {
|
||||
* "notice": {
|
||||
* "enabled": true,
|
||||
* "notice": { "enabled": true, "email": false, "soundType": "default" },
|
||||
* "event": { "enabled": true, "email": true, "soundType": "sam_voice" }
|
||||
* },
|
||||
* "order": {
|
||||
* "enabled": true,
|
||||
* "salesOrder": { ... },
|
||||
* "purchaseOrder": { ... },
|
||||
* "approvalRequest": { "enabled": false, "email": false, "soundType": "default" } // 새로 추가
|
||||
* }
|
||||
* }
|
||||
* ========================================
|
||||
*/
|
||||
|
||||
// 알림 소리 타입 (NEW: 2026-01-05)
|
||||
export type SoundType = 'default' | 'sam_voice' | 'mute';
|
||||
|
||||
// 알림 소리 옵션
|
||||
export const SOUND_OPTIONS: { value: SoundType; label: string }[] = [
|
||||
{ value: 'default', label: '기본 알림음' },
|
||||
{ value: 'sam_voice', label: 'SAM 보이스' },
|
||||
{ value: 'mute', label: '무음' },
|
||||
];
|
||||
|
||||
// 개별 알림 항목 설정
|
||||
export interface NotificationItem {
|
||||
enabled: boolean;
|
||||
email: boolean;
|
||||
soundType: SoundType;
|
||||
}
|
||||
|
||||
// 공지 알림 설정
|
||||
@@ -43,6 +82,7 @@ export interface OrderNotificationSettings {
|
||||
enabled: boolean;
|
||||
salesOrder: NotificationItem; // 수주 등록 알림
|
||||
purchaseOrder: NotificationItem; // 발주 알림
|
||||
approvalRequest: NotificationItem; // 결재요청 알림
|
||||
}
|
||||
|
||||
// 전자결재 알림 설정
|
||||
@@ -76,46 +116,48 @@ export interface NotificationSettings {
|
||||
export const DEFAULT_NOTIFICATION_ITEM: NotificationItem = {
|
||||
enabled: false,
|
||||
email: false,
|
||||
soundType: 'default',
|
||||
};
|
||||
|
||||
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||
notice: {
|
||||
enabled: false,
|
||||
notice: { enabled: false, email: false },
|
||||
event: { enabled: true, email: false },
|
||||
notice: { enabled: false, email: false, soundType: 'default' },
|
||||
event: { enabled: true, email: false, soundType: 'default' },
|
||||
},
|
||||
schedule: {
|
||||
enabled: false,
|
||||
vatReport: { enabled: false, email: false },
|
||||
incomeTaxReport: { enabled: true, email: false },
|
||||
vatReport: { enabled: false, email: false, soundType: 'mute' },
|
||||
incomeTaxReport: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
vendor: {
|
||||
enabled: false,
|
||||
newVendor: { enabled: false, email: false },
|
||||
creditRating: { enabled: true, email: false },
|
||||
newVendor: { enabled: false, email: false, soundType: 'default' },
|
||||
creditRating: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
attendance: {
|
||||
enabled: false,
|
||||
annualLeave: { enabled: false, email: false },
|
||||
clockIn: { enabled: true, email: false },
|
||||
late: { enabled: false, email: false },
|
||||
absent: { enabled: true, email: false },
|
||||
annualLeave: { enabled: false, email: false, soundType: 'default' },
|
||||
clockIn: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
late: { enabled: false, email: false, soundType: 'default' },
|
||||
absent: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
order: {
|
||||
enabled: false,
|
||||
salesOrder: { enabled: false, email: false },
|
||||
purchaseOrder: { enabled: true, email: false },
|
||||
salesOrder: { enabled: false, email: false, soundType: 'default' },
|
||||
purchaseOrder: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
approvalRequest: { enabled: false, email: false, soundType: 'default' },
|
||||
},
|
||||
approval: {
|
||||
enabled: false,
|
||||
approvalRequest: { enabled: false, email: false },
|
||||
draftApproved: { enabled: true, email: false },
|
||||
draftRejected: { enabled: false, email: false },
|
||||
draftCompleted: { enabled: true, email: false },
|
||||
approvalRequest: { enabled: false, email: false, soundType: 'default' },
|
||||
draftApproved: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
draftRejected: { enabled: false, email: false, soundType: 'default' },
|
||||
draftCompleted: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
production: {
|
||||
enabled: false,
|
||||
safetyStock: { enabled: false, email: false },
|
||||
productionComplete: { enabled: true, email: false },
|
||||
safetyStock: { enabled: false, email: false, soundType: 'default' },
|
||||
productionComplete: { enabled: true, email: false, soundType: 'sam_voice' },
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { PaymentHistoryClient } from './PaymentHistoryClient';
|
||||
export { PaymentHistoryClient as PaymentHistoryManagement } from './PaymentHistoryClient';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
|
||||
@@ -27,7 +27,7 @@ function RadioGroupItem({
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"border-gray-400 text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:border-gray-500 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user