Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/components/production/WorkOrders/WorkOrderList.tsx
This commit is contained in:
2026-02-06 16:09:13 +09:00
68 changed files with 1471 additions and 1213 deletions

View 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** | ✅ 전체 완료 |

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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 || '-'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' : ''}
/>

View File

@@ -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>

View File

@@ -348,6 +348,8 @@ export default function ItemForm({ mode, initialData, onSubmit }: ItemFormProps)
setCertificationFile={setCertificationFile}
isSubmitting={isSubmitting}
register={register}
setValue={setValue}
getValues={getValues}
/>
)}
</CardContent>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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}`}
/>

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>
}

View File

@@ -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>

View File

@@ -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
}
/>
{/* 검사 설정 모달 */}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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]}

View File

@@ -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>
{/* 자재 투입 목록 (토글) */}

View File

@@ -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 {

View File

@@ -106,8 +106,9 @@ export interface BendingDetailPart {
// ===== 공정 단계 (pill) =====
export interface WorkStepData {
id: string;
name: string; // 단계명 (자재투입, 절단, 미싱, 포장완료)
name: string; // 단계명 (자재투입, 절단, 미싱, 중간검사, 포장완료)
isMaterialInput: boolean; // 자재투입 단계 여부
isInspection?: boolean; // 중간검사 단계 여부
isCompleted: boolean; // 완료 여부
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')}
/>

View File

@@ -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')}
/>

View File

@@ -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}
/>
);

View 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 };

View File

@@ -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' },

View File

@@ -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) 타입 정의
// ============================================================================