feat: add design request management page
- Introduced a new Design Request page for managing design requests, including functionalities for creating, updating, and deleting requests. - Implemented client-side filtering and progress tracking for design requests, enhancing user experience and data management. - Integrated a combobox for selecting customers and orders, improving the efficiency of data entry. - Added necessary types and styles for better organization and visual representation of design request statuses. These changes aim to provide a comprehensive interface for managing design requests, facilitating better tracking and organization within the application.
This commit is contained in:
parent
8c946312fe
commit
722cb536ed
|
|
@ -0,0 +1,839 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
RotateCcw,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Calendar,
|
||||||
|
Upload,
|
||||||
|
PointerIcon,
|
||||||
|
Ruler,
|
||||||
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
ResizablePanelGroup,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizableHandle,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
getDesignRequestList,
|
||||||
|
createDesignRequest,
|
||||||
|
updateDesignRequest,
|
||||||
|
deleteDesignRequest,
|
||||||
|
} from "@/lib/api/design";
|
||||||
|
|
||||||
|
// ========== 타입 ==========
|
||||||
|
interface HistoryItem {
|
||||||
|
id?: string;
|
||||||
|
step: string;
|
||||||
|
history_date: string;
|
||||||
|
user_name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesignRequest {
|
||||||
|
id: string;
|
||||||
|
request_no: string;
|
||||||
|
source_type: string;
|
||||||
|
request_date: string;
|
||||||
|
due_date: string;
|
||||||
|
design_type: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
approval_step: string;
|
||||||
|
target_name: string;
|
||||||
|
customer: string;
|
||||||
|
req_dept: string;
|
||||||
|
requester: string;
|
||||||
|
designer: string;
|
||||||
|
order_no: string;
|
||||||
|
spec: string;
|
||||||
|
change_type: string;
|
||||||
|
drawing_no: string;
|
||||||
|
urgency: string;
|
||||||
|
reason: string;
|
||||||
|
content: string;
|
||||||
|
apply_timing: string;
|
||||||
|
review_memo: string;
|
||||||
|
project_id: string;
|
||||||
|
ecn_no: string;
|
||||||
|
created_date: string;
|
||||||
|
updated_date: string;
|
||||||
|
writer: string;
|
||||||
|
company_code: string;
|
||||||
|
history: HistoryItem[];
|
||||||
|
impact: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 스타일 맵 ==========
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
신규접수: "bg-muted text-foreground",
|
||||||
|
접수대기: "bg-muted text-foreground",
|
||||||
|
검토중: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
설계진행: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||||
|
설계검토: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
|
||||||
|
출도완료: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||||
|
반려: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||||
|
종료: "bg-muted text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<string, string> = {
|
||||||
|
신규설계: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||||
|
유사설계: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||||
|
개조설계: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_STYLES: Record<string, string> = {
|
||||||
|
긴급: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||||
|
높음: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
보통: "bg-muted text-foreground",
|
||||||
|
낮음: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_PROGRESS: Record<string, number> = {
|
||||||
|
신규접수: 0,
|
||||||
|
접수대기: 0,
|
||||||
|
검토중: 20,
|
||||||
|
설계진행: 50,
|
||||||
|
설계검토: 80,
|
||||||
|
출도완료: 100,
|
||||||
|
반려: 0,
|
||||||
|
종료: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProgressColor(p: number) {
|
||||||
|
if (p >= 100) return "bg-emerald-500";
|
||||||
|
if (p >= 60) return "bg-amber-500";
|
||||||
|
if (p >= 20) return "bg-blue-500";
|
||||||
|
return "bg-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressTextColor(p: number) {
|
||||||
|
if (p >= 100) return "text-emerald-500";
|
||||||
|
if (p >= 60) return "text-amber-500";
|
||||||
|
if (p >= 20) return "text-blue-500";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM = {
|
||||||
|
request_no: "",
|
||||||
|
request_date: "",
|
||||||
|
due_date: "",
|
||||||
|
design_type: "",
|
||||||
|
priority: "보통",
|
||||||
|
target_name: "",
|
||||||
|
customer: "",
|
||||||
|
req_dept: "",
|
||||||
|
requester: "",
|
||||||
|
designer: "",
|
||||||
|
order_no: "",
|
||||||
|
spec: "",
|
||||||
|
drawing_no: "",
|
||||||
|
content: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 메인 컴포넌트 ==========
|
||||||
|
export default function DesignRequestPage() {
|
||||||
|
const [requests, setRequests] = useState<DesignRequest[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [filterStatus, setFilterStatus] = useState("");
|
||||||
|
const [filterType, setFilterType] = useState("");
|
||||||
|
const [filterPriority, setFilterPriority] = useState("");
|
||||||
|
const [filterKeyword, setFilterKeyword] = useState("");
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState(INITIAL_FORM);
|
||||||
|
|
||||||
|
const today = useMemo(() => new Date(), []);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const fetchRequests = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { source_type: "dr" };
|
||||||
|
if (filterStatus && filterStatus !== "__all__") params.status = filterStatus;
|
||||||
|
if (filterType && filterType !== "__all__") {
|
||||||
|
// design_type은 서버에서 직접 필터링하지 않으므로 클라이언트에서 처리
|
||||||
|
}
|
||||||
|
if (filterPriority && filterPriority !== "__all__") params.priority = filterPriority;
|
||||||
|
if (filterKeyword) params.search = filterKeyword;
|
||||||
|
|
||||||
|
const res = await getDesignRequestList(params);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setRequests(res.data);
|
||||||
|
} else {
|
||||||
|
setRequests([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRequests([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterStatus, filterPriority, filterKeyword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRequests();
|
||||||
|
}, [fetchRequests]);
|
||||||
|
|
||||||
|
// 클라이언트 사이드 필터링 (design_type은 서버에서 지원하지 않으므로)
|
||||||
|
const filteredRequests = useMemo(() => {
|
||||||
|
let list = requests;
|
||||||
|
if (filterType && filterType !== "__all__") {
|
||||||
|
list = list.filter((item) => item.design_type === filterType);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [requests, filterType]);
|
||||||
|
|
||||||
|
const selectedItem = useMemo(() => {
|
||||||
|
if (!selectedId) return null;
|
||||||
|
return requests.find((r) => r.id === selectedId) || null;
|
||||||
|
}, [selectedId, requests]);
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
return {
|
||||||
|
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
|
||||||
|
설계진행: requests.filter((r) => r.status === "설계진행").length,
|
||||||
|
출도완료: requests.filter((r) => r.status === "출도완료").length,
|
||||||
|
};
|
||||||
|
}, [requests]);
|
||||||
|
|
||||||
|
const handleResetFilter = useCallback(() => {
|
||||||
|
setFilterStatus("");
|
||||||
|
setFilterType("");
|
||||||
|
setFilterPriority("");
|
||||||
|
setFilterKeyword("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 채번: 기존 데이터 기반으로 다음 번호 생성
|
||||||
|
const generateNextNo = useCallback(() => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
|
||||||
|
const maxNum = existing.reduce((max, r) => {
|
||||||
|
const parts = r.request_no?.split("-");
|
||||||
|
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
|
||||||
|
return num > max ? num : max;
|
||||||
|
}, 0);
|
||||||
|
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
|
||||||
|
}, [requests]);
|
||||||
|
|
||||||
|
const handleOpenRegister = useCallback(() => {
|
||||||
|
setIsEditMode(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setForm({
|
||||||
|
...INITIAL_FORM,
|
||||||
|
request_no: generateNextNo(),
|
||||||
|
request_date: new Date().toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}, [generateNextNo]);
|
||||||
|
|
||||||
|
const handleOpenEdit = useCallback(() => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
setIsEditMode(true);
|
||||||
|
setEditingId(selectedItem.id);
|
||||||
|
setForm({
|
||||||
|
request_no: selectedItem.request_no || "",
|
||||||
|
request_date: selectedItem.request_date || "",
|
||||||
|
due_date: selectedItem.due_date || "",
|
||||||
|
design_type: selectedItem.design_type || "",
|
||||||
|
priority: selectedItem.priority || "보통",
|
||||||
|
target_name: selectedItem.target_name || "",
|
||||||
|
customer: selectedItem.customer || "",
|
||||||
|
req_dept: selectedItem.req_dept || "",
|
||||||
|
requester: selectedItem.requester || "",
|
||||||
|
designer: selectedItem.designer || "",
|
||||||
|
order_no: selectedItem.order_no || "",
|
||||||
|
spec: selectedItem.spec || "",
|
||||||
|
drawing_no: selectedItem.drawing_no || "",
|
||||||
|
content: selectedItem.content || "",
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}, [selectedItem]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!form.target_name.trim()) { alert("설비/제품명을 입력하세요."); return; }
|
||||||
|
if (!form.design_type) { alert("의뢰 유형을 선택하세요."); return; }
|
||||||
|
if (!form.due_date) { alert("납기를 입력하세요."); return; }
|
||||||
|
if (!form.spec.trim()) { alert("요구사양을 입력하세요."); return; }
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
request_no: form.request_no,
|
||||||
|
source_type: "dr",
|
||||||
|
request_date: form.request_date,
|
||||||
|
due_date: form.due_date,
|
||||||
|
design_type: form.design_type,
|
||||||
|
priority: form.priority,
|
||||||
|
target_name: form.target_name,
|
||||||
|
customer: form.customer,
|
||||||
|
req_dept: form.req_dept,
|
||||||
|
requester: form.requester,
|
||||||
|
designer: form.designer,
|
||||||
|
order_no: form.order_no,
|
||||||
|
spec: form.spec,
|
||||||
|
drawing_no: form.drawing_no,
|
||||||
|
content: form.content,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (isEditMode && editingId) {
|
||||||
|
res = await updateDesignRequest(editingId, payload);
|
||||||
|
} else {
|
||||||
|
res = await createDesignRequest({
|
||||||
|
...payload,
|
||||||
|
status: "신규접수",
|
||||||
|
history: [{
|
||||||
|
step: "신규접수",
|
||||||
|
history_date: form.request_date || new Date().toISOString().split("T")[0],
|
||||||
|
user_name: form.requester || "시스템",
|
||||||
|
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
setModalOpen(false);
|
||||||
|
await fetchRequests();
|
||||||
|
if (isEditMode && editingId) {
|
||||||
|
setSelectedId(editingId);
|
||||||
|
} else if (res.data?.id) {
|
||||||
|
setSelectedId(res.data.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`저장 실패: ${res.message || "알 수 없는 오류"}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`저장 중 오류가 발생했습니다: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [form, isEditMode, editingId, fetchRequests]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!selectedId || !selectedItem) return;
|
||||||
|
const displayNo = selectedItem.request_no || selectedId;
|
||||||
|
if (!confirm(`${displayNo} 설계의뢰를 삭제하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteDesignRequest(selectedId);
|
||||||
|
if (res.success) {
|
||||||
|
setSelectedId(null);
|
||||||
|
await fetchRequests();
|
||||||
|
} else {
|
||||||
|
alert(`삭제 실패: ${res.message || "알 수 없는 오류"}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`삭제 중 오류가 발생했습니다: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, [selectedId, selectedItem, fetchRequests]);
|
||||||
|
|
||||||
|
const getDueDateInfo = useCallback(
|
||||||
|
(dueDate: string) => {
|
||||||
|
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
|
||||||
|
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
|
||||||
|
if (diff === 0) return { text: "오늘", color: "text-amber-500" };
|
||||||
|
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-amber-500" };
|
||||||
|
return { text: `${diff}일 남음`, color: "text-emerald-500" };
|
||||||
|
},
|
||||||
|
[today]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProgress = useCallback((status: string) => {
|
||||||
|
return STATUS_PROGRESS[status] ?? 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-2 p-3">
|
||||||
|
{/* 검색 섹션 */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-3 py-2">
|
||||||
|
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||||
|
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="상태 전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">상태 전체</SelectItem>
|
||||||
|
{["신규접수", "접수대기", "검토중", "설계진행", "설계검토", "출도완료", "반려", "종료"].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterType} onValueChange={setFilterType}>
|
||||||
|
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="유형 전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">유형 전체</SelectItem>
|
||||||
|
{["신규설계", "유사설계", "개조설계"].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterPriority} onValueChange={setFilterPriority}>
|
||||||
|
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="우선순위 전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">우선순위 전체</SelectItem>
|
||||||
|
{["긴급", "높음", "보통", "낮음"].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={filterKeyword}
|
||||||
|
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||||
|
placeholder="의뢰번호 / 설비명 / 고객명 검색"
|
||||||
|
className="h-7 w-[240px] pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
|
||||||
|
<RotateCcw className="mr-1 h-3 w-3" />초기화
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => fetchRequests()}>
|
||||||
|
<Search className="mr-1 h-3 w-3" />조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
||||||
|
{/* 왼쪽: 목록 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||||
|
<span className="text-sm font-bold">
|
||||||
|
<Ruler className="mr-1 inline h-4 w-4" />
|
||||||
|
설계의뢰 목록 (<span className="text-primary">{filteredRequests.length}</span>건)
|
||||||
|
</span>
|
||||||
|
<Button size="sm" className="h-7 text-xs" onClick={handleOpenRegister}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />설계의뢰 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">의뢰번호</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center text-[11px]">유형</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center text-[11px]">상태</TableHead>
|
||||||
|
<TableHead className="w-[60px] text-center text-[11px]">우선순위</TableHead>
|
||||||
|
<TableHead className="text-[11px]">설비/제품명</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-[11px]">고객명</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-[11px]">설계담당</TableHead>
|
||||||
|
<TableHead className="w-[85px] text-[11px]">납기</TableHead>
|
||||||
|
<TableHead className="w-[65px] text-center text-[11px]">진행률</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRequests.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="py-12 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-muted-foreground">
|
||||||
|
<Ruler className="h-8 w-8" />
|
||||||
|
<span className="text-sm">등록된 설계의뢰가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filteredRequests.map((item) => {
|
||||||
|
const progress = getProgress(item.status);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
|
||||||
|
onClick={() => setSelectedId(item.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-[11px] font-semibold text-primary">{item.request_no || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{item.design_type ? (
|
||||||
|
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
|
||||||
|
) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">{item.target_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[11px]">{item.customer || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[11px]">{item.designer || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[11px]">{item.due_date || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 오른쪽: 상세 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||||
|
<span className="text-sm font-bold">
|
||||||
|
<ClipboardList className="mr-1 inline h-4 w-4" />
|
||||||
|
상세 정보
|
||||||
|
</span>
|
||||||
|
{selectedItem && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-[10px]" onClick={handleOpenEdit}>
|
||||||
|
<Pencil className="mr-0.5 h-3 w-3" />수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-[10px] text-destructive hover:text-destructive" onClick={handleDelete}>
|
||||||
|
<Trash2 className="mr-0.5 h-3 w-3" />삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3">
|
||||||
|
{/* 상태 카드 */}
|
||||||
|
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => setFilterStatus("접수대기")}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-muted-foreground">접수대기</div>
|
||||||
|
<div className="text-xl font-bold text-blue-500">{statusCounts.접수대기}</div>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => setFilterStatus("설계진행")}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-muted-foreground">설계진행</div>
|
||||||
|
<div className="text-xl font-bold text-amber-500">{statusCounts.설계진행}</div>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => setFilterStatus("출도완료")}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-muted-foreground">출도완료</div>
|
||||||
|
<div className="text-xl font-bold text-emerald-500">{statusCounts.출도완료}</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 내용 */}
|
||||||
|
{!selectedItem ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||||
|
<PointerIcon className="h-8 w-8" />
|
||||||
|
<span className="text-sm">좌측 목록에서 설계의뢰를 선택하세요</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-bold">
|
||||||
|
<FileText className="mr-1 inline h-3.5 w-3.5" />기본 정보
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
|
||||||
|
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
|
||||||
|
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
|
||||||
|
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
|
||||||
|
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
|
||||||
|
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
|
||||||
|
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
|
||||||
|
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
|
||||||
|
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
|
||||||
|
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
|
||||||
|
<InfoRow
|
||||||
|
label="납기"
|
||||||
|
value={
|
||||||
|
selectedItem.due_date ? (
|
||||||
|
<span>
|
||||||
|
{selectedItem.due_date}{" "}
|
||||||
|
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
|
||||||
|
({getDueDateInfo(selectedItem.due_date).text})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : "-"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
|
||||||
|
<InfoRow
|
||||||
|
label="진행률"
|
||||||
|
value={
|
||||||
|
(() => {
|
||||||
|
const progress = getProgress(selectedItem.status);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요구사양 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-bold">
|
||||||
|
<FileText className="mr-1 inline h-3.5 w-3.5" />요구사양
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/10 p-3">
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
|
||||||
|
{selectedItem.drawing_no && (
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">참조 도면: </span>
|
||||||
|
<span className="text-primary">{selectedItem.drawing_no}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedItem.content && (
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">비고: </span>{selectedItem.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행 이력 */}
|
||||||
|
{selectedItem.history && selectedItem.history.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-bold">
|
||||||
|
<Calendar className="mr-1 inline h-3.5 w-3.5" />진행 이력
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{selectedItem.history.map((h, idx) => {
|
||||||
|
const isLast = idx === selectedItem.history.length - 1;
|
||||||
|
const isDone = h.step === "출도완료" || h.step === "종료";
|
||||||
|
return (
|
||||||
|
<div key={h.id || idx} className="flex gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
|
||||||
|
isLast && !isDone
|
||||||
|
? "border-blue-500 bg-blue-500"
|
||||||
|
: isDone || !isLast
|
||||||
|
? "border-emerald-500 bg-emerald-500"
|
||||||
|
: "border-muted-foreground bg-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isLast && <div className="w-px flex-1 bg-border" />}
|
||||||
|
</div>
|
||||||
|
<div className="pb-3">
|
||||||
|
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
|
||||||
|
<div className="mt-0.5 text-xs">{h.description}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[1100px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg">
|
||||||
|
{isEditMode ? <><Pencil className="mr-1.5 inline h-5 w-5" />설계의뢰 수정</> : <><Plus className="mr-1.5 inline h-5 w-5" />설계의뢰 등록</>}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm">
|
||||||
|
{isEditMode ? "설계의뢰 정보를 수정합니다." : "새 설계의뢰를 등록합니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* 좌측: 기본 정보 */}
|
||||||
|
<div className="w-[420px] shrink-0 space-y-4">
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
<FileText className="mr-1 inline h-4 w-4" />의뢰 기본 정보
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰번호</Label>
|
||||||
|
<Input value={form.request_no} readOnly className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰일자</Label>
|
||||||
|
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">납기 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰 유형 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">우선순위 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">설비/제품명 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰부서</Label>
|
||||||
|
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰자</Label>
|
||||||
|
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">고객명</Label>
|
||||||
|
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">수주번호</Label>
|
||||||
|
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">설계담당자</Label>
|
||||||
|
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 상세 내용 */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
<FileText className="mr-1 inline h-4 w-4" />요구사양 및 설명
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-sm">요구사양 <span className="text-destructive">*</span></Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.spec}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
|
||||||
|
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술하세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
|
||||||
|
className="min-h-[180px] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">참조 도면번호</Label>
|
||||||
|
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">비고</Label>
|
||||||
|
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px] text-sm" rows={3} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
<Upload className="mr-1 inline h-4 w-4" />첨부파일
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
|
||||||
|
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
|
<div className="mt-1.5 text-sm text-muted-foreground">클릭하여 파일 첨부 (사양서, 도면, 사진 등)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)} className="h-10 px-6 text-sm" disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={handleSave} className="h-10 px-6 text-sm" disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||||
|
{saving ? "저장 중..." : "저장"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 정보 행 서브컴포넌트 ==========
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-xs font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,19 @@ import {
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Download,
|
||||||
|
|
@ -46,8 +59,12 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
|
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
|
||||||
|
|
@ -58,6 +75,7 @@ interface Claim {
|
||||||
claimDate: string;
|
claimDate: string;
|
||||||
claimType: ClaimType;
|
claimType: ClaimType;
|
||||||
claimStatus: ClaimStatus;
|
claimStatus: ClaimStatus;
|
||||||
|
customerCode: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
managerName: string;
|
managerName: string;
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
|
|
@ -65,6 +83,17 @@ interface Claim {
|
||||||
processContent: string;
|
processContent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CustomerOption {
|
||||||
|
customerCode: string;
|
||||||
|
customerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesOrderOption {
|
||||||
|
orderNo: string;
|
||||||
|
partnerName: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Sample Data ---
|
// --- Sample Data ---
|
||||||
const initialData: Claim[] = [
|
const initialData: Claim[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -72,6 +101,7 @@ const initialData: Claim[] = [
|
||||||
claimDate: "2025-11-09",
|
claimDate: "2025-11-09",
|
||||||
claimType: "불량",
|
claimType: "불량",
|
||||||
claimStatus: "접수",
|
claimStatus: "접수",
|
||||||
|
customerCode: "CUST-0001",
|
||||||
customerName: "주식회사 코아스포트",
|
customerName: "주식회사 코아스포트",
|
||||||
managerName: "김철수",
|
managerName: "김철수",
|
||||||
orderNo: "SO-2025-0102",
|
orderNo: "SO-2025-0102",
|
||||||
|
|
@ -83,6 +113,7 @@ const initialData: Claim[] = [
|
||||||
claimDate: "2025-01-05",
|
claimDate: "2025-01-05",
|
||||||
claimType: "불량",
|
claimType: "불량",
|
||||||
claimStatus: "접수",
|
claimStatus: "접수",
|
||||||
|
customerCode: "CUST-0002",
|
||||||
customerName: "(주)현상산업",
|
customerName: "(주)현상산업",
|
||||||
managerName: "김철수",
|
managerName: "김철수",
|
||||||
orderNo: "SO-2025-0102",
|
orderNo: "SO-2025-0102",
|
||||||
|
|
@ -94,6 +125,7 @@ const initialData: Claim[] = [
|
||||||
claimDate: "2025-01-04",
|
claimDate: "2025-01-04",
|
||||||
claimType: "교환",
|
claimType: "교환",
|
||||||
claimStatus: "처리중",
|
claimStatus: "처리중",
|
||||||
|
customerCode: "CUST-0003",
|
||||||
customerName: "대한전섬",
|
customerName: "대한전섬",
|
||||||
managerName: "이영희",
|
managerName: "이영희",
|
||||||
orderNo: "SO-2025-0095",
|
orderNo: "SO-2025-0095",
|
||||||
|
|
@ -105,6 +137,7 @@ const initialData: Claim[] = [
|
||||||
claimDate: "2025-01-03",
|
claimDate: "2025-01-03",
|
||||||
claimType: "반품",
|
claimType: "반품",
|
||||||
claimStatus: "완료",
|
claimStatus: "완료",
|
||||||
|
customerCode: "CUST-0004",
|
||||||
customerName: "삼성전자",
|
customerName: "삼성전자",
|
||||||
managerName: "박민수",
|
managerName: "박민수",
|
||||||
orderNo: "SO-2024-1285",
|
orderNo: "SO-2024-1285",
|
||||||
|
|
@ -165,6 +198,16 @@ export default function ClaimManagementPage() {
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [formData, setFormData] = useState<Partial<Claim>>({});
|
const [formData, setFormData] = useState<Partial<Claim>>({});
|
||||||
|
|
||||||
|
// Combobox 상태
|
||||||
|
const [customerOpen, setCustomerOpen] = useState(false);
|
||||||
|
const [orderOpen, setOrderOpen] = useState(false);
|
||||||
|
|
||||||
|
// DB 데이터
|
||||||
|
const [customers, setCustomers] = useState<CustomerOption[]>([]);
|
||||||
|
const [salesOrders, setSalesOrders] = useState<SalesOrderOption[]>([]);
|
||||||
|
const [customersLoading, setCustomersLoading] = useState(false);
|
||||||
|
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const thirtyDaysAgo = new Date(today);
|
const thirtyDaysAgo = new Date(today);
|
||||||
|
|
@ -173,6 +216,62 @@ export default function ClaimManagementPage() {
|
||||||
setSearchDateTo(today.toISOString().split("T")[0]);
|
setSearchDateTo(today.toISOString().split("T")[0]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 거래처 목록 조회
|
||||||
|
const fetchCustomers = useCallback(async () => {
|
||||||
|
if (customers.length > 0) return;
|
||||||
|
setCustomersLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post("/table-management/tables/customer_mng/data", {
|
||||||
|
page: 1,
|
||||||
|
size: 9999,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
if (res.data?.success && res.data?.data?.rows) {
|
||||||
|
const list: CustomerOption[] = res.data.data.rows.map((row: any) => ({
|
||||||
|
customerCode: row.customer_code || "",
|
||||||
|
customerName: row.customer_name || "",
|
||||||
|
}));
|
||||||
|
setCustomers(list);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("거래처 목록 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setCustomersLoading(false);
|
||||||
|
}
|
||||||
|
}, [customers.length]);
|
||||||
|
|
||||||
|
// 수주 목록 조회
|
||||||
|
const fetchSalesOrders = useCallback(async () => {
|
||||||
|
if (salesOrders.length > 0) return;
|
||||||
|
setOrdersLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post("/table-management/tables/sales_order_mng/data", {
|
||||||
|
page: 1,
|
||||||
|
size: 9999,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
if (res.data?.success && res.data?.data?.rows) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const list: SalesOrderOption[] = [];
|
||||||
|
for (const row of res.data.data.rows) {
|
||||||
|
const orderNo = row.order_no || "";
|
||||||
|
if (!orderNo || seen.has(orderNo)) continue;
|
||||||
|
seen.add(orderNo);
|
||||||
|
list.push({
|
||||||
|
orderNo,
|
||||||
|
partnerName: row.partner_id || "",
|
||||||
|
status: row.status || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSalesOrders(list);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("수주 목록 조회 실패:", e);
|
||||||
|
} finally {
|
||||||
|
setOrdersLoading(false);
|
||||||
|
}
|
||||||
|
}, [salesOrders.length]);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return data
|
return data
|
||||||
.filter((claim) => {
|
.filter((claim) => {
|
||||||
|
|
@ -241,6 +340,7 @@ export default function ClaimManagementPage() {
|
||||||
claimDate: new Date().toISOString().split("T")[0],
|
claimDate: new Date().toISOString().split("T")[0],
|
||||||
claimType: undefined,
|
claimType: undefined,
|
||||||
claimStatus: "접수",
|
claimStatus: "접수",
|
||||||
|
customerCode: "",
|
||||||
customerName: "",
|
customerName: "",
|
||||||
managerName: "",
|
managerName: "",
|
||||||
orderNo: "",
|
orderNo: "",
|
||||||
|
|
@ -273,6 +373,7 @@ export default function ClaimManagementPage() {
|
||||||
claimDate: formData.claimDate || new Date().toISOString().split("T")[0],
|
claimDate: formData.claimDate || new Date().toISOString().split("T")[0],
|
||||||
claimType: formData.claimType as ClaimType,
|
claimType: formData.claimType as ClaimType,
|
||||||
claimStatus: (formData.claimStatus as ClaimStatus) || "접수",
|
claimStatus: (formData.claimStatus as ClaimStatus) || "접수",
|
||||||
|
customerCode: formData.customerCode || "",
|
||||||
customerName: formData.customerName || "",
|
customerName: formData.customerName || "",
|
||||||
managerName: formData.managerName || "",
|
managerName: formData.managerName || "",
|
||||||
orderNo: formData.orderNo || "",
|
orderNo: formData.orderNo || "",
|
||||||
|
|
@ -787,17 +888,81 @@ export default function ClaimManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="customerName" className="text-xs sm:text-sm">
|
<Label className="text-xs sm:text-sm">
|
||||||
거래처명 <span className="text-destructive">*</span>
|
거래처명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Popover open={customerOpen} onOpenChange={setCustomerOpen}>
|
||||||
id="customerName"
|
<PopoverTrigger asChild>
|
||||||
value={formData.customerName || ""}
|
<Button
|
||||||
onChange={(e) =>
|
variant="outline"
|
||||||
handleFormChange("customerName", e.target.value)
|
role="combobox"
|
||||||
}
|
aria-expanded={customerOpen}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm font-normal"
|
||||||
|
onClick={() => fetchCustomers()}
|
||||||
|
>
|
||||||
|
{formData.customerName || "거래처 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="거래처 검색..."
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
/>
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{customersLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm py-4 text-center">
|
||||||
|
거래처를 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{customers.map((cust) => (
|
||||||
|
<CommandItem
|
||||||
|
key={cust.customerCode}
|
||||||
|
value={`${cust.customerCode} ${cust.customerName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customerCode: cust.customerCode,
|
||||||
|
customerName: cust.customerName,
|
||||||
|
}));
|
||||||
|
setCustomerOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.customerCode === cust.customerCode
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{cust.customerName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{cust.customerCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -815,17 +980,81 @@ export default function ClaimManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="orderNo" className="text-xs sm:text-sm">
|
<Label className="text-xs sm:text-sm">
|
||||||
수주번호
|
수주번호
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Popover open={orderOpen} onOpenChange={setOrderOpen}>
|
||||||
id="orderNo"
|
<PopoverTrigger asChild>
|
||||||
value={formData.orderNo || ""}
|
<Button
|
||||||
onChange={(e) =>
|
variant="outline"
|
||||||
handleFormChange("orderNo", e.target.value)
|
role="combobox"
|
||||||
}
|
aria-expanded={orderOpen}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm font-normal"
|
||||||
|
onClick={() => fetchSalesOrders()}
|
||||||
|
>
|
||||||
|
{formData.orderNo || "수주번호 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="수주번호 검색..."
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
/>
|
/>
|
||||||
|
<CommandList>
|
||||||
|
{ordersLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CommandEmpty className="text-xs sm:text-sm py-4 text-center">
|
||||||
|
수주번호를 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{salesOrders.map((order) => (
|
||||||
|
<CommandItem
|
||||||
|
key={order.orderNo}
|
||||||
|
value={`${order.orderNo} ${order.partnerName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
}));
|
||||||
|
setOrderOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.orderNo === order.orderNo
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{order.orderNo}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{order.status}
|
||||||
|
{order.partnerName ? ` | ${order.partnerName}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ export default function ShippingOrderPage() {
|
||||||
setFormVehicle(order.vehicle_no || "");
|
setFormVehicle(order.vehicle_no || "");
|
||||||
setFormDriver(order.driver_name || "");
|
setFormDriver(order.driver_name || "");
|
||||||
setFormDriverPhone(order.driver_contact || "");
|
setFormDriverPhone(order.driver_contact || "");
|
||||||
setFormArrival(order.arrival_time ? order.arrival_time.slice(0, 16) : "");
|
setFormArrival(order.arrival_time ? new Date(order.arrival_time).toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T").slice(0, 16) : "");
|
||||||
setFormAddress(order.delivery_address || "");
|
setFormAddress(order.delivery_address || "");
|
||||||
setFormMemo(order.memo || "");
|
setFormMemo(order.memo || "");
|
||||||
|
|
||||||
|
|
@ -371,7 +371,7 @@ export default function ShippingOrderPage() {
|
||||||
vehicleNo: formVehicle,
|
vehicleNo: formVehicle,
|
||||||
driverName: formDriver,
|
driverName: formDriver,
|
||||||
driverContact: formDriverPhone,
|
driverContact: formDriverPhone,
|
||||||
arrivalTime: formArrival || null,
|
arrivalTime: formArrival ? `${formArrival}+09:00` : null,
|
||||||
deliveryAddress: formAddress,
|
deliveryAddress: formAddress,
|
||||||
items: selectedItems.map(item => ({
|
items: selectedItems.map(item => ({
|
||||||
itemCode: item.itemCode,
|
itemCode: item.itemCode,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||||
// 설계 관리 (커스텀 페이지)
|
// 설계 관리 (커스텀 페이지)
|
||||||
"/design/task-management": dynamic(() => import("@/app/(main)/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
"/design/task-management": dynamic(() => import("@/app/(main)/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
"/design/my-work": dynamic(() => import("@/app/(main)/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
"/design/my-work": dynamic(() => import("@/app/(main)/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
"/design/design-request": dynamic(() => import("@/app/(main)/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
|
||||||
// 영업 관리 (커스텀 페이지)
|
// 영업 관리 (커스텀 페이지)
|
||||||
"/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
"/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue