782 lines
33 KiB
TypeScript
782 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
|
import {
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Calendar,
|
|
Upload,
|
|
Ruler,
|
|
FileText,
|
|
Loader2,
|
|
Inbox,
|
|
Save,
|
|
Settings2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
import {
|
|
getDesignRequestList,
|
|
createDesignRequest,
|
|
updateDesignRequest,
|
|
deleteDesignRequest,
|
|
} from "@/lib/api/design";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
|
|
// ========== 타입 ==========
|
|
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-warning/10 text-warning",
|
|
설계진행: "bg-info/10 text-info",
|
|
설계검토: "bg-primary/10 text-primary",
|
|
출도완료: "bg-success/10 text-success",
|
|
반려: "bg-destructive/10 text-destructive",
|
|
종료: "bg-muted text-muted-foreground",
|
|
};
|
|
|
|
const TYPE_STYLES: Record<string, string> = {
|
|
신규설계: "bg-info/10 text-info",
|
|
유사설계: "bg-success/10 text-success",
|
|
개조설계: "bg-warning/10 text-warning",
|
|
};
|
|
|
|
const PRIORITY_STYLES: Record<string, string> = {
|
|
긴급: "bg-destructive/10 text-destructive",
|
|
높음: "bg-warning/10 text-warning",
|
|
보통: "bg-muted text-foreground",
|
|
낮음: "bg-success/10 text-success",
|
|
};
|
|
|
|
const STATUS_PROGRESS: Record<string, number> = {
|
|
신규접수: 0,
|
|
접수대기: 0,
|
|
검토중: 20,
|
|
설계진행: 50,
|
|
설계검토: 80,
|
|
출도완료: 100,
|
|
반려: 0,
|
|
종료: 100,
|
|
};
|
|
|
|
function getProgressColor(p: number) {
|
|
if (p >= 100) return "bg-success";
|
|
if (p >= 60) return "bg-warning";
|
|
if (p >= 20) return "bg-info";
|
|
return "bg-muted";
|
|
}
|
|
|
|
function getProgressTextColor(p: number) {
|
|
if (p >= 100) return "text-success";
|
|
if (p >= 60) return "text-warning";
|
|
if (p >= 20) return "text-info";
|
|
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: "",
|
|
};
|
|
|
|
// ========== Grid Columns ==========
|
|
const DR_GRID_COLUMNS = [
|
|
{ key: "request_no", label: "의뢰번호" },
|
|
{ key: "design_type", label: "유형" },
|
|
{ key: "status", label: "상태" },
|
|
{ key: "priority", label: "우선순위" },
|
|
{ key: "target_name", label: "설비/제품명" },
|
|
{ key: "customer", label: "고객명" },
|
|
{ key: "designer", label: "설계담당" },
|
|
{ key: "due_date", label: "납기" },
|
|
{ key: "progress", label: "진행률" },
|
|
];
|
|
|
|
// ========== 메인 컴포넌트 ==========
|
|
export default function DesignRequestPage() {
|
|
const ts = useTableSettings("c16-design-request", "dsn_design_request", DR_GRID_COLUMNS);
|
|
const [requests, setRequests] = useState<DesignRequest[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
|
|
// 검색 필터 (DynamicSearchFilter)
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
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" };
|
|
const res = await getDesignRequestList(params);
|
|
if (res.success && res.data) {
|
|
setRequests(res.data);
|
|
} else {
|
|
setRequests([]);
|
|
}
|
|
} catch {
|
|
setRequests([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchRequests();
|
|
}, [fetchRequests]);
|
|
|
|
// 클라이언트 사이드 필터링 (DynamicSearchFilter)
|
|
const filteredRequests = useMemo(() => {
|
|
if (searchFilters.length === 0) return requests;
|
|
return requests.filter((item) => {
|
|
for (const f of searchFilters) {
|
|
const val = item[f.columnName as keyof DesignRequest];
|
|
const strVal = val !== undefined && val !== null ? (Array.isArray(val) ? val.join(",") : String(val)) : "";
|
|
if (f.operator === "contains") {
|
|
if (!strVal.toLowerCase().includes(f.value.toLowerCase())) return false;
|
|
} else if (f.operator === "equals") {
|
|
if (strVal !== f.value) return false;
|
|
} else if (f.operator === "in") {
|
|
const allowed = f.value.split("|");
|
|
if (!allowed.includes(strVal)) return false;
|
|
} else if (f.operator === "between") {
|
|
const [from, to] = f.value.split("|");
|
|
if (from && strVal < from) return false;
|
|
if (to && strVal > to) return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}, [requests, searchFilters]);
|
|
|
|
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 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 || "",
|
|
});
|
|
setDetailOpen(false);
|
|
setModalOpen(true);
|
|
}, [selectedItem]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!form.target_name.trim()) { toast.error("설비/제품명을 입력해 주세요."); return; }
|
|
if (!form.design_type) { toast.error("의뢰 유형을 선택해 주세요."); return; }
|
|
if (!form.due_date) { toast.error("납기를 입력해 주세요."); return; }
|
|
if (!form.spec.trim()) { toast.error("요구사양을 입력해 주세요."); 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) {
|
|
toast.success(isEditMode ? "수정되었어요." : "등록되었어요.");
|
|
setModalOpen(false);
|
|
await fetchRequests();
|
|
if (isEditMode && editingId) {
|
|
setSelectedId(editingId);
|
|
} else if (res.data?.id) {
|
|
setSelectedId(res.data.id);
|
|
}
|
|
} else {
|
|
toast.error(res.message || "저장에 실패했어요.");
|
|
}
|
|
} catch (err: any) {
|
|
toast.error("저장에 실패했어요.");
|
|
} 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) {
|
|
toast.success("삭제되었어요.");
|
|
setSelectedId(null);
|
|
setDetailOpen(false);
|
|
await fetchRequests();
|
|
} else {
|
|
toast.error(res.message || "삭제에 실패했어요.");
|
|
}
|
|
} catch (err: any) {
|
|
toast.error("삭제에 실패했어요.");
|
|
}
|
|
}, [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-warning" };
|
|
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-warning" };
|
|
return { text: `${diff}일 남음`, color: "text-success" };
|
|
},
|
|
[today]
|
|
);
|
|
|
|
const getProgress = useCallback((status: string) => {
|
|
return STATUS_PROGRESS[status] ?? 0;
|
|
}, []);
|
|
|
|
const handleRowClick = useCallback((id: string) => {
|
|
setSelectedId(id);
|
|
setDetailOpen(true);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col gap-3 p-3">
|
|
{/* 검색 필터 */}
|
|
<div className="shrink-0">
|
|
<DynamicSearchFilter
|
|
tableName="dsn_design_request"
|
|
filterId="c16-design-request"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={filteredRequests.length}
|
|
/>
|
|
</div>
|
|
|
|
{/* 현황 카드 */}
|
|
<div className="grid grid-cols-3 gap-3 shrink-0">
|
|
<div className="rounded-lg border bg-card px-3 py-2 text-left">
|
|
<div className="text-[10px] text-muted-foreground">접수대기</div>
|
|
<div className="text-xl font-bold text-info">{statusCounts.접수대기}</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-card px-3 py-2 text-left">
|
|
<div className="text-[10px] text-muted-foreground">설계진행</div>
|
|
<div className="text-xl font-bold text-warning">{statusCounts.설계진행}</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-card px-3 py-2 text-left">
|
|
<div className="text-[10px] text-muted-foreground">출도완료</div>
|
|
<div className="text-xl font-bold text-success">{statusCounts.출도완료}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 액션 바 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold">설계의뢰 관리</h2>
|
|
<Badge variant="secondary" className="font-mono">{filteredRequests.length}건</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={handleOpenRegister}>
|
|
<Plus className="w-4 h-4 mr-1.5" /> 설계의뢰 등록
|
|
</Button>
|
|
<div className="mx-1 h-6 w-px bg-border" />
|
|
<Button variant="outline" size="sm" disabled={!selectedId} onClick={handleOpenEdit}>
|
|
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
|
</Button>
|
|
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
|
|
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 영역 */}
|
|
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
|
<EDataTable<DesignRequest>
|
|
columns={ts.visibleColumns.map((col): EDataTableColumn<DesignRequest> => ({
|
|
key: col.key,
|
|
label: col.label,
|
|
width: col.key === "request_no" ? "w-[100px]" : col.key === "design_type" ? "w-[70px]" : col.key === "status" ? "w-[70px]" : col.key === "priority" ? "w-[60px]" : col.key === "customer" ? "w-[90px]" : col.key === "designer" ? "w-[70px]" : col.key === "due_date" ? "w-[85px]" : col.key === "progress" ? "w-[65px]" : undefined,
|
|
align: (col.key === "design_type" || col.key === "status" || col.key === "priority" || col.key === "progress") ? "center" : undefined,
|
|
render: col.key === "request_no"
|
|
? (val: any) => <span className="text-[11px] font-semibold text-primary">{val || "-"}</span>
|
|
: col.key === "design_type"
|
|
? (val: any) => val ? <Badge className={cn("text-[9px]", TYPE_STYLES[val])}>{val}</Badge> : <span>-</span>
|
|
: col.key === "status"
|
|
? (val: any) => <Badge className={cn("text-[9px]", STATUS_STYLES[val])}>{val}</Badge>
|
|
: col.key === "priority"
|
|
? (val: any) => <Badge className={cn("text-[9px]", PRIORITY_STYLES[val])}>{val}</Badge>
|
|
: col.key === "progress"
|
|
? (_val: any, row: DesignRequest) => {
|
|
const progress = STATUS_PROGRESS[row.status] ?? 0;
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
: undefined,
|
|
}))}
|
|
data={ts.groupData(filteredRequests)}
|
|
loading={loading}
|
|
emptyMessage="등록된 설계의뢰가 없어요"
|
|
selectedId={selectedId}
|
|
onSelect={(id) => setSelectedId(id)}
|
|
onRowClick={(row) => handleRowClick(row.id)}
|
|
draggableColumns={false}
|
|
/>
|
|
</div>
|
|
|
|
{/* 상세 정보 다이얼로그 */}
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="max-w-[900px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>{selectedItem ? `설계의뢰 상세 — ${selectedItem.request_no}` : "설계의뢰 상세"}</DialogTitle>
|
|
<DialogDescription>설계의뢰의 상세 정보를 확인해요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{selectedItem && (
|
|
<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-primary bg-primary"
|
|
: isDone || !isLast
|
|
? "border-success bg-success"
|
|
: "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>
|
|
<DialogFooter>
|
|
{selectedItem && (
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={handleOpenEdit}>
|
|
<Pencil className="mr-1 h-3.5 w-3.5" />수정
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive" onClick={handleDelete}>
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />삭제
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 등록/수정 모달 */}
|
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
<DialogContent className="max-w-5xl w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditMode ? "설계의뢰 수정" : "설계의뢰 등록"}</DialogTitle>
|
|
<DialogDescription>{isEditMode ? "설계의뢰 정보를 수정해요." : "새 설계의뢰를 등록해요."}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="flex flex-col md:flex-row gap-6 p-6">
|
|
{/* 좌측: 기본 정보 */}
|
|
<div className="md:w-[420px] shrink-0 space-y-4">
|
|
<h3 className="text-sm font-semibold pb-2 border-b">의뢰 기본 정보</h3>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰번호</Label>
|
|
<Input value={form.request_no} readOnly className="h-9 bg-muted cursor-not-allowed" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰일자</Label>
|
|
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">납기 <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" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰 유형 <span className="text-destructive">*</span></Label>
|
|
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">우선순위 <span className="text-destructive">*</span></Label>
|
|
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비/제품명 <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" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰부서</Label>
|
|
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
|
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰자</Label>
|
|
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">고객명</Label>
|
|
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">수주번호</Label>
|
|
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설계담당자</Label>
|
|
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
|
|
<SelectTrigger className="h-9"><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">
|
|
<h3 className="text-sm font-semibold pb-2 border-b">요구사양 및 설명</h3>
|
|
<div className="flex-1">
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">요구사양 <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]"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">참조 도면번호</Label>
|
|
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">비고</Label>
|
|
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px]" rows={3} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold pb-2 border-b mb-2">첨부파일</h3>
|
|
<div className="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>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setModalOpen(false)} disabled={saving}>취소</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|