Merge remote-tracking branch 'origin/master'
# Conflicts: # src/components/production/WorkOrders/WorkOrderList.tsx
This commit is contained in:
106
claudedocs/[IMPL-2026-02-06] datepicker-migration-checklist.md
Normal file
106
claudedocs/[IMPL-2026-02-06] datepicker-migration-checklist.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# DatePicker 전체 교체 계획서
|
||||
|
||||
> `input type="date"` → `DatePicker` 컴포넌트 교체
|
||||
> 작성일: 2026-02-06
|
||||
|
||||
## 개요
|
||||
- 기존 브라우저 기본 `input type="date"`를 공통 `DatePicker` 컴포넌트로 교체
|
||||
- 공휴일/토요일/일요일 색상, 연월 직접 선택, 오늘 버튼 등 통일된 UX 제공
|
||||
|
||||
## 교체 패턴
|
||||
```tsx
|
||||
// Before
|
||||
<Input type="date" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
|
||||
// After
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
<DatePicker value={value} onChange={setValue} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 공통 템플릿 (우선 - 파급 효과 큼) ✅ 완료
|
||||
- [x] `molecules/DateRangeSelector.tsx` (4곳)
|
||||
- [x] `molecules/FormField.tsx` (1곳)
|
||||
- [x] `templates/IntegratedDetailTemplate/FieldRenderer.tsx` (1곳)
|
||||
- [x] `templates/IntegratedDetailTemplate/FieldInput.tsx` (1곳)
|
||||
- [x] `items/DynamicItemForm/fields/DateField.tsx` (1곳)
|
||||
|
||||
## Phase 2: 회계 ✅ 완료
|
||||
- [x] `accounting/DepositManagement/DepositDetail.tsx` (1곳)
|
||||
- [x] `accounting/WithdrawalManagement/WithdrawalDetail.tsx` (1곳)
|
||||
- [x] `accounting/BadDebtCollection/BadDebtDetail.tsx` (2곳)
|
||||
- [x] `accounting/DailyReport/index.tsx` (1곳)
|
||||
- [x] `accounting/PurchaseManagement/PurchaseDetailModal.tsx` (1곳)
|
||||
- [x] `accounting/PurchaseManagement/PurchaseDetail.tsx` (1곳)
|
||||
- [x] `accounting/BillManagement/BillDetailV2.tsx` (3곳)
|
||||
- [x] `accounting/BillManagement/BillDetail.tsx` (3곳)
|
||||
- [x] `accounting/SalesManagement/SalesDetail.tsx` (1곳)
|
||||
|
||||
## Phase 3: 건설/현장 ✅ 완료
|
||||
- [x] `construction/bidding/BiddingDetailForm.tsx` (5곳)
|
||||
- [x] `construction/site-briefings/SiteBriefingForm.tsx` (2곳)
|
||||
- [x] `construction/issue-management/IssueDetailForm.tsx` (2곳)
|
||||
- [x] `construction/structure-review/StructureReviewDetailForm.tsx` (2곳)
|
||||
- [x] `construction/contract/ContractDetailForm.tsx` (3곳)
|
||||
- [x] `construction/management/ConstructionDetailClient.tsx` (1곳)
|
||||
- [x] `construction/management/ProjectEndDialog.tsx` (2곳)
|
||||
- [x] `construction/estimates/sections/EstimateInfoSection.tsx` (3곳)
|
||||
- [x] `construction/order-management/cards/ConstructionDetailCard.tsx` (2곳)
|
||||
- [x] `construction/order-management/tables/OrderDetailItemTable.tsx` (5곳)
|
||||
- [x] `construction/handover-report/HandoverReportDetailForm.tsx` (3곳)
|
||||
|
||||
## Phase 4: 주문/견적/단가 ✅ 완료
|
||||
- [x] `quotes/QuoteRegistrationV2.tsx` (1곳)
|
||||
- [x] `quotes/QuoteRegistration.tsx` (2곳)
|
||||
- [x] `orders/OrderSalesDetailEdit.tsx` (2곳)
|
||||
- [x] `orders/OrderRegistration.tsx` (2곳)
|
||||
- [x] `pricing/PricingFormClient.tsx` (2곳)
|
||||
- [x] `pricing-distribution/PriceDistributionDetail.tsx` (1곳)
|
||||
|
||||
## Phase 5: 인사 ✅ 완료
|
||||
- [x] `hr/VacationManagement/VacationRequestDialog.tsx` (2곳)
|
||||
- [x] `hr/VacationManagement/VacationGrantDialog.tsx` (1곳)
|
||||
- [x] `hr/EmployeeManagement/EmployeeDialog.tsx` (1곳)
|
||||
- [x] `hr/EmployeeManagement/EmployeeForm.tsx` (2곳)
|
||||
|
||||
## Phase 6: 품질 ✅ 완료
|
||||
- [x] `quality/InspectionManagement/InspectionCreate.tsx` (3곳)
|
||||
- [x] `quality/InspectionManagement/InspectionDetail.tsx` (3곳)
|
||||
|
||||
## Phase 7: 생산/자재/출고 ✅ 완료
|
||||
- [x] `production/WorkOrders/WorkOrderCreate.tsx` (1곳)
|
||||
- [x] `production/WorkOrders/WorkOrderEdit.tsx` (1곳)
|
||||
- [x] `production/WorkerScreen/index.tsx` (1곳)
|
||||
- [x] `material/ReceivingManagement/InspectionCreate.tsx` (1곳)
|
||||
- [x] `material/ReceivingManagement/ReceivingDetail.tsx` (2곳)
|
||||
- [x] `outbound/ShipmentManagement/ShipmentCreate.tsx` (2곳)
|
||||
- [x] `outbound/ShipmentManagement/ShipmentEdit.tsx` (2곳)
|
||||
|
||||
## Phase 8: 결재/설정/기타 ✅ 완료
|
||||
- [x] `approval/DocumentCreate/ProposalForm.tsx` (1곳)
|
||||
- [x] `approval/DocumentCreate/ExpenseReportForm.tsx` (2곳)
|
||||
- [x] `settings/PopupManagement/PopupForm.tsx` (2곳)
|
||||
- [x] `items/ItemForm/forms/ProductForm.tsx` (2곳)
|
||||
- [x] `app/sales/order-management-sales/[id]/edit/page.tsx` (2곳)
|
||||
|
||||
## 제외 대상
|
||||
- ~~`hr/documents/page.tsx`~~ - 메뉴 미연결 페이지
|
||||
- ~~`hr/documents/new/page.tsx`~~ - 메뉴 미연결 페이지
|
||||
- ~~`CEODashboard/TodayIssueSection.tsx`~~ - 이미 적용 완료
|
||||
- ~~`CEODashboard/ScheduleDetailModal.tsx`~~ - 이미 적용 완료
|
||||
|
||||
---
|
||||
|
||||
## 진행 상황
|
||||
| Phase | 파일 수 | 완료 | 상태 |
|
||||
|-------|---------|------|------|
|
||||
| 1. 공통 템플릿 | 5 | 5 | ✅ 완료 |
|
||||
| 2. 회계 | 9 | 9 | ✅ 완료 |
|
||||
| 3. 건설/현장 | 11 | 11 | ✅ 완료 |
|
||||
| 4. 주문/견적/단가 | 6 | 6 | ✅ 완료 |
|
||||
| 5. 인사 | 4 | 4 | ✅ 완료 |
|
||||
| 6. 품질 | 2 | 2 | ✅ 완료 |
|
||||
| 7. 생산/자재/출고 | 7 | 7 | ✅ 완료 |
|
||||
| 8. 결재/설정/기타 | 5 | 5 | ✅ 완료 |
|
||||
| **합계** | **49** | **49** | ✅ 전체 완료 |
|
||||
@@ -12,6 +12,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PhoneInput } from "@/components/ui/phone-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -322,11 +323,10 @@ export default function OrderEditPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={form.expectedShipDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expectedShipDate: e.target.value })
|
||||
onChange={(date) =>
|
||||
setForm({ ...form, expectedShipDate: date })
|
||||
}
|
||||
disabled={form.expectedShipDateUndecided}
|
||||
className="flex-1"
|
||||
@@ -358,11 +358,10 @@ export default function OrderEditPage() {
|
||||
<Label>
|
||||
납품요청일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={form.deliveryRequestDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, deliveryRequestDate: e.target.value })
|
||||
onChange={(date) =>
|
||||
setForm({ ...form, deliveryRequestDate: date })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,6 @@ import {
|
||||
} from "@/components/orders";
|
||||
import { sendSalesOrderNotification } from "@/lib/actions/fcm";
|
||||
import { OrderSalesDetailEdit } from "@/components/orders/OrderSalesDetailEdit";
|
||||
import { getDepartmentTree, type DepartmentRecord } from "@/components/hr/DepartmentManagement/actions";
|
||||
|
||||
/**
|
||||
* 수량 포맷 함수
|
||||
@@ -159,8 +158,6 @@ export default function OrderDetailPage() {
|
||||
const [isProductionDialogOpen, setIsProductionDialogOpen] = useState(false);
|
||||
const [isCreatingProduction, setIsCreatingProduction] = useState(false);
|
||||
const [productionPriority, setProductionPriority] = useState<"normal" | "high" | "urgent">("normal");
|
||||
const [productionDepartmentId, setProductionDepartmentId] = useState<string>("");
|
||||
const [departments, setDepartments] = useState<DepartmentRecord[]>([]);
|
||||
const [productionMemo, setProductionMemo] = useState("");
|
||||
// 생산지시 완료 알림 모달 상태
|
||||
const [isProductionSuccessDialogOpen, setIsProductionSuccessDialogOpen] = useState(false);
|
||||
@@ -208,45 +205,11 @@ export default function OrderDetailPage() {
|
||||
router.push(`/sales/order-management-sales/${orderId}?mode=edit`);
|
||||
};
|
||||
|
||||
// 부서 트리를 평탄화
|
||||
const flattenDepartments = (depts: DepartmentRecord[]): DepartmentRecord[] => {
|
||||
const result: DepartmentRecord[] = [];
|
||||
const traverse = (list: DepartmentRecord[]) => {
|
||||
for (const dept of list) {
|
||||
result.push(dept);
|
||||
if (dept.children?.length) traverse(dept.children);
|
||||
}
|
||||
};
|
||||
traverse(depts);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleProductionOrder = async () => {
|
||||
const handleProductionOrder = () => {
|
||||
// 생산지시 생성 모달 열기
|
||||
setProductionPriority("normal");
|
||||
setProductionMemo("");
|
||||
setProductionDepartmentId("");
|
||||
setIsProductionDialogOpen(true);
|
||||
|
||||
// 부서 목록 로드
|
||||
if (departments.length === 0) {
|
||||
const result = await getDepartmentTree();
|
||||
if (result.success && result.data) {
|
||||
const flatList = flattenDepartments(result.data);
|
||||
setDepartments(flatList);
|
||||
// 디폴트: 생산부서
|
||||
const defaultDept = flatList.find(d => d.name.includes("생산"));
|
||||
if (defaultDept) {
|
||||
setProductionDepartmentId(String(defaultDept.id));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 이미 로드된 경우 디폴트 설정
|
||||
const defaultDept = departments.find(d => d.name.includes("생산"));
|
||||
if (defaultDept) {
|
||||
setProductionDepartmentId(String(defaultDept.id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 생산지시 확정 처리
|
||||
@@ -256,7 +219,6 @@ export default function OrderDetailPage() {
|
||||
try {
|
||||
const result = await createProductionOrder(order.id, {
|
||||
priority: productionPriority,
|
||||
departmentId: productionDepartmentId ? Number(productionDepartmentId) : undefined,
|
||||
memo: productionMemo || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
@@ -1295,26 +1257,6 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">부서</Label>
|
||||
<Select
|
||||
value={productionDepartmentId}
|
||||
onValueChange={setProductionDepartmentId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="부서를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem key={dept.id} value={String(dept.id)}>
|
||||
{dept.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productionMemo">비고</Label>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { format } from 'date-fns';
|
||||
import { Plus, X, FileText, Receipt, CreditCard, Upload, Download, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
@@ -836,23 +837,19 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
{/* 악성채권 발생일 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">악성채권 발생일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.occurrenceDate}
|
||||
onChange={(e) => handleChange('occurrenceDate', e.target.value)}
|
||||
onChange={(date) => handleChange('occurrenceDate', date)}
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
{/* 악성채권 종료일 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">악성채권 종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.endDate || ''}
|
||||
onChange={(e) => handleChange('endDate', e.target.value || null)}
|
||||
onChange={(date) => handleChange('endDate', date || null)}
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
placeholder="-"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -301,11 +302,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="issueDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={issueDate}
|
||||
onChange={(e) => setIssueDate(e.target.value)}
|
||||
onChange={setIssueDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -315,11 +314,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maturityDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={maturityDate}
|
||||
onChange={(e) => setMaturityDate(e.target.value)}
|
||||
onChange={setMaturityDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -399,12 +396,10 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'date', e.target.value)}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -369,11 +370,9 @@ export function BillDetailV2({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="issueDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.issueDate}
|
||||
onChange={(e) => updateField('issueDate', e.target.value)}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -383,11 +382,9 @@ export function BillDetailV2({ billId, mode }: BillDetailProps) {
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="maturityDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.maturityDate}
|
||||
onChange={(e) => updateField('maturityDate', e.target.value)}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -471,12 +468,10 @@ export function BillDetailV2({ billId, mode }: BillDetailProps) {
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'date', e.target.value)}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -197,11 +197,11 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">조회 일자</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
className="w-[160px]"
|
||||
onChange={setSelectedDate}
|
||||
className="w-[170px]"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -214,11 +215,8 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
{/* 입금일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="depositDate">입금일</Label>
|
||||
<Input
|
||||
id="depositDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={depositDate}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -380,10 +381,9 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
{/* 매입일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={purchaseDate}
|
||||
onChange={(e) => setPurchaseDate(e.target.value)}
|
||||
onChange={setPurchaseDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
@@ -199,10 +200,9 @@ export function PurchaseDetailModal({
|
||||
{/* 매입일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매입일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.purchaseDate}
|
||||
onChange={(e) => handleFieldChange('purchaseDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('purchaseDate', date)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -296,11 +297,9 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
{/* 매출일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salesDate">매출일</Label>
|
||||
<Input
|
||||
id="salesDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={salesDate}
|
||||
onChange={(e) => setSalesDate(e.target.value)}
|
||||
onChange={setSalesDate}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -214,11 +215,8 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
{/* 출금일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="withdrawalDate">출금일</Label>
|
||||
<Input
|
||||
id="withdrawalDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={withdrawalDate}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
@@ -83,21 +84,17 @@ export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requestDate">지출 요청일</Label>
|
||||
<Input
|
||||
id="requestDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={data.requestDate}
|
||||
onChange={(e) => onChange({ ...data, requestDate: e.target.value })}
|
||||
onChange={(date) => onChange({ ...data, requestDate: date })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentDate">결제일</Label>
|
||||
<Input
|
||||
id="paymentDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={data.paymentDate}
|
||||
onChange={(e) => onChange({ ...data, paymentDate: e.target.value })}
|
||||
onChange={(date) => onChange({ ...data, paymentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Mic } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
@@ -109,11 +110,9 @@ export function ProposalForm({ data, onChange }: ProposalFormProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorPaymentDate">구매처 결제일</Label>
|
||||
<Input
|
||||
id="vendorPaymentDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={data.vendorPaymentDate}
|
||||
onChange={(e) => onChange({ ...data, vendorPaymentDate: e.target.value })}
|
||||
onChange={(date) => onChange({ ...data, vendorPaymentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -172,17 +173,17 @@ export function ScheduleDetailModal({
|
||||
기간
|
||||
</label>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.startDate}
|
||||
onChange={(e) => handleFieldChange('startDate', e.target.value)}
|
||||
onChange={(value) => handleFieldChange('startDate', value)}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-gray-400 px-1">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.endDate}
|
||||
onChange={(e) => handleFieldChange('endDate', e.target.value)}
|
||||
onChange={(value) => handleFieldChange('endDate', value)}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -204,9 +204,8 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
const yesterday = useMemo(() => getYesterday(), []);
|
||||
const isNextDisabled = isSameDay(pastDate, yesterday);
|
||||
|
||||
// 날짜 input 직접 선택
|
||||
const handleDateInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value; // yyyy-MM-dd
|
||||
// DatePicker 날짜 선택
|
||||
const handleDatePickerChange = useCallback((value: string) => {
|
||||
if (!value) return;
|
||||
const selected = new Date(value + 'T00:00:00');
|
||||
if (isNaN(selected.getTime())) return;
|
||||
@@ -296,24 +295,27 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
|
||||
{activeTab === 'past' && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
className="h-8 px-2"
|
||||
onClick={handlePrevDate}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formatDateParam(pastDate)}
|
||||
max={formatDateParam(yesterday)}
|
||||
onChange={handleDateInputChange}
|
||||
className="w-[160px] h-8 text-sm text-center"
|
||||
maxDate={yesterday}
|
||||
onChange={handleDatePickerChange}
|
||||
size="sm"
|
||||
displayFormat="yyyy년 MM월 dd일"
|
||||
className="w-[170px]"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
className="h-8 px-2"
|
||||
onClick={handleNextDate}
|
||||
disabled={isNextDisabled}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -184,10 +185,9 @@ export default function BiddingDetailForm({
|
||||
{isViewMode ? (
|
||||
<Input value={formData.biddingDate} disabled className="bg-muted" />
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.biddingDate}
|
||||
onChange={(e) => handleFieldChange('biddingDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('biddingDate', date)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -210,21 +210,15 @@ export default function BiddingDetailForm({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.constructionStartDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('constructionStartDate', e.target.value)
|
||||
}
|
||||
onChange={(date) => handleFieldChange('constructionStartDate', date)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.constructionEndDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('constructionEndDate', e.target.value)
|
||||
}
|
||||
onChange={(date) => handleFieldChange('constructionEndDate', date)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
@@ -313,10 +307,9 @@ export default function BiddingDetailForm({
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.submissionDate}
|
||||
onChange={(e) => handleFieldChange('submissionDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('submissionDate', date)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -331,10 +324,9 @@ export default function BiddingDetailForm({
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.confirmDate}
|
||||
onChange={(e) => handleFieldChange('confirmDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('confirmDate', date)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -297,10 +298,9 @@ export default function ContractDetailForm({
|
||||
{/* 계약일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.contractDate}
|
||||
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('contractDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -319,17 +319,15 @@ export default function ContractDetailForm({
|
||||
<div className="space-y-2">
|
||||
<Label>계약기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.contractStartDate}
|
||||
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('contractStartDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.contractEndDate}
|
||||
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('contractEndDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -162,12 +163,10 @@ export function EstimateInfoSection({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">입찰일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.bidInfo.bidDate}
|
||||
onChange={(e) => onBidInfoChange('bidDate', e.target.value)}
|
||||
onChange={(date) => onBidInfoChange('bidDate', date)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -182,20 +181,16 @@ export function EstimateInfoSection({
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">공사기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.bidInfo.constructionStartDate}
|
||||
onChange={(e) => onBidInfoChange('constructionStartDate', e.target.value)}
|
||||
onChange={(date) => onBidInfoChange('constructionStartDate', date)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.bidInfo.constructionEndDate}
|
||||
onChange={(e) => onBidInfoChange('constructionEndDate', e.target.value)}
|
||||
onChange={(date) => onBidInfoChange('constructionEndDate', date)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Plus, X, Eye, Stamp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -268,10 +269,9 @@ export default function HandoverReportDetailForm({
|
||||
{/* 계약일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.contractDate}
|
||||
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('contractDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -290,17 +290,15 @@ export default function HandoverReportDetailForm({
|
||||
<div className="space-y-2">
|
||||
<Label>계약기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.contractStartDate}
|
||||
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('contractStartDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.contractEndDate}
|
||||
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
|
||||
onChange={(date) => handleFieldChange('contractEndDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getTodayString } from '@/utils/date';
|
||||
import { Mic, X, Upload } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
@@ -406,11 +407,9 @@ export default function IssueDetailForm({ issue, mode = 'view' }: IssueDetailFor
|
||||
{/* 이슈보고일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportDate">이슈보고일</Label>
|
||||
<Input
|
||||
id="reportDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.reportDate}
|
||||
onChange={handleInputChange('reportDate')}
|
||||
onChange={(date) => handleInputChange('reportDate')({ target: { value: date } } as React.ChangeEvent<HTMLInputElement>)}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
@@ -418,11 +417,9 @@ export default function IssueDetailForm({ issue, mode = 'view' }: IssueDetailFor
|
||||
{/* 이슈해결일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resolvedDate">이슈해결일</Label>
|
||||
<Input
|
||||
id="resolvedDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.resolvedDate}
|
||||
onChange={handleInputChange('resolvedDate')}
|
||||
onChange={(date) => handleInputChange('resolvedDate')({ target: { value: date } } as React.ChangeEvent<HTMLInputElement>)}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getTodayString, formatDate } from '@/utils/date';
|
||||
import { Plus, Trash2, FileText, Upload, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -399,13 +400,11 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
|
||||
{isViewMode ? (
|
||||
<span>{worker.workDate || '-'}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={worker.workDate}
|
||||
onChange={(e) =>
|
||||
handleWorkerInfoChange(worker.id, 'workDate', e.target.value)
|
||||
onChange={(date) =>
|
||||
handleWorkerInfoChange(worker.id, 'workDate', date)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
@@ -112,9 +113,7 @@ export default function ProjectEndDialog({
|
||||
{/* 결선작업일 - 읽기전용 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="workDate">결선작업일</Label>
|
||||
<Input
|
||||
id="workDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.workDate}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
@@ -124,12 +123,10 @@ export default function ProjectEndDialog({
|
||||
{/* 결선완료일 - 입력 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="completionDate">결선완료일</Label>
|
||||
<Input
|
||||
id="completionDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.completionDate}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, completionDate: e.target.value }))
|
||||
onChange={(date) =>
|
||||
setFormData((prev) => ({ ...prev, completionDate: date }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
@@ -60,19 +61,16 @@ export function ConstructionDetailCard({
|
||||
시공완료일
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.constructionStartDate}
|
||||
onChange={(e) => onFieldChange('constructionStartDate', e.target.value)}
|
||||
onChange={(date) => onFieldChange('constructionStartDate', date)}
|
||||
disabled={isViewMode}
|
||||
required
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.constructionEndDate}
|
||||
onChange={(e) => onFieldChange('constructionEndDate', e.target.value)}
|
||||
onChange={(date) => onFieldChange('constructionEndDate', date)}
|
||||
disabled={isViewMode}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Plus, Trash2, Image as ImageIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
@@ -219,13 +220,12 @@ export function OrderDetailItemTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={item.constructionStartDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'constructionStartDate', e.target.value)
|
||||
onChange={(date) =>
|
||||
onItemChange(item.id, 'constructionStartDate', date)
|
||||
}
|
||||
className="h-8"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
item.constructionStartDate || '-'
|
||||
@@ -233,13 +233,12 @@ export function OrderDetailItemTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={item.constructionEndDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'constructionEndDate', e.target.value)
|
||||
onChange={(date) =>
|
||||
onItemChange(item.id, 'constructionEndDate', date)
|
||||
}
|
||||
className="h-8"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
item.constructionEndDate || '-'
|
||||
@@ -360,13 +359,12 @@ export function OrderDetailItemTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={item.orderDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'orderDate', e.target.value)
|
||||
onChange={(date) =>
|
||||
onItemChange(item.id, 'orderDate', date)
|
||||
}
|
||||
className="h-8"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
item.orderDate || '-'
|
||||
@@ -374,13 +372,12 @@ export function OrderDetailItemTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={item.plannedDeliveryDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'plannedDeliveryDate', e.target.value)
|
||||
onChange={(date) =>
|
||||
onItemChange(item.id, 'plannedDeliveryDate', date)
|
||||
}
|
||||
className="h-8"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
item.plannedDeliveryDate || '-'
|
||||
@@ -388,13 +385,12 @@ export function OrderDetailItemTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={item.actualDeliveryDate}
|
||||
onChange={(e) =>
|
||||
onItemChange(item.id, 'actualDeliveryDate', e.target.value)
|
||||
onChange={(date) =>
|
||||
onItemChange(item.id, 'actualDeliveryDate', date)
|
||||
}
|
||||
className="h-8"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
item.actualDeliveryDate || '-'
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -751,20 +752,16 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">공사기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.constructionStartDate}
|
||||
onChange={(e) => handleChange('constructionStartDate', e.target.value)}
|
||||
onChange={(date) => handleChange('constructionStartDate', date)}
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.constructionEndDate}
|
||||
onChange={(e) => handleChange('constructionEndDate', e.target.value)}
|
||||
onChange={(date) => handleChange('constructionEndDate', date)}
|
||||
disabled={isViewMode}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -310,11 +311,9 @@ export default function StructureReviewDetailForm({
|
||||
{/* 구조검토 의뢰일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requestDate">구조검토 의뢰일</Label>
|
||||
<Input
|
||||
id="requestDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.requestDate}
|
||||
onChange={handleInputChange('requestDate')}
|
||||
onChange={(date) => handleInputChange('requestDate')({ target: { value: date } } as React.ChangeEvent<HTMLInputElement>)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -322,11 +321,9 @@ export default function StructureReviewDetailForm({
|
||||
{/* 구조검토 완료일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="completionDate">구조검토 완료일</Label>
|
||||
<Input
|
||||
id="completionDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.completionDate || ''}
|
||||
onChange={handleInputChange('completionDate')}
|
||||
onChange={(date) => handleInputChange('completionDate')({ target: { value: date } } as React.ChangeEvent<HTMLInputElement>)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
@@ -348,11 +349,9 @@ export function EmployeeDialog({
|
||||
{fieldSettings.showHireDate && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hireDate">입사일</Label>
|
||||
<Input
|
||||
id="hireDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => handleChange('hireDate', e.target.value)}
|
||||
onChange={(date) => handleChange('hireDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { employeeCreateConfig, employeeEditConfig, employeeConfig } from './empl
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
@@ -686,11 +687,9 @@ export function EmployeeForm({
|
||||
{fieldSettings.showHireDate && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hireDate">입사일</Label>
|
||||
<Input
|
||||
id="hireDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.hireDate}
|
||||
onChange={(e) => handleChange('hireDate', e.target.value)}
|
||||
onChange={(date) => handleChange('hireDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -887,11 +886,9 @@ export function EmployeeForm({
|
||||
{fieldSettings.showResignationDate && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resignationDate">퇴사일</Label>
|
||||
<Input
|
||||
id="resignationDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.resignationDate}
|
||||
onChange={(e) => handleChange('resignationDate', e.target.value)}
|
||||
onChange={(date) => handleChange('resignationDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
@@ -156,11 +157,9 @@ export function VacationGrantDialog({
|
||||
{/* 부여일 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="grantDate">부여일</Label>
|
||||
<Input
|
||||
id="grantDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.grantDate}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, grantDate: e.target.value }))}
|
||||
onChange={(date) => setFormData(prev => ({ ...prev, grantDate: date }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -165,23 +166,19 @@ export function VacationRequestDialog({
|
||||
{/* 시작일 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="startDate">시작일</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.startDate}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, startDate: e.target.value }))}
|
||||
onChange={(date) => setFormData(prev => ({ ...prev, startDate: date }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 종료일 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="endDate">종료일</Label>
|
||||
<Input
|
||||
id="endDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.endDate}
|
||||
min={formData.startDate || undefined}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, endDate: e.target.value }))}
|
||||
minDate={formData.startDate ? new Date(formData.startDate) : undefined}
|
||||
onChange={(date) => setFormData(prev => ({ ...prev, endDate: date }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function DateField({
|
||||
@@ -25,11 +25,9 @@ export function DateField({
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldKey}
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(date) => onChange(date)}
|
||||
disabled={disabled}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -186,6 +187,8 @@ export function ProductCertificationSection({
|
||||
setCertificationFile,
|
||||
isSubmitting,
|
||||
register,
|
||||
setValue,
|
||||
getValues,
|
||||
}: Pick<ProductFormProps,
|
||||
| 'remarks'
|
||||
| 'setRemarks'
|
||||
@@ -197,6 +200,8 @@ export function ProductCertificationSection({
|
||||
| 'setCertificationFile'
|
||||
| 'isSubmitting'
|
||||
| 'register'
|
||||
| 'setValue'
|
||||
| 'getValues'
|
||||
>) {
|
||||
return (
|
||||
<div className="pt-6 mt-6 border-t space-y-4">
|
||||
@@ -221,20 +226,18 @@ export function ProductCertificationSection({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationStartDate">인정 유효기간 시작일</Label>
|
||||
<Input
|
||||
id="certificationStartDate"
|
||||
type="date"
|
||||
{...register('certificationStartDate')}
|
||||
<DatePicker
|
||||
value={getValues('certificationStartDate') || ''}
|
||||
onChange={(date) => setValue('certificationStartDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="certificationEndDate">인정 유효기간 종료일</Label>
|
||||
<Input
|
||||
id="certificationEndDate"
|
||||
type="date"
|
||||
{...register('certificationEndDate')}
|
||||
<DatePicker
|
||||
value={getValues('certificationEndDate') || ''}
|
||||
onChange={(date) => setValue('certificationEndDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -348,6 +348,8 @@ export default function ItemForm({ mode, initialData, onSubmit }: ItemFormProps)
|
||||
setCertificationFile={setCertificationFile}
|
||||
isSubmitting={isSubmitting}
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
getValues={getValues}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Calendar } from 'lucide-react';
|
||||
|
||||
import { getTodayString } from '@/utils/date';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { materialInspectionCreateConfig } from './inspectionConfig';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
@@ -251,15 +252,10 @@ export function InspectionCreate({ id }: Props) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">검사일</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="date"
|
||||
value={inspectionDate}
|
||||
onChange={(e) => setInspectionDate(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
<DatePicker
|
||||
value={inspectionDate}
|
||||
onChange={(date) => setInspectionDate(date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ImportInspectionInputModal, type ImportInspectionData } from './ImportI
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
// import { SupplierSearchModal } from './SupplierSearchModal';
|
||||
import {
|
||||
@@ -538,12 +539,9 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
<Label htmlFor="receivingDate" className="text-sm text-muted-foreground">
|
||||
입고일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="receivingDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.receivingDate || ''}
|
||||
onChange={(e) => handleInputChange('receivingDate', e.target.value)}
|
||||
className="mt-1.5"
|
||||
onChange={(date) => handleInputChange('receivingDate', date)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -649,17 +647,16 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
|
||||
<TableRow key={adj.id}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={adj.adjustmentDate}
|
||||
onChange={(e) => {
|
||||
onChange={(date) => {
|
||||
setAdjustments((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === adj.id ? { ...a, adjustmentDate: e.target.value } : a
|
||||
a.id === adj.id ? { ...a, adjustmentDate: date } : a
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { format, startOfYear, endOfYear, subMonths, startOfMonth, endOfMonth, subDays } from 'date-fns';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
|
||||
|
||||
/**
|
||||
@@ -152,18 +152,20 @@ export function DateRangeSelector({
|
||||
{/* 날짜 범위 선택 */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
onChange={onStartDateChange}
|
||||
className="w-[170px]"
|
||||
size="sm"
|
||||
displayFormat="yyyy년 MM월 dd일"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
onChange={onEndDateChange}
|
||||
className="w-[170px]"
|
||||
size="sm"
|
||||
displayFormat="yyyy년 MM월 dd일"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -186,18 +188,20 @@ export function DateRangeSelector({
|
||||
{/* 날짜 범위 선택 */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
onChange={onStartDateChange}
|
||||
className="w-[170px]"
|
||||
size="sm"
|
||||
displayFormat="yyyy년 MM월 dd일"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
onChange={onEndDateChange}
|
||||
className="w-[170px]"
|
||||
size="sm"
|
||||
displayFormat="yyyy년 MM월 dd일"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PersonalNumberInput } from "../ui/personal-number-input";
|
||||
import { NumberInput } from "../ui/number-input";
|
||||
import { CurrencyInput } from "../ui/currency-input";
|
||||
import { QuantityInput } from "../ui/quantity-input";
|
||||
import { DatePicker } from "../ui/date-picker";
|
||||
|
||||
export type FormFieldType =
|
||||
| 'text'
|
||||
@@ -166,11 +167,9 @@ export function FormField({
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
id={htmlFor}
|
||||
type="date"
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
<DatePicker
|
||||
value={(value as string) || ''}
|
||||
onChange={(date) => onChange?.(date)}
|
||||
disabled={disabled}
|
||||
className={`${error ? 'border-red-500' : ''} ${inputClassName}`}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useDaumPostcode } from "@/hooks/useDaumPostcode";
|
||||
import { useClientList } from "@/hooks/useClientList";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QuantityInput } from "@/components/ui/quantity-input";
|
||||
@@ -730,13 +731,12 @@ export function OrderRegistration({
|
||||
<Label>
|
||||
납품요청일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={form.deliveryRequestDate}
|
||||
onChange={(e) => {
|
||||
onChange={(date) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
deliveryRequestDate: e.target.value,
|
||||
deliveryRequestDate: date,
|
||||
}));
|
||||
clearFieldError("deliveryRequestDate");
|
||||
}}
|
||||
@@ -750,13 +750,12 @@ export function OrderRegistration({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={form.expectedShipDate}
|
||||
onChange={(e) =>
|
||||
onChange={(date) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expectedShipDate: e.target.value,
|
||||
expectedShipDate: date,
|
||||
}))
|
||||
}
|
||||
disabled={form.expectedShipDateUndecided}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PhoneInput } from "@/components/ui/phone-input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -414,22 +415,20 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
|
||||
<Label>
|
||||
납품요청일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={form.deliveryRequestDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, deliveryRequestDate: e.target.value })
|
||||
onChange={(date) =>
|
||||
setForm({ ...form, deliveryRequestDate: date })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={form.expectedShipDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expectedShipDate: e.target.value })
|
||||
onChange={(date) =>
|
||||
setForm({ ...form, expectedShipDate: date })
|
||||
}
|
||||
disabled={form.expectedShipDateUndecided}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Plus, X as XIcon, ChevronDown, Search } from 'lucide-react';
|
||||
import { getTodayString } from '@/utils/date';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -426,19 +427,17 @@ export function ShipmentCreate() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고 예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.shipmentDate || ''}
|
||||
onChange={(e) => handleInputChange('shipmentDate', e.target.value)}
|
||||
onChange={(date) => handleInputChange('shipmentDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X as XIcon, ChevronDown, Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -423,19 +424,17 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고 예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
|
||||
onChange={(date) => handleInputChange('scheduledDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.shipmentDate || ''}
|
||||
onChange={(e) => handleInputChange('shipmentDate', e.target.value)}
|
||||
onChange={(date) => handleInputChange('shipmentDate', date)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||
@@ -304,11 +305,10 @@ export function PriceDistributionDetail({ id, mode: propMode }: Props) {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">적용시점</Label>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.effectiveDate}
|
||||
onChange={(e) => handleChange('effectiveDate', e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
onChange={(date) => handleChange('effectiveDate', date)}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm font-medium">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
@@ -426,11 +427,10 @@ export function PricingFormClient({
|
||||
<Label>
|
||||
적용일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={effectiveDate}
|
||||
onChange={(e) => {
|
||||
setEffectiveDate(e.target.value);
|
||||
onChange={(date) => {
|
||||
setEffectiveDate(date);
|
||||
setErrors((prev) => { const n = {...prev}; delete n.effectiveDate; return n; });
|
||||
}}
|
||||
className={errors.effectiveDate ? 'border-red-500' : ''}
|
||||
@@ -457,10 +457,9 @@ export function PricingFormClient({
|
||||
</div>
|
||||
<div>
|
||||
<Label>입고일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={receiveDate}
|
||||
onChange={(e) => setReceiveDate(e.target.value)}
|
||||
onChange={(date) => setReceiveDate(date)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -137,14 +137,15 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
{/* Row 1: 공정번호 | 공정명 | 담당부서 | 담당자 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">공정번호</div>
|
||||
<div className="font-medium">{process.processCode}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">공정형</div>
|
||||
<Badge variant="secondary">{process.processType}</Badge>
|
||||
<div className="text-sm text-muted-foreground">공정명</div>
|
||||
<div className="font-medium">{process.processName}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">담당부서</div>
|
||||
@@ -154,6 +155,13 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<div className="text-sm text-muted-foreground">담당자</div>
|
||||
<div className="font-medium">{process.manager || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">구분</div>
|
||||
<div className="font-medium">{process.processCategory || '없음'}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">생산일자</div>
|
||||
<div className="font-medium">
|
||||
@@ -188,7 +196,7 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
{itemCount}개
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
품목 선택
|
||||
공정 품목 선택
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 제거된 섹션: 자동분류규칙, 작업정보, 설명
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, GripVertical, Trash2, Package } from 'lucide-react';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
@@ -28,11 +28,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { RuleModal } from './RuleModal';
|
||||
import { toast } from 'sonner';
|
||||
import type { Process, ClassificationRule, ProcessType, ProcessStep } from '@/types/process';
|
||||
import { PROCESS_TYPE_OPTIONS } from '@/types/process';
|
||||
import { PROCESS_TYPE_OPTIONS, PROCESS_CATEGORY_OPTIONS } from '@/types/process';
|
||||
import {
|
||||
createProcess,
|
||||
updateProcess,
|
||||
@@ -57,6 +56,9 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
);
|
||||
const [department, setDepartment] = useState(initialData?.department || '');
|
||||
const [manager, setManager] = useState(initialData?.manager || '');
|
||||
const [processCategory, setProcessCategory] = useState(
|
||||
initialData?.processCategory || ''
|
||||
);
|
||||
const [useProductionDate, setUseProductionDate] = useState(
|
||||
initialData?.useProductionDate ?? false
|
||||
);
|
||||
@@ -86,6 +88,24 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// 공정명에 따른 구분 옵션 계산
|
||||
const categoryOptions = useMemo(() => {
|
||||
const name = processName.trim();
|
||||
for (const [key, options] of Object.entries(PROCESS_CATEGORY_OPTIONS)) {
|
||||
if (name.includes(key)) return options;
|
||||
}
|
||||
return [{ value: '없음', label: '없음' }];
|
||||
}, [processName]);
|
||||
|
||||
// 공정명 변경 시 구분 값 리셋
|
||||
useEffect(() => {
|
||||
if (categoryOptions.length === 0) {
|
||||
setProcessCategory('');
|
||||
} else if (processCategory && !categoryOptions.find(o => o.value === processCategory)) {
|
||||
setProcessCategory('');
|
||||
}
|
||||
}, [categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 품목 개수 계산
|
||||
const itemCount = classificationRules
|
||||
.filter((r) => r.registrationType === 'individual')
|
||||
@@ -227,6 +247,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
const formData = {
|
||||
processName: processName.trim(),
|
||||
processType,
|
||||
processCategory: processCategory || undefined,
|
||||
department,
|
||||
classificationRules: classificationRules.map((rule) => ({
|
||||
registrationType: rule.registrationType,
|
||||
@@ -288,7 +309,18 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
{/* Row 1: 공정번호(수정시) | 공정명 | 담당부서 | 담당자 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{isEdit && initialData?.processCode && (
|
||||
<div className="space-y-2">
|
||||
<Label>공정번호</Label>
|
||||
<Input
|
||||
value={initialData.processCode}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="processName">공정명 *</Label>
|
||||
<Input
|
||||
@@ -298,24 +330,6 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
placeholder="예: 스크린"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>공정형</Label>
|
||||
<Select
|
||||
value={processType}
|
||||
onValueChange={(v) => setProcessType(v as ProcessType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROCESS_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>담당부서 *</Label>
|
||||
<Select
|
||||
@@ -345,17 +359,42 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
placeholder="담당자명"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2: 구분 | 생산일자 | 상태 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
|
||||
<div className="space-y-2">
|
||||
<Label>구분</Label>
|
||||
<Select
|
||||
key={`category-${processName}`}
|
||||
value={processCategory}
|
||||
onValueChange={setProcessCategory}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>생산일자</Label>
|
||||
<div className="flex items-center gap-2 h-10">
|
||||
<Switch
|
||||
checked={useProductionDate}
|
||||
onCheckedChange={setUseProductionDate}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{useProductionDate ? '사용' : '미사용'}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={useProductionDate ? '사용' : '미사용'}
|
||||
onValueChange={(v) => setUseProductionDate(v === '사용')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
@@ -399,7 +438,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
size="sm"
|
||||
onClick={() => setRuleModalOpen(true)}
|
||||
>
|
||||
품목 선택
|
||||
공정 품목 선택
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,12 +592,15 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
|
||||
onAdd={handleSaveRule}
|
||||
editRule={editingRule}
|
||||
processId={initialData?.id}
|
||||
processName={processName}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
[
|
||||
processName,
|
||||
processType,
|
||||
processCategory,
|
||||
categoryOptions,
|
||||
department,
|
||||
manager,
|
||||
useProductionDate,
|
||||
|
||||
@@ -236,7 +236,6 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
{ key: 'processCode', label: '공정번호', className: 'w-[120px]' },
|
||||
{ key: 'processName', label: '공정명', className: 'min-w-[200px]' },
|
||||
{ key: 'department', label: '담당부서', className: 'w-[120px]' },
|
||||
{ key: 'steps', label: '단계', className: 'w-[80px] text-center' },
|
||||
{ key: 'items', label: '품목', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
],
|
||||
@@ -329,7 +328,6 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
<TableCell className="font-medium">{process.processCode}</TableCell>
|
||||
<TableCell>{process.processName}</TableCell>
|
||||
<TableCell>{process.department}</TableCell>
|
||||
<TableCell className="text-center">{process.steps?.length ?? 0}</TableCell>
|
||||
<TableCell className="text-center">{itemCount > 0 ? itemCount : '-'}</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
@@ -383,7 +381,6 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="담당부서" value={process.department} />
|
||||
<InfoField label="단계" value={`${process.steps?.length ?? 0}개`} />
|
||||
<InfoField label="품목" value={itemCount > 0 ? `${itemCount}개` : '-'} />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
@@ -31,17 +27,33 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, Package } from 'lucide-react';
|
||||
import type {
|
||||
ClassificationRule,
|
||||
RuleRegistrationType,
|
||||
RuleType,
|
||||
MatchingType,
|
||||
} from '@/types/process';
|
||||
import { RULE_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
|
||||
import { Search } from 'lucide-react';
|
||||
import type { ClassificationRule } from '@/types/process';
|
||||
import { PROCESS_CATEGORY_OPTIONS } from '@/types/process';
|
||||
|
||||
// 품목 유형 기본 옵션 (전체)
|
||||
const DEFAULT_ITEM_TYPE_OPTION = { value: 'all', label: '전체' };
|
||||
// 공정 필터 옵션
|
||||
const PROCESS_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '스크린', label: '스크린' },
|
||||
{ value: '슬릿', label: '슬릿' },
|
||||
{ value: '절곡', label: '절곡' },
|
||||
];
|
||||
|
||||
// 공정 필터에 따른 구분 필터 옵션
|
||||
function getCategoryFilterOptions(processFilter: string): { value: string; label: string }[] {
|
||||
if (processFilter === 'all') {
|
||||
return [{ value: 'all', label: '전체' }];
|
||||
}
|
||||
const categories = PROCESS_CATEGORY_OPTIONS[processFilter];
|
||||
if (!categories || categories.length === 0) {
|
||||
return [{ value: 'all', label: '전체' }];
|
||||
}
|
||||
// 스크린의 경우 '없음'만 있으므로 전체만 표시
|
||||
if (categories.length === 1 && categories[0].value === '없음') {
|
||||
return [{ value: 'all', label: '전체' }];
|
||||
}
|
||||
return [{ value: 'all', label: '전체' }, ...categories];
|
||||
}
|
||||
|
||||
interface RuleModalProps {
|
||||
open: boolean;
|
||||
@@ -50,44 +62,40 @@ interface RuleModalProps {
|
||||
editRule?: ClassificationRule;
|
||||
/** 현재 공정 ID (다른 공정에 이미 배정된 품목 제외용) */
|
||||
processId?: string;
|
||||
/** 현재 공정명 (하단 안내 문구용) */
|
||||
processName?: string;
|
||||
}
|
||||
|
||||
export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: RuleModalProps) {
|
||||
// 공통 상태
|
||||
const [registrationType, setRegistrationType] = useState<RuleRegistrationType>(
|
||||
editRule?.registrationType || 'pattern'
|
||||
);
|
||||
const [description, setDescription] = useState(editRule?.description || '');
|
||||
|
||||
// 패턴 규칙용 상태
|
||||
const [ruleType, setRuleType] = useState<RuleType>(editRule?.ruleType || '품목코드');
|
||||
const [matchingType, setMatchingType] = useState<MatchingType>(
|
||||
editRule?.matchingType || 'startsWith'
|
||||
);
|
||||
const [conditionValue, setConditionValue] = useState(editRule?.conditionValue || '');
|
||||
const [priority, setPriority] = useState(editRule?.priority || 10);
|
||||
const [isActive, setIsActive] = useState(editRule?.isActive ?? true);
|
||||
|
||||
// 개별 품목용 상태
|
||||
export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, processName }: RuleModalProps) {
|
||||
// 검색/필터 상태
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [selectedItemType, setSelectedItemType] = useState('all');
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 공정/구분 필터 상태
|
||||
const [processFilter, setProcessFilter] = useState('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
|
||||
// 품목 목록 API 상태
|
||||
const [itemList, setItemList] = useState<ItemOption[]>([]);
|
||||
const [isItemsLoading, setIsItemsLoading] = useState(false);
|
||||
|
||||
// 품목 유형 옵션 (common_codes에서 동적 조회)
|
||||
const [itemTypeOptions, setItemTypeOptions] = useState<Array<{ value: string; label: string }>>([DEFAULT_ITEM_TYPE_OPTION]);
|
||||
const [itemTypeOptions, setItemTypeOptions] = useState<Array<{ value: string; label: string }>>([
|
||||
{ value: 'all', label: '전체' },
|
||||
]);
|
||||
|
||||
// 품목 목록 로드 (debounced)
|
||||
// 구분 필터 옵션 (공정 필터에 따라 변경)
|
||||
const categoryFilterOptions = getCategoryFilterOptions(processFilter);
|
||||
|
||||
// 품목 목록 로드
|
||||
const loadItems = useCallback(async (q?: string, itemType?: string) => {
|
||||
setIsItemsLoading(true);
|
||||
const items = await getItemList({
|
||||
q: q || undefined,
|
||||
itemType: itemType === 'all' ? undefined : itemType,
|
||||
size: 1000, // 전체 품목 조회
|
||||
excludeProcessId: processId, // 다른 공정에 이미 배정된 품목 제외
|
||||
size: 1000,
|
||||
excludeProcessId: processId,
|
||||
});
|
||||
setItemList(items);
|
||||
setIsItemsLoading(false);
|
||||
@@ -96,21 +104,14 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: Ru
|
||||
// 검색어 유효성 검사 함수
|
||||
const isValidSearchKeyword = (keyword: string): boolean => {
|
||||
if (!keyword || keyword.trim() === '') return false;
|
||||
|
||||
const trimmed = keyword.trim();
|
||||
// 한글이 포함되어 있으면 1자 이상
|
||||
const hasKorean = /[가-힣]/.test(trimmed);
|
||||
if (hasKorean) return trimmed.length >= 1;
|
||||
|
||||
// 영어/숫자만 있으면 2자 이상
|
||||
return trimmed.length >= 2;
|
||||
};
|
||||
|
||||
// 검색어/품목유형 변경 시 API 호출 (debounce)
|
||||
// 검색어 변경 시 API 호출 (debounce)
|
||||
useEffect(() => {
|
||||
if (registrationType !== 'individual') return;
|
||||
|
||||
// 검색어 유효성 검사 - 유효하지 않으면 빈 목록
|
||||
if (!isValidSearchKeyword(searchKeyword)) {
|
||||
setItemList([]);
|
||||
return;
|
||||
@@ -121,30 +122,40 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: Ru
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchKeyword, selectedItemType, registrationType, loadItems]);
|
||||
}, [searchKeyword, selectedItemType, loadItems]);
|
||||
|
||||
// 품목유형 변경 시 검색어가 유효하면 재검색
|
||||
useEffect(() => {
|
||||
if (registrationType !== 'individual') return;
|
||||
if (!isValidSearchKeyword(searchKeyword)) return;
|
||||
|
||||
loadItems(searchKeyword, selectedItemType);
|
||||
}, [selectedItemType]);
|
||||
|
||||
// 모달 열릴 때 품목 목록 초기화 + 품목유형 옵션 로드
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// 품목유형 옵션 로드 (common_codes에서 동적 조회)
|
||||
// 품목유형 옵션 로드
|
||||
getItemTypeOptions().then((options) => {
|
||||
setItemTypeOptions([DEFAULT_ITEM_TYPE_OPTION, ...options]);
|
||||
setItemTypeOptions([{ value: 'all', label: '전체' }, ...options]);
|
||||
});
|
||||
|
||||
if (registrationType === 'individual') {
|
||||
setItemList([]);
|
||||
setSearchKeyword('');
|
||||
if (editRule) {
|
||||
// 수정 모드: 기존 선택된 품목 ID 설정
|
||||
if (editRule.registrationType === 'individual') {
|
||||
const ids = editRule.conditionValue.split(',').filter(Boolean);
|
||||
setSelectedItemIds(new Set(ids));
|
||||
} else {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
} else {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
setProcessFilter('all');
|
||||
setCategoryFilter('all');
|
||||
setItemList([]);
|
||||
}
|
||||
}, [open, registrationType]);
|
||||
}, [open, editRule]);
|
||||
|
||||
// 공정 필터 변경 시 구분 필터 리셋
|
||||
useEffect(() => {
|
||||
setCategoryFilter('all');
|
||||
}, [processFilter]);
|
||||
|
||||
// 체크박스 토글
|
||||
const handleToggleItem = (id: string) => {
|
||||
@@ -159,363 +170,174 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId }: Ru
|
||||
});
|
||||
};
|
||||
|
||||
// 전체 선택
|
||||
const handleSelectAll = () => {
|
||||
const allIds = itemList.map((item) => item.id);
|
||||
setSelectedItemIds(new Set(allIds));
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleResetSelection = () => {
|
||||
setSelectedItemIds(new Set());
|
||||
};
|
||||
|
||||
// 모달 열릴 때 초기화 또는 수정 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editRule) {
|
||||
// 수정 모드: 기존 데이터 로드
|
||||
setRegistrationType(editRule.registrationType);
|
||||
setDescription(editRule.description || '');
|
||||
setRuleType(editRule.ruleType);
|
||||
setMatchingType(editRule.matchingType);
|
||||
setConditionValue(editRule.conditionValue);
|
||||
setPriority(editRule.priority);
|
||||
setIsActive(editRule.isActive);
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
|
||||
// 개별 품목인 경우 선택된 품목 ID 설정
|
||||
if (editRule.registrationType === 'individual') {
|
||||
const ids = editRule.conditionValue.split(',').filter(Boolean);
|
||||
setSelectedItemIds(new Set(ids));
|
||||
} else {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
} else {
|
||||
// 추가 모드: 초기화 (개별 품목을 디폴트로)
|
||||
setRegistrationType('individual');
|
||||
setDescription('');
|
||||
setRuleType('품목코드');
|
||||
setMatchingType('startsWith');
|
||||
setConditionValue('');
|
||||
setPriority(10);
|
||||
setIsActive(true);
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
}
|
||||
}, [open, editRule]);
|
||||
|
||||
// 저장
|
||||
const handleSubmit = () => {
|
||||
if (registrationType === 'pattern') {
|
||||
if (!conditionValue.trim()) {
|
||||
alert('조건 값을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (selectedItemIds.size === 0) {
|
||||
alert('품목을 최소 1개 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (selectedItemIds.size === 0) {
|
||||
alert('품목을 최소 1개 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 개별 품목의 경우 conditionValue에 품목코드들을 저장
|
||||
const finalConditionValue =
|
||||
registrationType === 'individual'
|
||||
? Array.from(selectedItemIds).join(',')
|
||||
: conditionValue.trim();
|
||||
const finalConditionValue = Array.from(selectedItemIds).join(',');
|
||||
|
||||
onAdd({
|
||||
registrationType,
|
||||
ruleType: registrationType === 'individual' ? '품목코드' : ruleType,
|
||||
matchingType: registrationType === 'individual' ? 'equals' : matchingType,
|
||||
registrationType: 'individual',
|
||||
ruleType: '품목코드',
|
||||
matchingType: 'equals',
|
||||
conditionValue: finalConditionValue,
|
||||
priority: registrationType === 'individual' ? 10 : priority,
|
||||
description: description.trim() || undefined,
|
||||
isActive: registrationType === 'individual' ? true : isActive,
|
||||
priority: 10,
|
||||
description: undefined,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setRegistrationType('pattern');
|
||||
setDescription('');
|
||||
setRuleType('품목코드');
|
||||
setMatchingType('startsWith');
|
||||
setConditionValue('');
|
||||
setPriority(10);
|
||||
setIsActive(true);
|
||||
// Reset
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
setSelectedItemIds(new Set());
|
||||
setProcessFilter('all');
|
||||
setCategoryFilter('all');
|
||||
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={registrationType === 'individual' ? 'max-w-xl max-h-[90vh] flex flex-col' : 'max-w-md'}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editRule ? '규칙 수정' : '규칙 추가'}</DialogTitle>
|
||||
<DialogTitle>공정 품목 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4 overflow-y-auto flex-1">
|
||||
{/* 등록 방식 */}
|
||||
<div className="space-y-3">
|
||||
<Label>등록 방식 *</Label>
|
||||
<RadioGroup
|
||||
value={registrationType}
|
||||
onValueChange={(v) => setRegistrationType(v as RuleRegistrationType)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pattern" id="pattern" />
|
||||
<Label htmlFor="pattern" className="font-normal">
|
||||
패턴 규칙 (코드/명칭 기반 자동 분류)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="individual" id="individual" />
|
||||
<Label htmlFor="individual" className="font-normal">
|
||||
개별 품목 (특정 품목 직접 지정)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
placeholder="품목코드 또는 품목명으로 검색..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 패턴 규칙 UI */}
|
||||
{registrationType === 'pattern' && (
|
||||
<>
|
||||
{/* 규칙 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label>규칙 유형 *</Label>
|
||||
<Select value={ruleType} onValueChange={(v) => setRuleType(v as RuleType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RULE_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 카운트 + 필터 행 */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{isItemsLoading ? (
|
||||
'로딩 중...'
|
||||
) : (
|
||||
<>
|
||||
총 {itemList.length}건{' '}
|
||||
{selectedItemIds.size > 0 && (
|
||||
<span className="text-primary font-medium">
|
||||
{selectedItemIds.size}건 선택됨
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={processFilter} onValueChange={setProcessFilter}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||
<SelectValue placeholder="공정" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROCESS_FILTER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
key={`category-${processFilter}`}
|
||||
value={categoryFilter}
|
||||
onValueChange={setCategoryFilter}
|
||||
disabled={categoryFilterOptions.length <= 1}
|
||||
>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryFilterOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매칭 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label>매칭 방식 *</Label>
|
||||
<Select
|
||||
value={matchingType}
|
||||
onValueChange={(v) => setMatchingType(v as MatchingType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MATCHING_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건 값 */}
|
||||
<div className="space-y-2">
|
||||
<Label>조건 값 *</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={conditionValue}
|
||||
onChange={(e) => setConditionValue(e.target.value)}
|
||||
placeholder="예: SCR-, E-, STEEL-"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter 키를 누르거나 검색 버튼을 클릭하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 우선순위 - 패턴 규칙에서만 표시 */}
|
||||
<div className="space-y-2">
|
||||
<Label>우선순위</Label>
|
||||
<QuantityInput
|
||||
value={priority}
|
||||
onChange={(value) => setPriority(value ?? 1)}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-24"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">낮을수록 먼저 적용됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 설명 - 패턴 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label>설명</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="규칙에 대한 설명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 활성 상태 - 패턴 규칙에서만 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>활성 상태</Label>
|
||||
<Switch checked={isActive} onCheckedChange={setIsActive} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 개별 품목 UI - 기획서 기준 */}
|
||||
{registrationType === 'individual' && (
|
||||
<>
|
||||
{/* 설명 (선택) */}
|
||||
<div className="space-y-2">
|
||||
<Label>설명 (선택)</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="이 품목 그룹에 대한 설명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품목 검색 + 품목 유형 필터 */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm">품목 검색</Label>
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
placeholder="품목코드 또는 품목명으로 검색..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Label className="text-sm">품목 유형</Label>
|
||||
<Select value={selectedItemType} onValueChange={setSelectedItemType}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{itemTypeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 목록 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
<span>
|
||||
{isItemsLoading ? (
|
||||
'로딩 중...'
|
||||
) : (
|
||||
<>품목 목록 ({itemList.length}개) | 선택됨 ({selectedItemIds.size}개)</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs h-7"
|
||||
disabled={isItemsLoading || itemList.length === 0}
|
||||
>
|
||||
전체 선택
|
||||
</Button>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetSelection}
|
||||
className="text-xs h-7"
|
||||
disabled={selectedItemIds.size === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border rounded-lg max-h-[280px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px]">품목유형</TableHead>
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border rounded-lg max-h-[340px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
<TableHead className="w-[80px]">품목유형</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
{/* TODO: API에서 process_name, process_category 필드 지원 후 실제 데이터 표시 */}
|
||||
<TableHead className="w-[80px]">공정</TableHead>
|
||||
<TableHead className="w-[80px]">구분</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isItemsLoading ? (
|
||||
<TableRow key="loading">
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
품목 목록을 불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : itemList.length === 0 ? (
|
||||
<TableRow key="empty">
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
{searchKeyword.trim() === ''
|
||||
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
|
||||
: '검색 결과가 없습니다'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
itemList.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggleItem(item.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItemIds.has(item.id)}
|
||||
onCheckedChange={() => handleToggleItem(item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.fullName}</TableCell>
|
||||
{/* TODO: API 지원 후 item.processName / item.processCategory 표시 */}
|
||||
<TableCell className="text-muted-foreground">-</TableCell>
|
||||
<TableCell className="text-muted-foreground">-</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isItemsLoading ? (
|
||||
<TableRow key="loading">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||
품목 목록을 불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : itemList.length === 0 ? (
|
||||
<TableRow key="empty">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||
{searchKeyword.trim() === ''
|
||||
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
|
||||
: '검색 결과가 없습니다'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
itemList.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggleItem(item.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItemIds.has(item.id)}
|
||||
onCheckedChange={() => handleToggleItem(item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.code}</TableCell>
|
||||
<TableCell>{item.fullName}</TableCell>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
이 공정에 배정할 품목을 선택하세요. 다른 공정에 이미 배정된 품목은 표시되지 않습니다.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택 후 저장하시면 선택한 품목들이{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{processName || '해당'}
|
||||
</span>{' '}
|
||||
공정으로 변경됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>{editRule ? '수정' : '추가'}</Button>
|
||||
<Button onClick={handleSubmit}>저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
initialData?.needsApproval ? '필요' : '불필요'
|
||||
);
|
||||
const [needsInspection, setNeedsInspection] = useState(
|
||||
initialData?.needsInspection ? '필요' : '불필요'
|
||||
initialData?.needsInspection ? '사용' : '미사용'
|
||||
);
|
||||
const [isActive, setIsActive] = useState(
|
||||
initialData?.isActive !== false ? '사용' : '미사용'
|
||||
@@ -115,8 +115,8 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 검사여부가 "필요"인지 확인
|
||||
const isInspectionEnabled = needsInspection === '필요';
|
||||
// 검사여부가 "사용"인지 확인
|
||||
const isInspectionEnabled = needsInspection === '사용';
|
||||
|
||||
// 제출
|
||||
const handleSubmit = async (): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -130,7 +130,7 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
stepName: stepName.trim(),
|
||||
isRequired: isRequired === '필수',
|
||||
needsApproval: needsApproval === '필요',
|
||||
needsInspection: needsInspection === '필요',
|
||||
needsInspection: needsInspection === '사용',
|
||||
isActive: isActive === '사용',
|
||||
order: initialData?.order || 0,
|
||||
connectionType,
|
||||
@@ -232,8 +232,8 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="필요">필요</SelectItem>
|
||||
<SelectItem value="불필요">불필요</SelectItem>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -293,28 +293,6 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 검사여부가 "필요"일 때 버튼 표시 */}
|
||||
{isInspectionEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||||
onClick={() => setIsInspectionSettingOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
검사 설정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsInspectionPreviewOpen(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
검사 미리보기
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -374,6 +352,29 @@ export function StepForm({ mode, processId, initialData }: StepFormProps) {
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
renderForm={renderFormContent}
|
||||
headerActions={
|
||||
isInspectionEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||||
onClick={() => setIsInspectionSettingOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
검사 설정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsInspectionPreviewOpen(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
검사 미리보기
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 검사 설정 모달 */}
|
||||
|
||||
@@ -614,6 +614,9 @@ export interface ItemOption {
|
||||
id: string;
|
||||
fullName: string;
|
||||
type: string;
|
||||
// TODO: API 응답에 process_name, process_category 필드 추가 후 활성화
|
||||
processName?: string;
|
||||
processCategory?: string;
|
||||
}
|
||||
|
||||
interface GetItemListParams {
|
||||
@@ -627,6 +630,10 @@ interface GetItemListParams {
|
||||
/**
|
||||
* 품목 목록 조회 (분류 규칙용)
|
||||
* - excludeProcessId: 다른 공정에 이미 배정된 품목 제외 (중복 방지)
|
||||
*
|
||||
* TODO: 백엔드 API 수정 요청
|
||||
* - 응답에 process_name, process_category 필드 추가 필요 (공정 품목 선택 팝업에서 공정/구분 컬럼 표시용)
|
||||
* - 파라미터에 processName, processCategory 필터 추가 필요 (공정/구분 필터링용)
|
||||
*/
|
||||
export async function getItemList(params?: GetItemListParams): Promise<ItemOption[]> {
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
@@ -455,11 +456,9 @@ export function WorkOrderCreate() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.shipmentDate}
|
||||
onChange={(e) => setFormData({ ...formData, shipmentDate: e.target.value })}
|
||||
className="bg-white"
|
||||
onChange={(date) => setFormData({ ...formData, shipmentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -369,7 +369,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 md:p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 md:gap-x-6 gap-y-4">
|
||||
{/* 1행: 작업번호 | 수주일 | 공정구분 | 로트번호 */}
|
||||
{/* 1행: 작업번호 | 수주일 | 공정 | 구분 */}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground mb-1">작업번호</p>
|
||||
<p className="font-medium truncate">{order.workOrderNo}</p>
|
||||
@@ -379,15 +379,19 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<p className="font-medium">{order.salesOrderDate || '-'}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground mb-1">공정구분</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">공정</p>
|
||||
<p className="font-medium">{order.processName}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground mb-1">구분</p>
|
||||
<p className="font-medium">-</p>
|
||||
</div>
|
||||
|
||||
{/* 2행: 로트번호 | 수주처 | 현장명 | 수주 담당자 */}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground mb-1">로트번호</p>
|
||||
<p className="font-medium truncate">{order.lotNo}</p>
|
||||
</div>
|
||||
|
||||
{/* 2행: 수주처 | 현장명 | 수주 담당자 | 담당자 연락처 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">수주처</p>
|
||||
<p className="font-medium">{order.client}</p>
|
||||
@@ -400,12 +404,12 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<p className="text-sm text-muted-foreground mb-1">수주 담당자</p>
|
||||
<p className="font-medium">-</p>
|
||||
</div>
|
||||
|
||||
{/* 3행: 담당자 연락처 | 출고예정일 | 틀수 | 우선순위 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">담당자 연락처</p>
|
||||
<p className="font-medium">-</p>
|
||||
</div>
|
||||
|
||||
{/* 3행: 출고예정일 | 틀수 | 우선순위 | 부서 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">출고예정일</p>
|
||||
<p className="font-medium">{order.shipmentDate || '-'}</p>
|
||||
@@ -418,12 +422,12 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<p className="text-sm text-muted-foreground mb-1">우선순위</p>
|
||||
<p className="font-medium">{order.priorityLabel || '-'}</p>
|
||||
</div>
|
||||
|
||||
{/* 4행: 부서 | 생산 담당자 | 상태 | 비고 */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">부서</p>
|
||||
<p className="font-medium">{order.department || '-'}</p>
|
||||
</div>
|
||||
|
||||
{/* 4행: 생산 담당자 | 상태 | 비고 (colspan 2) */}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">생산 담당자</p>
|
||||
<p className="font-medium">
|
||||
@@ -438,7 +442,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
{WORK_ORDER_STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">비고</p>
|
||||
<p className="font-medium whitespace-pre-wrap">{order.note || '-'}</p>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -365,7 +366,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<section className="bg-amber-50 border border-amber-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-4 gap-x-6 gap-y-4">
|
||||
{/* 1행: 작업번호(읽기) | 수주일(읽기) | 공정구분(셀렉트) | 로트번호(읽기) */}
|
||||
{/* 1행: 작업번호(읽기) | 수주일(읽기) | 공정(셀렉트) | 구분(읽기) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">작업번호</Label>
|
||||
<Input value={workOrder?.workOrderNo || '-'} disabled className="bg-muted" />
|
||||
@@ -375,7 +376,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<Input value={workOrder?.salesOrderDate || '-'} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">공정구분 *</Label>
|
||||
<Label className="text-sm text-muted-foreground">공정 *</Label>
|
||||
<Select
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
@@ -393,12 +394,16 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">구분</Label>
|
||||
<Input value="-" disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 2행: 로트번호(읽기) | 수주처(읽기) | 현장명(입력) | 수주 담당자(읽기) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">로트번호</Label>
|
||||
<Input value={formData.orderNo || '-'} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 2행: 수주처(읽기) | 현장명(입력) | 수주 담당자(읽기) | 담당자 연락처(읽기) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">수주처</Label>
|
||||
<Input value={formData.client} disabled className="bg-muted" />
|
||||
@@ -415,19 +420,17 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<Label className="text-sm text-muted-foreground">수주 담당자</Label>
|
||||
<Input value="-" disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 3행: 담당자 연락처(읽기) | 출고예정일(입력) | 틀수(읽기) | 우선순위(셀렉트) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">담당자 연락처</Label>
|
||||
<Input value="-" disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 3행: 출고예정일(입력) | 틀수(읽기) | 우선순위(셀렉트) | 부서(읽기) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
|
||||
className="bg-white"
|
||||
onChange={(date) => setFormData({ ...formData, scheduledDate: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -452,12 +455,12 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 4행: 부서(읽기) | 생산 담당자(선택) | 상태(읽기) | 비고(입력) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">부서</Label>
|
||||
<Input value={workOrder?.department || '-'} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 4행: 생산 담당자(선택) | 상태(읽기) | 비고(입력, colspan 2) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">생산 담당자</Label>
|
||||
<div
|
||||
@@ -475,7 +478,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
|
||||
<Label className="text-sm text-muted-foreground">상태</Label>
|
||||
<Input value={workOrder ? (workOrder.status === 'waiting' ? '작업대기' : workOrder.status === 'in_progress' ? '작업중' : workOrder.status === 'completed' ? '작업완료' : workOrder.status) : '-'} disabled className="bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">비고</Label>
|
||||
<Textarea
|
||||
value={formData.note}
|
||||
|
||||
@@ -57,12 +57,11 @@ export function WorkOrderList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 활성 탭 및 재공품 모달 =====
|
||||
const [activeTab, setActiveTab] = useState(TAB_ALL);
|
||||
const [activeTab, setActiveTab] = useState('screen');
|
||||
const [isWipModalOpen, setIsWipModalOpen] = useState(false);
|
||||
|
||||
// ===== 공정 목록 및 ID 매핑 (API에서 동적 로드) =====
|
||||
const [processList, setProcessList] = useState<ProcessOption[]>([]);
|
||||
const [processMap, setProcessMap] = useState<Record<string, number | string>>({});
|
||||
// ===== 공정 ID 매핑 (getProcessOptions) =====
|
||||
const [processMap, setProcessMap] = useState<Record<string, number>>({});
|
||||
const [processMapLoaded, setProcessMapLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,42 +69,17 @@ export function WorkOrderList() {
|
||||
try {
|
||||
const result = await getProcessOptions();
|
||||
if (result.success && result.data) {
|
||||
setProcessList(result.data);
|
||||
|
||||
// 공정 ID → 탭 value 매핑 (공정 ID를 탭 value로 사용)
|
||||
const map: Record<string, number | string> = {
|
||||
[TAB_ALL]: TAB_ALL, // 전체: 필터 없음
|
||||
[TAB_OTHER]: 'none', // 기타: process_id IS NULL
|
||||
};
|
||||
const map: Record<string, number> = {};
|
||||
result.data.forEach((process: ProcessOption) => {
|
||||
// 탭 value = 공정 ID (문자열)
|
||||
map[String(process.id)] = process.id;
|
||||
// process_name 또는 process_code로 탭 매핑
|
||||
const tabKeyByName = PROCESS_NAME_TO_TAB[process.processName];
|
||||
const tabKeyByCode = PROCESS_CODE_TO_TAB[process.processCode];
|
||||
const tabKey = tabKeyByName || tabKeyByCode;
|
||||
if (tabKey) {
|
||||
map[tabKey] = process.id;
|
||||
}
|
||||
});
|
||||
setProcessMap(map);
|
||||
|
||||
// 탭 카운트 조회 (전체 + 기타 + 각 공정)
|
||||
const tabKeys = [TAB_ALL, TAB_OTHER, ...result.data.map(p => String(p.id))];
|
||||
const countPromises = tabKeys.map(async (tabKey) => {
|
||||
let res;
|
||||
if (tabKey === TAB_ALL) {
|
||||
// 전체: processId 파라미터 없이
|
||||
res = await getWorkOrders({ page: 1, perPage: 1 });
|
||||
} else if (tabKey === TAB_OTHER) {
|
||||
// 기타: process_id = none (NULL)
|
||||
res = await getWorkOrders({ page: 1, perPage: 1, processId: 'none' as unknown as number });
|
||||
} else {
|
||||
// 공정별: 해당 공정 ID
|
||||
res = await getWorkOrders({ page: 1, perPage: 1, processId: Number(tabKey) });
|
||||
}
|
||||
return { tabKey, count: res.success ? res.pagination.total : 0 };
|
||||
});
|
||||
|
||||
const counts = await Promise.all(countPromises);
|
||||
const newTabCounts: Record<string, number> = {};
|
||||
counts.forEach(({ tabKey, count }) => {
|
||||
newTabCounts[tabKey] = count;
|
||||
});
|
||||
setTabCounts(newTabCounts);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -156,24 +130,20 @@ export function WorkOrderList() {
|
||||
router.push('/ko/production/work-orders?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 탭 옵션 (전체 + 공정들 + 기타) — 카운트는 API 응답으로 동적 업데이트 =====
|
||||
// ===== 탭 옵션 (공정 기반 3개) — 카운트는 API 응답으로 동적 업데이트 =====
|
||||
const [tabCounts, setTabCounts] = useState<Record<string, number>>({
|
||||
[TAB_ALL]: 0,
|
||||
[TAB_OTHER]: 0,
|
||||
screen: 0,
|
||||
slat: 0,
|
||||
bending: 0,
|
||||
});
|
||||
|
||||
const tabs: TabOption[] = useMemo(
|
||||
() => [
|
||||
{ value: TAB_ALL, label: '전체', count: tabCounts[TAB_ALL] },
|
||||
// 공정 목록에서 동적 생성
|
||||
...processList.map((process) => ({
|
||||
value: String(process.id),
|
||||
label: process.processName,
|
||||
count: tabCounts[String(process.id)] || 0,
|
||||
})),
|
||||
{ value: TAB_OTHER, label: '기타', count: tabCounts[TAB_OTHER] },
|
||||
{ value: 'screen', label: '스크린 공정', count: tabCounts.screen },
|
||||
{ value: 'slat', label: '슬랫 공정', count: tabCounts.slat },
|
||||
{ value: 'bending', label: '절곡 공정', count: tabCounts.bending },
|
||||
],
|
||||
[tabCounts, processList]
|
||||
[tabCounts]
|
||||
);
|
||||
|
||||
// ===== 통계 카드 6개 (기획서 기반) =====
|
||||
@@ -235,31 +205,29 @@ export function WorkOrderList() {
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
// 탭 value 확인
|
||||
const tabValue = params?.tab || TAB_ALL;
|
||||
// 탭 → processId 매핑
|
||||
const tabValue = params?.tab || 'screen';
|
||||
setActiveTab(tabValue);
|
||||
const processId = processMap[tabValue];
|
||||
|
||||
// 해당 공정이 DB에 없으면 빈 목록 반환
|
||||
if (!processId) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 필터 값 추출
|
||||
const statusFilter = params?.filters?.status as string | undefined;
|
||||
const priorityFilter = params?.filters?.priority as string | undefined;
|
||||
|
||||
// processId 결정
|
||||
let processIdParam: number | 'none' | undefined;
|
||||
if (tabValue === TAB_ALL) {
|
||||
// 전체: processId 파라미터 없음
|
||||
processIdParam = undefined;
|
||||
} else if (tabValue === TAB_OTHER) {
|
||||
// 기타: process_id IS NULL
|
||||
processIdParam = 'none';
|
||||
} else {
|
||||
// 공정별: 해당 공정 ID
|
||||
processIdParam = Number(tabValue);
|
||||
}
|
||||
|
||||
const result = await getWorkOrders({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
processId: processIdParam as number | undefined,
|
||||
processId,
|
||||
status: statusFilter && statusFilter !== 'all'
|
||||
? (statusFilter as WorkOrderStatus)
|
||||
: undefined,
|
||||
@@ -306,8 +274,8 @@ export function WorkOrderList() {
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
|
||||
{ key: 'client', label: '수주처', className: 'min-w-[120px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'processName', label: '공정', className: 'w-[80px]' },
|
||||
{ key: 'itemCount', label: '틀수', className: 'w-[70px] text-center' },
|
||||
{ key: 'shutterCount', label: '틀수', className: 'w-[70px] text-center' },
|
||||
{ key: 'category', label: '구분', className: 'w-[80px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[90px]' },
|
||||
{ key: 'priority', label: '우선순위', className: 'w-[80px]' },
|
||||
{ key: 'department', label: '부서', className: 'w-[90px]' },
|
||||
@@ -401,18 +369,8 @@ export function WorkOrderList() {
|
||||
<TableCell>{item.lotNo}</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{item.projectName}</TableCell>
|
||||
<TableCell>
|
||||
{item.processName && item.processName !== '-' ? (
|
||||
<Badge variant="outline" className="text-xs font-medium">
|
||||
{item.processName}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
미지정
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.items?.length || item.shutterCount || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.shutterCount ?? '-'}</TableCell>
|
||||
<TableCell>{'-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[item.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[item.status]}
|
||||
|
||||
@@ -134,13 +134,18 @@ export function WorkItemCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 공정 단계 버튼 - 중간검사는 포장완료 앞에 위치 */}
|
||||
{/* 공정 단계 버튼 - 중간검사 포함 인라인 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 포장완료 전 단계들 */}
|
||||
{item.steps.slice(0, -1).map((step) => (
|
||||
{item.steps.map((step) => (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => handleStepClick(step)}
|
||||
onClick={() => {
|
||||
if (step.isInspection && onInspectionClick) {
|
||||
onInspectionClick(item.id);
|
||||
} else {
|
||||
handleStepClick(step);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
step.isCompleted
|
||||
@@ -154,36 +159,6 @@ export function WorkItemCard({
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* 중간검사 버튼 - 포장완료 앞 */}
|
||||
{onInspectionClick && (
|
||||
<button
|
||||
onClick={() => onInspectionClick(item.id)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-gray-800 text-white hover:bg-gray-700"
|
||||
>
|
||||
중간검사
|
||||
</button>
|
||||
)}
|
||||
{/* 포장완료 (마지막 단계) */}
|
||||
{item.steps.length > 0 && (() => {
|
||||
const lastStep = item.steps[item.steps.length - 1];
|
||||
return (
|
||||
<button
|
||||
key={lastStep.id}
|
||||
onClick={() => handleStepClick(lastStep)}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
lastStep.isCompleted
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-gray-800 text-white hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{lastStep.name}
|
||||
{lastStep.isCompleted && (
|
||||
<span className="ml-1.5 font-semibold">완료</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 자재 투입 목록 (토글) */}
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getMyWorkOrders, completeWorkOrder } from './actions';
|
||||
import { getProcessList } from '@/components/process-management/actions';
|
||||
@@ -67,7 +69,8 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
{ id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 's1-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' },
|
||||
@@ -82,7 +85,8 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
{ id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's2-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 's2-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -93,7 +97,8 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
{ id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 's3-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 's3-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 's3-5', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' },
|
||||
@@ -108,7 +113,8 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
steps: [
|
||||
{ id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'l1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' },
|
||||
@@ -121,7 +127,8 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
steps: [
|
||||
{ id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'l2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'l2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -143,7 +150,7 @@ const MOCK_ITEMS: Record<ProcessTab, WorkItemData[]> = {
|
||||
{ id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true },
|
||||
{ id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'b1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'b1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -159,6 +166,7 @@ const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
|
||||
steps: [
|
||||
{ id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'bw1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -169,6 +177,7 @@ const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
|
||||
steps: [
|
||||
{ id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'bw2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -179,6 +188,7 @@ const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [
|
||||
steps: [
|
||||
{ id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'bw3-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -193,7 +203,8 @@ const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
|
||||
steps: [
|
||||
{ id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true },
|
||||
{ id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb1-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'jb1-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
materialInputs: [
|
||||
{ id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' },
|
||||
@@ -207,33 +218,60 @@ const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [
|
||||
steps: [
|
||||
{ id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false },
|
||||
{ id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb2-3', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
{ id: 'jb2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false },
|
||||
{ id: 'jb2-4', name: '포장완료', isMaterialInput: false, isCompleted: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 사이드바 작업지시 목업 데이터
|
||||
interface SidebarOrder {
|
||||
id: string;
|
||||
siteName: string;
|
||||
date: string;
|
||||
quantity: number;
|
||||
shutterCount: number;
|
||||
priority: 'urgent' | 'priority' | 'normal';
|
||||
}
|
||||
|
||||
const MOCK_SIDEBAR_ORDERS: SidebarOrder[] = [
|
||||
{ id: 'order-1', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent' },
|
||||
{ id: 'order-2', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'priority' },
|
||||
{ id: 'order-3', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' },
|
||||
{ id: 'order-4', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' },
|
||||
];
|
||||
|
||||
const PRIORITY_GROUPS = [
|
||||
{ key: 'urgent' as const, label: '긴급', color: 'text-red-600' },
|
||||
{ key: 'priority' as const, label: '우선', color: 'text-orange-600' },
|
||||
{ key: 'normal' as const, label: '일반', color: 'text-gray-600' },
|
||||
];
|
||||
|
||||
// 하드코딩된 공정별 단계 폴백
|
||||
const PROCESS_STEPS: Record<string, { name: string; isMaterialInput: boolean }[]> = {
|
||||
const PROCESS_STEPS: Record<string, { name: string; isMaterialInput: boolean; isInspection?: boolean }[]> = {
|
||||
screen: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
{ name: '미싱', isMaterialInput: false },
|
||||
{ name: '중간검사', isMaterialInput: false, isInspection: true },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
slat: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '포밍/절단', isMaterialInput: false },
|
||||
{ name: '중간검사', isMaterialInput: false, isInspection: true },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
],
|
||||
bending: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
{ name: '절곡', isMaterialInput: false },
|
||||
{ name: '포장완료', isMaterialInput: false },
|
||||
{ name: '중간검사', isMaterialInput: false, isInspection: true },
|
||||
],
|
||||
bending_wip: [
|
||||
{ name: '자재투입', isMaterialInput: true },
|
||||
{ name: '절단', isMaterialInput: false },
|
||||
{ name: '중간검사', isMaterialInput: false, isInspection: true },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -247,9 +285,14 @@ export default function WorkerScreen() {
|
||||
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
|
||||
|
||||
// 작업 정보
|
||||
const [departmentId, setDepartmentId] = useState('');
|
||||
const [productionManagerId, setProductionManagerId] = useState('');
|
||||
const [productionDate, setProductionDate] = useState('');
|
||||
|
||||
// 좌측 사이드바
|
||||
const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState<string>('order-1');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
// 공정별 step 완료 상태: { [itemId-stepName]: boolean }
|
||||
const [stepCompletionMap, setStepCompletionMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -276,6 +319,17 @@ export default function WorkerScreen() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// PC에서 사이드바 sticky 동작을 위해 main의 overflow 임시 해제
|
||||
useEffect(() => {
|
||||
const mainEl = document.querySelector('main');
|
||||
if (!mainEl) return;
|
||||
const original = mainEl.style.overflow;
|
||||
mainEl.style.overflow = 'visible';
|
||||
return () => {
|
||||
mainEl.style.overflow = original;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 모달/다이얼로그 상태
|
||||
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null);
|
||||
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
|
||||
@@ -388,13 +442,15 @@ export default function WorkerScreen() {
|
||||
// ===== WorkOrder → WorkItemData 변환 + 목업 =====
|
||||
const workItems: WorkItemData[] = useMemo(() => {
|
||||
const apiItems: WorkItemData[] = filteredWorkOrders.map((order, index) => {
|
||||
const stepsTemplate = PROCESS_STEPS[activeTab];
|
||||
const stepsKey = (activeTab === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeTab;
|
||||
const stepsTemplate = PROCESS_STEPS[stepsKey];
|
||||
const steps: WorkStepData[] = stepsTemplate.map((st, si) => {
|
||||
const stepKey = `${order.id}-${st.name}`;
|
||||
return {
|
||||
id: `${order.id}-step-${si}`,
|
||||
name: st.name,
|
||||
isMaterialInput: st.isMaterialInput,
|
||||
isInspection: st.isInspection,
|
||||
isCompleted: stepCompletionMap[stepKey] || false,
|
||||
};
|
||||
});
|
||||
@@ -708,19 +764,17 @@ export default function WorkerScreen() {
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="pb-20">
|
||||
{/* 완료 토스트 */}
|
||||
{toastInfo && <CompletionToast info={toastInfo} />}
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{hasJointBarItems ? '슬랫 조인트바 공정' : hasWipItems ? '절곡 재공품 공정' : '작업자 화면'}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold">작업자 화면</h1>
|
||||
<p className="text-sm text-muted-foreground">작업을 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -738,183 +792,183 @@ export default function WorkerScreen() {
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* 탭 내용은 공통 (탭별 필터링만 다름) */}
|
||||
{(['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => (
|
||||
<TabsContent key={tab} value={tab}>
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* 상태 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="할일"
|
||||
value={stats.assigned}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업중"
|
||||
value={stats.inProgress}
|
||||
icon={<PlayCircle className="h-4 w-4" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="완료"
|
||||
value={stats.completed}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="긴급"
|
||||
value={stats.urgent}
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
variant="red"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 슬랫 탭: 슬랫/조인트바 전환 토글 */}
|
||||
{tab === 'slat' && (
|
||||
<TabsContent key={tab} value={tab} className="mt-4">
|
||||
{/* 모바일: 사이드바 토글 */}
|
||||
<div className="md:hidden mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
슬랫
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('jointbar')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'jointbar'
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
조인트바
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">작업 목록</span>
|
||||
</div>
|
||||
{isSidebarOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
{isSidebarOpen && (
|
||||
<div className="mt-2 p-3 border rounded-lg bg-white max-h-[300px] overflow-y-auto">
|
||||
<SidebarContent
|
||||
tab={tab}
|
||||
slatSubMode={slatSubMode}
|
||||
setSlatSubMode={setSlatSubMode}
|
||||
bendingSubMode={bendingSubMode}
|
||||
setBendingSubMode={setBendingSubMode}
|
||||
selectedOrderId={selectedSidebarOrderId}
|
||||
onSelectOrder={(id) => {
|
||||
setSelectedSidebarOrderId(id);
|
||||
setIsSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 절곡 탭: 절곡/재공품 전환 토글 */}
|
||||
{tab === 'bending' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('normal')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
절곡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('wip')}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'wip'
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
재공품
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">* 샘플 데이터 전환용</span>
|
||||
</div>
|
||||
{/* 2-패널 레이아웃 */}
|
||||
<div className="flex gap-6">
|
||||
{/* 좌측 사이드바 - 데스크탑만 (스크롤 따라다님) */}
|
||||
<aside className="hidden md:block w-[250px] shrink-0 self-start sticky top-[6.5rem]">
|
||||
<Card className="max-h-[calc(100vh-7.5rem)] overflow-y-auto">
|
||||
<CardContent className="p-4">
|
||||
<SidebarContent
|
||||
tab={tab}
|
||||
slatSubMode={slatSubMode}
|
||||
setSlatSubMode={setSlatSubMode}
|
||||
bendingSubMode={bendingSubMode}
|
||||
setBendingSubMode={setBendingSubMode}
|
||||
selectedOrderId={selectedSidebarOrderId}
|
||||
onSelectOrder={setSelectedSidebarOrderId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
{/* 우측 메인 패널 */}
|
||||
<div className="flex-1 min-w-0 space-y-6">
|
||||
{/* 상태 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
title="할일"
|
||||
value={stats.assigned}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업중"
|
||||
value={stats.inProgress}
|
||||
icon={<PlayCircle className="h-4 w-4" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="완료"
|
||||
value={stats.completed}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="긴급"
|
||||
value={stats.urgent}
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
variant="red"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수주 정보 섹션 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">수주 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||
<InfoField label="수주일" value={orderInfo?.orderDate} />
|
||||
<InfoField label="로트번호" value={orderInfo?.lotNo} />
|
||||
<InfoField label="현장명" value={orderInfo?.siteName} />
|
||||
<InfoField label="수주처" value={orderInfo?.client} />
|
||||
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
|
||||
<InfoField label="담당자 연락처" value={orderInfo?.managerPhone} />
|
||||
<InfoField label="출고예정일" value={orderInfo?.shippingDate} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업 정보 섹션 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">작업 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">생산 담당자</Label>
|
||||
<Select
|
||||
value={productionManagerId}
|
||||
onValueChange={setProductionManagerId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 담당자 목록 - 현재 작업 담당자들 */}
|
||||
{Array.from(
|
||||
new Set(
|
||||
filteredWorkOrders.flatMap((o) => o.assignees || []).filter(Boolean)
|
||||
)
|
||||
).map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 수주 정보 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">수주 정보</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3">
|
||||
<InfoField label="수주일" value={orderInfo?.orderDate} />
|
||||
<InfoField label="로트번호" value={orderInfo?.lotNo} />
|
||||
<InfoField label="현장명" value={orderInfo?.siteName} />
|
||||
<InfoField label="수주처" value={orderInfo?.client} />
|
||||
<InfoField label="수주 담당자" value={orderInfo?.salesManager} />
|
||||
<InfoField label="담당자 연락처" value={orderInfo?.managerPhone} />
|
||||
<InfoField label="출고예정일" value={orderInfo?.shippingDate} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">생산일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={productionDate}
|
||||
onChange={(e) => setProductionDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업 목록 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">작업 목록</h3>
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="cards" rows={4} />
|
||||
) : workItems.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
해당 공정에 배정된 작업이 없습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workItems.map((item) => (
|
||||
<WorkItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onStepClick={handleStepClick}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
onInspectionClick={handleInspectionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 작업 정보 - 부서 필드 추가 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">작업 정보</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">부서</Label>
|
||||
<Select
|
||||
value={departmentId}
|
||||
onValueChange={setDepartmentId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="production">생산부서</SelectItem>
|
||||
<SelectItem value="quality">품질관리부</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">생산 담당자</Label>
|
||||
<Select
|
||||
value={productionManagerId}
|
||||
onValueChange={setProductionManagerId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from(
|
||||
new Set(
|
||||
filteredWorkOrders.flatMap((o) => o.assignees || []).filter(Boolean)
|
||||
)
|
||||
).map((name) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">생산일자</Label>
|
||||
<DatePicker
|
||||
value={productionDate}
|
||||
onChange={(date) => setProductionDate(date)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업 목록 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">작업 목록</h3>
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="cards" rows={4} />
|
||||
) : workItems.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
해당 공정에 배정된 작업이 없습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workItems.map((item) => (
|
||||
<WorkItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onStepClick={handleStepClick}
|
||||
onEditMaterial={handleEditMaterial}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
onInspectionClick={handleInspectionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -922,11 +976,10 @@ export default function WorkerScreen() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 하단 고정 버튼 - DetailActions 패턴 적용 */}
|
||||
{/* 하단 고정 버튼 */}
|
||||
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'}`}>
|
||||
<div className="flex gap-3">
|
||||
{hasWipItems ? (
|
||||
// 재공품: 버튼 1개
|
||||
<Button
|
||||
onClick={handleInspection}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
@@ -934,7 +987,6 @@ export default function WorkerScreen() {
|
||||
작업일지 및 검사성적서 보기
|
||||
</Button>
|
||||
) : (
|
||||
// 일반/조인트바: 버튼 2개
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1017,6 +1069,121 @@ export default function WorkerScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 사이드바 컨텐츠 =====
|
||||
interface SidebarContentProps {
|
||||
tab: ProcessTab;
|
||||
slatSubMode: 'normal' | 'jointbar';
|
||||
setSlatSubMode: (mode: 'normal' | 'jointbar') => void;
|
||||
bendingSubMode: 'normal' | 'wip';
|
||||
setBendingSubMode: (mode: 'normal' | 'wip') => void;
|
||||
selectedOrderId: string;
|
||||
onSelectOrder: (id: string) => void;
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
tab,
|
||||
slatSubMode,
|
||||
setSlatSubMode,
|
||||
bendingSubMode,
|
||||
setBendingSubMode,
|
||||
selectedOrderId,
|
||||
onSelectOrder,
|
||||
}: SidebarContentProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">작업 목록</h3>
|
||||
|
||||
{/* 서브 탭: 슬랫 */}
|
||||
{tab === 'slat' && (
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('normal')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
슬랫
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSlatSubMode('jointbar')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
slatSubMode === 'jointbar'
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
조인트바
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서브 탭: 절곡 */}
|
||||
{tab === 'bending' && (
|
||||
<div className="flex items-center gap-1 p-1 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('normal')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'normal'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
절곡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBendingSubMode('wip')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
bendingSubMode === 'wip'
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
재공품
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우선순위별 작업지시 카드 */}
|
||||
{PRIORITY_GROUPS.map((group) => {
|
||||
const orders = MOCK_SIDEBAR_ORDERS.filter((o) => o.priority === group.key);
|
||||
if (orders.length === 0) return null;
|
||||
return (
|
||||
<div key={group.key}>
|
||||
<p className={`text-xs font-semibold mb-1.5 ${group.color}`}>{group.label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{orders.map((order) => (
|
||||
<button
|
||||
key={order.id}
|
||||
type="button"
|
||||
onClick={() => onSelectOrder(order.id)}
|
||||
className={cn(
|
||||
'w-full text-left p-2.5 rounded-lg border transition-colors text-xs',
|
||||
selectedOrderId === order.id
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<p className="font-medium text-gray-900">{order.siteName}</p>
|
||||
<div className="flex items-center justify-between mt-1 text-gray-500">
|
||||
<span>{order.date}</span>
|
||||
<span>{order.quantity}/{order.shutterCount}소</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 컴포넌트 =====
|
||||
|
||||
interface StatCardProps {
|
||||
|
||||
@@ -106,8 +106,9 @@ export interface BendingDetailPart {
|
||||
// ===== 공정 단계 (pill) =====
|
||||
export interface WorkStepData {
|
||||
id: string;
|
||||
name: string; // 단계명 (자재투입, 절단, 미싱, 포장완료)
|
||||
name: string; // 단계명 (자재투입, 절단, 미싱, 중간검사, 포장완료)
|
||||
isMaterialInput: boolean; // 자재투입 단계 여부
|
||||
isInspection?: boolean; // 중간검사 단계 여부
|
||||
isCompleted: boolean; // 완료 여부
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
@@ -566,26 +567,23 @@ export function InspectionCreate() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>검사방문요청일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduleInfo.visitRequestDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'visitRequestDate', e.target.value)}
|
||||
onChange={(date) => updateNested('scheduleInfo', 'visitRequestDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>검사시작일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduleInfo.startDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'startDate', e.target.value)}
|
||||
onChange={(date) => updateNested('scheduleInfo', 'startDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>검사종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduleInfo.endDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'endDate', e.target.value)}
|
||||
onChange={(date) => updateNested('scheduleInfo', 'endDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
@@ -932,26 +933,23 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>검사방문요청일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduleInfo.visitRequestDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'visitRequestDate', e.target.value)}
|
||||
onChange={(date) => updateNested('scheduleInfo', 'visitRequestDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>검사시작일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduleInfo.startDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'startDate', e.target.value)}
|
||||
onChange={(date) => updateNested('scheduleInfo', 'startDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>검사종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.scheduleInfo.endDate}
|
||||
onChange={(e) => updateNested('scheduleInfo', 'endDate', e.target.value)}
|
||||
onChange={(date) => updateNested('scheduleInfo', 'endDate', date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { DatePicker } from "../ui/date-picker";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { QuantityInput } from "../ui/quantity-input";
|
||||
import { CurrencyInput } from "../ui/currency-input";
|
||||
@@ -671,12 +672,9 @@ export function QuoteRegistration({
|
||||
>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="등록일" htmlFor="registrationDate" type="custom">
|
||||
<Input
|
||||
id="registrationDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.registrationDate}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -752,11 +750,9 @@ export function QuoteRegistration({
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="납기일" htmlFor="dueDate" type="custom">
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.dueDate}
|
||||
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
|
||||
onChange={(date) => handleFieldChange("dueDate", date)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="col-span-2" />
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Input } from "../ui/input";
|
||||
import { DatePicker } from "../ui/date-picker";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { PhoneInput } from "../ui/phone-input";
|
||||
import {
|
||||
@@ -754,10 +755,9 @@ export function QuoteRegistrationV2({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">접수일</label>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={formData.registrationDate}
|
||||
onChange={(e) => handleFieldChange("registrationDate", e.target.value)}
|
||||
onChange={(date) => handleFieldChange("registrationDate", date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
@@ -189,17 +190,15 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
|
||||
기간 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
onChange={(date) => setStartDate(date)}
|
||||
className={errors.startDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
onChange={(date) => setEndDate(date)}
|
||||
className={errors.endDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AccountNumberInput } from '@/components/ui/account-number-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatPhoneNumber, formatBusinessNumber, formatCardNumber, formatAccountNumber, formatNumber } from '@/lib/formatters';
|
||||
import type { FieldDefinition, DetailMode, FieldOption } from './types';
|
||||
@@ -291,11 +292,9 @@ function renderFormField(
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(date) => onChange(date)}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import { PersonalNumberInput } from '@/components/ui/personal-number-input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { QuantityInput } from '@/components/ui/quantity-input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatPhoneNumber, formatBusinessNumber, formatNumber } from '@/lib/formatters';
|
||||
import type { FieldDefinition, DetailMode, FieldOption } from './types';
|
||||
@@ -297,11 +298,9 @@ function renderFormField(
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="date"
|
||||
<DatePicker
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(date) => onChange(date)}
|
||||
disabled={disabled}
|
||||
className={cn(error && 'border-red-500')}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,10 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { format, getDay } from "date-fns";
|
||||
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "./utils";
|
||||
import { buttonVariants } from "./button";
|
||||
import { isHoliday } from "@/constants/calendarEvents";
|
||||
import { isHoliday, isTaxDeadline } from "@/constants/calendarEvents";
|
||||
|
||||
// 토요일 체크 함수 (react-day-picker modifier용)
|
||||
const saturdayMatcher = (date: Date) => {
|
||||
@@ -27,6 +28,12 @@ const holidayMatcher = (date: Date) => {
|
||||
return isHoliday(dateStr);
|
||||
};
|
||||
|
||||
// 세금신고일 체크 함수 (react-day-picker modifier용)
|
||||
const taxDeadlineMatcher = (date: Date) => {
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
return isTaxDeadline(dateStr);
|
||||
};
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
@@ -54,6 +61,7 @@ function Calendar({
|
||||
saturday: saturdayMatcher,
|
||||
sunday: sundayMatcher,
|
||||
holiday: holidayMatcher,
|
||||
taxDeadline: taxDeadlineMatcher,
|
||||
...modifiers,
|
||||
};
|
||||
|
||||
@@ -61,6 +69,7 @@ function Calendar({
|
||||
saturday: "text-blue-500 font-semibold",
|
||||
sunday: "text-red-500 font-semibold",
|
||||
holiday: "text-red-500 font-semibold",
|
||||
taxDeadline: "text-green-600 font-semibold",
|
||||
...modifiersClassNames,
|
||||
};
|
||||
|
||||
@@ -119,6 +128,7 @@ function Calendar({
|
||||
return <Icon className="size-5" {...props} />;
|
||||
},
|
||||
}}
|
||||
locale={ko}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
278
src/components/ui/date-picker.tsx
Normal file
278
src/components/ui/date-picker.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { format, parse, isValid } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "./utils";
|
||||
import { Button } from "./button";
|
||||
import { Calendar } from "./calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
||||
|
||||
interface DatePickerProps {
|
||||
/** 선택된 날짜 (YYYY-MM-DD 형식 문자열) */
|
||||
value?: string;
|
||||
/** 날짜 변경 핸들러 */
|
||||
onChange?: (date: string) => void;
|
||||
/** 플레이스홀더 텍스트 */
|
||||
placeholder?: string;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
/** 트리거 버튼 크기 */
|
||||
size?: "default" | "sm" | "lg";
|
||||
/** 날짜 표시 형식 (date-fns format) */
|
||||
displayFormat?: string;
|
||||
/** 최소 선택 가능 날짜 */
|
||||
minDate?: Date;
|
||||
/** 최대 선택 가능 날짜 */
|
||||
maxDate?: Date;
|
||||
/** 팝오버 정렬 (start, center, end) */
|
||||
align?: "start" | "center" | "end";
|
||||
/** 팝오버 위치 (top, right, bottom, left) */
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
/** 팝오버 사이드 오프셋 */
|
||||
sideOffset?: number;
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"1월", "2월", "3월", "4월", "5월", "6월",
|
||||
"7월", "8월", "9월", "10월", "11월", "12월",
|
||||
];
|
||||
|
||||
/**
|
||||
* DatePicker 컴포넌트
|
||||
*
|
||||
* 공휴일/주말 색상 표시가 포함된 커스텀 날짜 선택기
|
||||
* 기존 input[type="date"]를 대체하여 사용
|
||||
*
|
||||
* @example
|
||||
* // 기본 사용
|
||||
* <DatePicker value={date} onChange={setDate} />
|
||||
*
|
||||
* // 형식 지정
|
||||
* <DatePicker value={date} onChange={setDate} displayFormat="yyyy년 MM월 dd일" />
|
||||
*/
|
||||
function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "날짜 선택",
|
||||
disabled = false,
|
||||
className,
|
||||
size = "default",
|
||||
displayFormat = "yyyy-MM-dd",
|
||||
minDate,
|
||||
maxDate,
|
||||
align = "center",
|
||||
side = "bottom",
|
||||
sideOffset = 8,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [displayMonth, setDisplayMonth] = React.useState<Date | undefined>();
|
||||
const [showMonthPicker, setShowMonthPicker] = React.useState(false);
|
||||
const [pickerYear, setPickerYear] = React.useState(new Date().getFullYear());
|
||||
|
||||
// 문자열 → Date 변환
|
||||
const selectedDate = React.useMemo(() => {
|
||||
if (!value) return undefined;
|
||||
const parsed = parse(value, "yyyy-MM-dd", new Date());
|
||||
return isValid(parsed) ? parsed : undefined;
|
||||
}, [value]);
|
||||
|
||||
// 팝오버 열릴 때 리셋
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
const date = selectedDate ?? new Date();
|
||||
setDisplayMonth(date);
|
||||
setPickerYear(date.getFullYear());
|
||||
setShowMonthPicker(false);
|
||||
}
|
||||
}, [open, selectedDate]);
|
||||
|
||||
// 날짜 선택 핸들러
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date | undefined) => {
|
||||
if (date && onChange) {
|
||||
onChange(format(date, "yyyy-MM-dd"));
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
// 연월 피커에서 월 선택
|
||||
const handleMonthSelect = React.useCallback(
|
||||
(month: number) => {
|
||||
setDisplayMonth(new Date(pickerYear, month, 1));
|
||||
setShowMonthPicker(false);
|
||||
},
|
||||
[pickerYear]
|
||||
);
|
||||
|
||||
// 연월 텍스트 클릭 → 연월 피커 열기
|
||||
const handleCaptionClick = React.useCallback(() => {
|
||||
setPickerYear(displayMonth?.getFullYear() ?? new Date().getFullYear());
|
||||
setShowMonthPicker(true);
|
||||
}, [displayMonth]);
|
||||
|
||||
// 오늘로 이동
|
||||
const handleGoToToday = React.useCallback(() => {
|
||||
const today = new Date();
|
||||
setDisplayMonth(today);
|
||||
setShowMonthPicker(false);
|
||||
}, []);
|
||||
|
||||
// 표시 텍스트
|
||||
const displayText = React.useMemo(() => {
|
||||
if (!selectedDate) return placeholder;
|
||||
return format(selectedDate, displayFormat, { locale: ko });
|
||||
}, [selectedDate, displayFormat, placeholder]);
|
||||
|
||||
// 버튼 크기 스타일
|
||||
const sizeClasses = {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-8 px-2 text-sm",
|
||||
lg: "h-12 px-4 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!selectedDate && "text-muted-foreground",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{displayText}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align={align} side={side} sideOffset={sideOffset}>
|
||||
<div className="p-3">
|
||||
{showMonthPicker ? (
|
||||
/* 연월 선택 피커 */
|
||||
<div className="w-[252px]">
|
||||
{/* 연도 네비게이션 */}
|
||||
<div className="flex items-center justify-between h-10 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerYear((y) => y - 1)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium">{pickerYear}년</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerYear((y) => y + 1)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 월 그리드 (4행 3열) */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{MONTH_LABELS.map((label, i) => {
|
||||
const isActive =
|
||||
displayMonth?.getMonth() === i &&
|
||||
displayMonth?.getFullYear() === pickerYear;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleMonthSelect(i)}
|
||||
className={cn(
|
||||
"h-9 rounded-lg text-sm font-medium transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isActive &&
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 오늘 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToToday}
|
||||
className="w-full mt-2 h-8 text-sm font-medium text-primary hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* 달력 뷰 */
|
||||
<div className="relative">
|
||||
{/* 연월 텍스트 클릭 영역 (화살표 사이) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCaptionClick}
|
||||
className="absolute top-0 left-8 right-8 h-10 z-20 cursor-pointer hover:bg-accent/50 rounded-md transition-colors"
|
||||
/>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
disabled={(date) => {
|
||||
if (minDate && date < minDate) return true;
|
||||
if (maxDate && date > maxDate) return true;
|
||||
return false;
|
||||
}}
|
||||
month={displayMonth}
|
||||
onMonthChange={setDisplayMonth}
|
||||
classNames={{
|
||||
months: "flex flex-col relative",
|
||||
month: "relative",
|
||||
month_caption: "flex justify-center items-center h-10",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "absolute top-0 left-1 right-1 h-10 flex items-center justify-between z-10",
|
||||
button_previous: cn(
|
||||
"h-7 w-7 bg-transparent p-0 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center justify-center"
|
||||
),
|
||||
button_next: cn(
|
||||
"h-7 w-7 bg-transparent p-0 hover:bg-accent hover:text-accent-foreground rounded-md flex items-center justify-center"
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem] text-center",
|
||||
week: "flex w-full mt-2",
|
||||
day: "h-9 w-9 text-center text-sm p-0 relative",
|
||||
day_button: cn(
|
||||
"h-9 w-9 p-0 font-normal rounded-md hover:bg-accent hover:text-accent-foreground flex items-center justify-center"
|
||||
),
|
||||
selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground rounded-lg",
|
||||
today: "bg-orange-100 text-orange-600 font-bold rounded-lg",
|
||||
outside: "text-muted-foreground/40",
|
||||
disabled: "opacity-50 cursor-not-allowed",
|
||||
hidden: "invisible",
|
||||
}}
|
||||
/>
|
||||
{/* 오늘 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToToday}
|
||||
className="w-full mt-2 h-8 text-sm font-medium text-primary hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export { DatePicker };
|
||||
export type { DatePickerProps };
|
||||
@@ -25,6 +25,7 @@ export const HOLIDAYS_2026: CalendarEvent[] = [
|
||||
{ date: '2026-05-05', name: '어린이날', type: 'holiday' },
|
||||
{ date: '2026-05-24', name: '부처님오신날', type: 'holiday' },
|
||||
{ date: '2026-06-06', name: '현충일', type: 'holiday' },
|
||||
{ date: '2026-07-17', name: '제헌절', type: 'holiday' },
|
||||
{ date: '2026-08-15', name: '광복절', type: 'holiday' },
|
||||
{ date: '2026-08-17', name: '대체공휴일(광복절)', type: 'holiday' }, // 8/15가 토요일
|
||||
{ date: '2026-09-24', name: '추석 연휴', type: 'holiday' },
|
||||
|
||||
@@ -76,6 +76,9 @@ export interface Process {
|
||||
// 생산일자 사용여부 (신규 필드 - 백엔드 미준비)
|
||||
useProductionDate?: boolean;
|
||||
|
||||
// 구분 (신규 필드 - 공정명에 따라 옵션 변경)
|
||||
processCategory?: string;
|
||||
|
||||
// 단계 목록 (신규 필드 - 백엔드 미준비)
|
||||
steps?: ProcessStep[];
|
||||
|
||||
@@ -130,6 +133,21 @@ export const PROCESS_TYPE_OPTIONS: { value: ProcessType; label: string }[] = [
|
||||
{ value: '조립', label: '조립' },
|
||||
];
|
||||
|
||||
// 공정명별 구분(카테고리) 옵션 매핑
|
||||
export const PROCESS_CATEGORY_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||
'스크린': [
|
||||
{ value: '없음', label: '없음' },
|
||||
],
|
||||
'슬릿': [
|
||||
{ value: '슬릿', label: '슬릿' },
|
||||
{ value: '조인트바', label: '조인트바' },
|
||||
],
|
||||
'절곡': [
|
||||
{ value: '철판', label: '철판' },
|
||||
{ value: '제곡풍', label: '제곡풍' },
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 공정 단계 (Process Step) 타입 정의
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user