Files
wace_rps/frontend/app/(main)/COMPANY_16/sales/claim/page.tsx
T

985 lines
40 KiB
TypeScript

"use client";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Download,
Plus,
Save,
ClipboardList,
Inbox,
Check,
ChevronsUpDown,
Loader2,
FileSpreadsheet,
Trash2,
Pencil,
FileText,
Wrench,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
// --- 상수 ---
const TABLE_NAME = "claim_mng";
const GRID_COLUMNS = [
{ key: "claim_no", label: "클레임번호" },
{ key: "claim_date", label: "접수일자" },
{ key: "claim_type", label: "유형" },
{ key: "claim_status", label: "상태" },
{ key: "customer_name", label: "거래처명" },
{ key: "manager_name", label: "담당자" },
{ key: "claim_content", label: "클레임 내용" },
];
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
type ClaimStatus = "접수" | "처리중" | "완료" | "취소";
interface ClaimRow {
id: number;
claim_no: string;
claim_date: string;
claim_type: string;
claim_status: string;
customer_code: string;
customer_name: string;
manager_name: string;
order_no: string;
claim_content: string;
process_content: string;
company_code?: string;
writer?: string;
created_date?: string;
updated_date?: string;
[key: string]: any;
}
interface CustomerOption {
customerCode: string;
customerName: string;
}
interface SalesOrderOption {
orderNo: string;
partnerName: string;
status: string;
}
// Badge variant 매핑 (CSS 변수 기반)
const getClaimStatusVariant = (status: string) => {
switch (status) {
case "접수": return "default" as const;
case "처리중": return "secondary" as const;
case "완료": return "outline" as const;
case "취소": return "destructive" as const;
default: return "secondary" as const;
}
};
const getClaimTypeVariant = (type: string) => {
switch (type) {
case "불량": return "destructive" as const;
case "교환": return "warning" as const;
case "반품": return "default" as const;
case "배송지연": return "secondary" as const;
case "기타": return "outline" as const;
default: return "outline" as const;
}
};
const CLAIM_TYPES: ClaimType[] = ["불량", "교환", "반품", "배송지연", "기타"];
const CLAIM_STATUSES: ClaimStatus[] = ["접수", "처리중", "완료", "취소"];
export default function ClaimManagementPage() {
useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const ts = useTableSettings("c16-claim", TABLE_NAME, GRID_COLUMNS);
const [data, setData] = useState<ClaimRow[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [selectedClaimNo, setSelectedClaimNo] = useState<string | null>(null);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Partial<ClaimRow>>({});
// 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);
// --- 데이터 조회 (서버사이드 필터 + autoFilter 멀티테넌시) ---
const fetchData = useCallback(async () => {
setLoading(true);
try {
const filters = searchFilters.map(f => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "claim_date", order: "desc" },
});
const rows: ClaimRow[] = res.data?.data?.data || res.data?.data?.rows || [];
setData(rows);
setTotalCount(res.data?.data?.total || rows.length);
} catch {
toast.error("클레임 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 거래처 목록 조회 (autoFilter로 멀티테넌시 적용)
const fetchCustomers = useCallback(async (force = false) => {
if (!force && customers.length > 0) return;
setCustomersLoading(true);
try {
const res = await apiClient.post("/table-management/tables/customer_mng/data", {
page: 1,
size: 9999,
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows;
if (res.data?.success && Array.isArray(rows)) {
const list: CustomerOption[] = rows.map((row: any) => ({
customerCode: row.customer_code || row.id || "",
customerName: row.customer_name || "",
}));
setCustomers(list);
}
} catch {
/* skip */
} finally {
setCustomersLoading(false);
}
}, [customers.length]);
// 수주 목록 조회 (autoFilter로 멀티테넌시 적용)
const fetchSalesOrders = useCallback(async (force = false) => {
if (!force && 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,
});
const rows = res.data?.data?.data || res.data?.data?.rows;
if (res.data?.success && Array.isArray(rows)) {
const seen = new Set<string>();
const list: SalesOrderOption[] = [];
for (const row of 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 {
/* skip */
} finally {
setOrdersLoading(false);
}
}, [salesOrders.length]);
// 상태별 카운트
const statusCounts = useMemo(() => {
const counts = { 접수: 0, 처리중: 0, 완료: 0, 취소: 0 };
data.forEach((claim) => {
if (counts[claim.claim_status as keyof typeof counts] !== undefined) {
counts[claim.claim_status as keyof typeof counts]++;
}
});
return counts;
}, [data]);
// 클레임번호 자동 생성
const generateClaimNo = useCallback(() => {
const year = new Date().getFullYear();
const prefix = `CLM-${year}-`;
const existingNumbers = data
.filter((c) => c.claim_no?.startsWith(prefix))
.map((c) => parseInt(c.claim_no.replace(prefix, ""), 10))
.filter((n) => !isNaN(n));
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
return `${prefix}${String(maxNumber + 1).padStart(3, "0")}`;
}, [data]);
const handleRowClick = (claimNo: string) => {
setSelectedClaimNo(claimNo);
};
const openRegisterModal = () => {
setIsEditMode(false);
setFormData({
claim_no: generateClaimNo(),
claim_date: new Date().toISOString().split("T")[0],
claim_type: undefined,
claim_status: "접수",
customer_code: "",
customer_name: "",
manager_name: "",
order_no: "",
claim_content: "",
process_content: "",
});
setIsModalOpen(true);
fetchCustomers(true);
fetchSalesOrders(true);
};
const openEditModal = (claimNo: string) => {
const claim = data.find((c) => c.claim_no === claimNo);
if (!claim) return;
setIsEditMode(true);
setFormData({ ...claim });
setIsModalOpen(true);
fetchCustomers(true);
fetchSalesOrders(true);
};
const handleFormChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// --- 저장 (table-management API, company_code 자동 주입) ---
const handleSave = async () => {
if (!formData.claim_type || !formData.customer_name || !formData.claim_content) {
toast.error("필수 항목을 모두 입력해주세요. (클레임유형, 거래처명, 클레임내용)");
return;
}
setSaving(true);
try {
const { id, company_code, writer, created_date, updated_date, created_by, updated_by, ...saveFields } = formData as any;
if (isEditMode && id) {
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id },
updatedData: saveFields,
});
toast.success("클레임이 수정되었어요.");
} else {
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, saveFields);
toast.success("클레임이 등록되었어요.");
}
setIsModalOpen(false);
fetchData();
} catch (err: any) {
console.error("클레임 저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
// --- 삭제 ---
const handleDelete = async (claimNo: string) => {
const claim = data.find((c) => c.claim_no === claimNo);
if (!claim) return;
const ok = await confirm(`클레임 ${claimNo}을(를) 삭제하시겠습니까?`, {
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: claim.id }],
});
toast.success("클레임이 삭제되었어요.");
if (selectedClaimNo === claimNo) setSelectedClaimNo(null);
fetchData();
} catch (err: any) {
console.error("클레임 삭제 실패:", err);
toast.error(err.response?.data?.message || "삭제에 실패했어요.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (data.length === 0) {
toast.error("다운로드할 데이터가 없어요.");
return;
}
try {
const exportData = data.map((row) => ({
클레임번호: row.claim_no,
접수일자: row.claim_date,
클레임유형: row.claim_type,
처리상태: row.claim_status,
거래처코드: row.customer_code,
거래처명: row.customer_name,
담당자: row.manager_name,
수주번호: row.order_no,
클레임내용: row.claim_content,
처리내용: row.process_content,
}));
await exportToExcel(exportData, "클레임관리.xlsx", "클레임");
toast.success("엑셀 다운로드 완료");
} catch (err) {
console.error("엑셀 다운로드 실패:", err);
toast.error("엑셀 다운로드에 실패했어요.");
}
};
const selectedClaim = useMemo(
() => data.find((c) => c.claim_no === selectedClaimNo),
[data, selectedClaimNo]
);
return (
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
{/* 검색 필터 (DynamicSearchFilter) */}
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="c16-claim"
onFilterChange={setSearchFilters}
dataCount={data.length}
externalFilterConfig={ts.filterConfig}
/>
{/* ───── 메인 분할 레이아웃 ───── */}
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal" className="h-full">
{/* ── 좌측: 클레임 목록 (65%) ── */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex flex-col h-full bg-card">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-muted-foreground" />
<span className="text-[13px] font-bold text-foreground"> </span>
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
{data.length}
</span>
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-3.5 h-3.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 overflow-auto">
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide">#</TableHead>
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide">
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading && data.length === 0 ? (
<TableRow>
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Inbox className="w-8 h-8 opacity-30" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
) : (
data.map((claim, idx) => (
<TableRow
key={claim.id}
className={cn(
"cursor-pointer transition-colors",
selectedClaimNo === claim.claim_no
? "bg-primary/8 border-l-2 border-l-primary"
: "hover:bg-muted/50"
)}
onClick={() => handleRowClick(claim.claim_no)}
onDoubleClick={() => openEditModal(claim.claim_no)}
>
<TableCell className="text-center text-[11px] text-muted-foreground py-2">
{idx + 1}
</TableCell>
{ts.visibleColumns.map((col) => {
if (col.key === "claim_type") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-center py-2">
<Badge variant={getClaimTypeVariant(claim.claim_type)} className="text-[10px] px-1.5 py-0">
{claim.claim_type}
</Badge>
</TableCell>
);
}
if (col.key === "claim_status") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-center py-2">
<Badge variant={getClaimStatusVariant(claim.claim_status)} className="text-[10px] px-1.5 py-0">
{claim.claim_status}
</Badge>
</TableCell>
);
}
if (col.key === "claim_content") {
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-sm text-muted-foreground py-2 max-w-[200px] truncate">
{claim.claim_content}
</TableCell>
);
}
return (
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-sm py-2">
{claim[col.key] ?? "-"}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ── 우측: 클레임 상세 (35%) ── */}
<ResizablePanel defaultSize={35} minSize={20}>
<div className="flex flex-col h-full bg-card border-l">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-4 h-4 text-muted-foreground" />
<span className="text-[13px] font-bold text-foreground"> </span>
{selectedClaim && (
<span className="text-[11px] font-mono text-muted-foreground">{selectedClaim.claim_no}</span>
)}
</div>
{selectedClaim && (
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => openEditModal(selectedClaim.claim_no)}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(selectedClaim.claim_no)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
)}
</div>
{/* 상태별 요약 카운트 */}
<div className="flex items-center gap-3 px-4 py-2 border-b bg-muted/20 shrink-0">
{([
{ label: "접수", count: statusCounts["접수"], variant: "default" as const },
{ label: "처리중", count: statusCounts["처리중"], variant: "secondary" as const },
{ label: "완료", count: statusCounts["완료"], variant: "outline" as const },
{ label: "취소", count: statusCounts["취소"], variant: "destructive" as const },
]).map((item) => (
<div key={item.label} className="flex items-center gap-1">
<Badge variant={item.variant} className="text-[10px] px-1.5 py-0">
{item.label}
</Badge>
<span className="text-sm font-bold tabular-nums text-foreground">{item.count}</span>
</div>
))}
</div>
{/* 상세 컨텐츠 */}
<div className="flex-1 overflow-auto">
{selectedClaim ? (
<div className="p-4 space-y-4">
{/* 기본 정보 그리드 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm text-foreground">{selectedClaim.claim_date}</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm text-foreground">{selectedClaim.manager_name || "-"}</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<Badge variant={getClaimTypeVariant(selectedClaim.claim_type)} className="text-[11px]">
{selectedClaim.claim_type}
</Badge>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<Badge variant={getClaimStatusVariant(selectedClaim.claim_status)} className="text-[11px]">
{selectedClaim.claim_status}
</Badge>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm font-medium text-foreground">{selectedClaim.customer_name}</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1"></p>
<p className="text-sm font-mono text-muted-foreground">{selectedClaim.order_no || "-"}</p>
</div>
</div>
<div className="border-t" />
{/* 클레임 내용 */}
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<FileText className="w-3.5 h-3.5 text-muted-foreground" />
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> </p>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-sm text-foreground whitespace-pre-wrap min-h-[60px]">
{selectedClaim.claim_content || "-"}
</div>
</div>
{/* 처리 내용 */}
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Wrench className="w-3.5 h-3.5 text-muted-foreground" />
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> </p>
</div>
<div className="rounded-md border bg-muted/30 p-3 text-sm text-foreground whitespace-pre-wrap min-h-[60px]">
{selectedClaim.process_content || "-"}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-14 h-14 flex items-center justify-center mb-4">
<Inbox className="w-7 h-7 text-muted-foreground/30" />
</div>
<p className="text-sm font-medium text-muted-foreground mb-1">
</p>
<p className="text-xs text-muted-foreground/60">
</p>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* ───── 클레임 등록/수정 모달 ───── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[900px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "클레임 수정" : "클레임 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "클레임 정보를 수정해요." : "새로운 클레임을 등록해요."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col md:flex-row gap-4 py-2">
{/* 왼쪽: 기본 정보 */}
<div className="md:w-[320px] shrink-0 space-y-3 bg-muted/30 p-4 rounded-lg border">
<h3 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h3>
<div className="space-y-1">
<Label htmlFor="claim_no" className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
id="claim_no"
value={formData.claim_no || ""}
readOnly
className="h-9 text-sm bg-muted cursor-not-allowed font-mono"
/>
</div>
<div className="space-y-1">
<Label htmlFor="claim_date" className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
id="claim_date"
type="date"
value={formData.claim_date || ""}
onChange={(e) => handleFormChange("claim_date", e.target.value)}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="claim_type" className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.claim_type || ""}
onValueChange={(v) => handleFormChange("claim_type", v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="유형을 선택해주세요" />
</SelectTrigger>
<SelectContent>
{CLAIM_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="claim_status" className="text-[11px] font-semibold text-muted-foreground"> </Label>
<Select
value={formData.claim_status || "접수"}
onValueChange={(v) => handleFormChange("claim_status", v)}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLAIM_STATUSES.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Popover open={customerOpen} onOpenChange={setCustomerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={customerOpen}
className="h-9 w-full justify-between font-normal"
onClick={() => fetchCustomers(false)}
>
{formData.customer_name || "거래처를 선택해주세요"}
<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="거래처 검색..." />
<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="py-4 text-center text-sm">
.
</CommandEmpty>
<CommandGroup>
{customers.map((cust) => (
<CommandItem
key={cust.customerCode}
value={`${cust.customerCode} ${cust.customerName}`}
onSelect={() => {
setFormData((prev) => ({
...prev,
customer_code: cust.customerCode,
customer_name: cust.customerName,
}));
setCustomerOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.customer_code === cust.customerCode
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{cust.customerName}</span>
<span className="text-[10px] text-muted-foreground">{cust.customerCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label htmlFor="manager_name" className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input
id="manager_name"
value={formData.manager_name || ""}
onChange={(e) => handleFormChange("manager_name", e.target.value)}
placeholder="담당자를 입력해주세요"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Popover open={orderOpen} onOpenChange={setOrderOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={orderOpen}
className="h-9 w-full justify-between font-normal"
onClick={() => fetchSalesOrders(false)}
>
{formData.order_no || "수주번호를 선택해주세요"}
<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="수주번호 검색..." />
<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="py-4 text-center text-sm">
.
</CommandEmpty>
<CommandGroup>
{salesOrders.map((order) => (
<CommandItem
key={order.orderNo}
value={`${order.orderNo} ${order.partnerName}`}
onSelect={() => {
setFormData((prev) => ({
...prev,
order_no: order.orderNo,
}));
setOrderOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.order_no === order.orderNo
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{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 className="flex-1 space-y-3 bg-muted/30 p-4 rounded-lg border min-w-0">
<h3 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h3>
<div className="space-y-1">
<Label htmlFor="claim_content" className="text-[11px] font-semibold text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Textarea
id="claim_content"
value={formData.claim_content || ""}
onChange={(e) => handleFormChange("claim_content", e.target.value)}
placeholder="클레임 내용을 상세히 입력해주세요"
className="min-h-[200px] resize-y text-sm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="process_content" className="text-[11px] font-semibold text-muted-foreground"> </Label>
<Textarea
id="process_content"
value={formData.process_content || ""}
onChange={(e) => handleFormChange("process_content", e.target.value)}
placeholder="처리 내용을 입력해주세요"
className="min-h-[150px] resize-y text-sm"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
onSuccess={() => {
fetchData();
toast.success("엑셀 업로드가 완료되었어요.");
}}
/>
{/* 확인 다이얼로그 */}
{ConfirmDialogComponent}
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}