775d698d06
- Introduced multiple new pages to enhance design management functionalities, allowing users to manage design requests, track their work, and oversee project and task statuses. - Implemented UI components such as tables, dialogs, and forms to facilitate user interactions and data management. - Integrated necessary API calls for fetching and manipulating design-related data, ensuring a seamless user experience across the new pages. These additions significantly expand the design management capabilities of the application, providing users with comprehensive tools for managing their design workflows.
840 lines
37 KiB
TypeScript
840 lines
37 KiB
TypeScript
"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>
|
|
);
|
|
}
|