feat(WEB): 부실채권, 재고, 입고, 수주 UI 개선

- BadDebtCollection 액션/타입 리팩토링
- ReceivingProcessDialog 입고처리 개선
- StockStatusList 재고현황 UI 개선
- OrderSalesDetailView 수주 상세 수정
- UniversalListPage 범용 리스트 개선
- production-order 페이지 수정
This commit is contained in:
2026-01-23 21:32:24 +09:00
parent 9fb5c171eb
commit a0343eec93
12 changed files with 315 additions and 251 deletions

View File

@@ -27,9 +27,9 @@ import {
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle, User } from "lucide-react";
import { AssigneeSelectModal } from "@/components/production/WorkOrders/AssigneeSelectModal";
import { PageLayout } from "@/components/organisms/PageLayout";
import {
AlertDialog,
@@ -50,8 +50,6 @@ import {
type CreateProductionOrderData,
} from "@/components/orders/actions";
import { getProcessList } from "@/components/process-management/actions";
import { getEmployees } from "@/components/hr/EmployeeManagement/actions";
import type { Employee } from "@/components/hr/EmployeeManagement/types";
import type { Process } from "@/types/process";
import { formatAmount } from "@/utils/formatAmount";
@@ -352,28 +350,28 @@ export default function ProductionOrderCreatePage() {
const [error, setError] = useState<string | null>(null);
const [order, setOrder] = useState<Order | null>(null);
const [processes, setProcesses] = useState<Process[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// 우선순위 상태
const [selectedPriority, setSelectedPriority] = useState<PriorityLevel>("normal");
const [selectedAssignee, setSelectedAssignee] = useState<string>("");
const [selectedAssignees, setSelectedAssignees] = useState<string[]>([]);
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
const [memo, setMemo] = useState("");
// 성공 다이얼로그 상태
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
const [generatedWorkOrders, setGeneratedWorkOrders] = useState<Array<{ workOrderNo: string; processName?: string }>>([]);
// 수주 데이터, 공정 목록, 직원 목록 로드
// 수주 데이터, 공정 목록 로드
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// 수주 정보, 공정 목록, 직원 목록을 병렬로 로드
const [orderResult, processResult, employeeResult] = await Promise.all([
// 수주 정보, 공정 목록을 병렬로 로드
const [orderResult, processResult] = await Promise.all([
getOrderById(orderId),
getProcessList({ status: "사용중" }),
getEmployees({ status: "active", per_page: 100 }),
]);
if (orderResult.success && orderResult.data) {
@@ -385,10 +383,6 @@ export default function ProductionOrderCreatePage() {
if (processResult.success && processResult.data) {
setProcesses(processResult.data.items);
}
if (employeeResult.data) {
setEmployees(employeeResult.data);
}
} catch {
setError("서버 오류가 발생했습니다.");
} finally {
@@ -412,7 +406,7 @@ export default function ProductionOrderCreatePage() {
if (!order) return;
// 담당자 필수 검증
if (!selectedAssignee) {
if (selectedAssignees.length === 0) {
setError("담당자를 선택해주세요.");
return;
}
@@ -436,9 +430,14 @@ export default function ProductionOrderCreatePage() {
.map((g) => parseInt(g.process.id, 10))
.filter((id) => !isNaN(id));
// 담당자 ID 배열 변환 (string[] → number[])
const assigneeIds = selectedAssignees
.map(id => parseInt(id, 10))
.filter(id => !isNaN(id));
const productionData: CreateProductionOrderData = {
priority: selectedPriority,
assigneeId: parseInt(selectedAssignee, 10),
assigneeIds: assigneeIds.length > 0 ? assigneeIds : undefined,
memo: memo || undefined,
processIds: allProcessIds.length > 0 ? allProcessIds : undefined,
};
@@ -726,27 +725,26 @@ export default function ProductionOrderCreatePage() {
)}
</div>
{/* 담당자 선택 */}
{/* 담당자 선택 (다중 선택) */}
<div>
<Label className="text-sm font-medium flex items-center gap-2 mb-2">
<User className="h-4 w-4" />
<span className="text-red-500">*</span>
( ) <span className="text-red-500">*</span>
</Label>
<Select value={selectedAssignee} onValueChange={setSelectedAssignee}>
<SelectTrigger className={cn("w-full md:w-[300px]", !selectedAssignee && "border-red-300")}>
<SelectValue placeholder="담당자를 선택하세요" />
</SelectTrigger>
<SelectContent>
{employees
.filter((emp) => emp.userId) // 시스템 계정이 있는 직원만
.map((emp) => (
<SelectItem key={emp.id} value={String(emp.userId)}>
{emp.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!selectedAssignee && (
<div
onClick={() => setIsAssigneeModalOpen(true)}
className={cn(
"flex min-h-10 w-full md:w-[400px] cursor-pointer items-center rounded-md border bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50",
selectedAssignees.length === 0 ? "border-red-300" : "border-input"
)}
>
{assigneeNames.length > 0 ? (
<span>{assigneeNames.join(', ')}</span>
) : (
<span className="text-muted-foreground"> (/)</span>
)}
</div>
{selectedAssignees.length === 0 && (
<p className="text-xs text-red-500 mt-1"> .</p>
)}
</div>
@@ -1024,6 +1022,17 @@ export default function ProductionOrderCreatePage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 담당자 선택 모달 */}
<AssigneeSelectModal
open={isAssigneeModalOpen}
onOpenChange={setIsAssigneeModalOpen}
selectedIds={selectedAssignees}
onSelect={(ids, names) => {
setSelectedAssignees(ids);
setAssigneeNames(names);
}}
/>
</PageLayout>
);
}

View File

@@ -494,7 +494,7 @@ export default function OrderManagementSalesPage() {
<TableCell>{order.siteName}</TableCell>
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
<TableCell>{order.expectedShipDate || "-"}</TableCell>
<TableCell>{order.deliveryMethod || "-"}</TableCell>
<TableCell>{order.deliveryMethodLabel || "-"}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex gap-1">
@@ -564,7 +564,7 @@ export default function OrderManagementSalesPage() {
<InfoField label="현장명" value={order.siteName} />
<InfoField label="견적번호" value={order.quoteNumber} />
<InfoField label="출고예정일" value={order.expectedShipDate || "-"} />
<InfoField label="배송방식" value={order.deliveryMethod || "-"} />
<InfoField label="배송방식" value={order.deliveryMethodLabel || "-"} />
<InfoField
label="금액"
value={formatAmount(order.amount)}