Files
pipeline/frontend/app/(main)/COMPANY_7/sales/claim/page.tsx
T
kjs f32861df8b feat: add new design management pages and session files
- Introduced multiple new pages for design management, including change management, design requests, my work, project management, and task management.
- Added session files to track design sessions with relevant details such as session ID, end time, and reason.
- Enhanced the overall structure and organization of the design management features, improving user experience and functionality.

This commit expands the design management capabilities within the application, allowing for better tracking and handling of design-related tasks.
2026-03-27 14:48:15 +09:00

1040 lines
39 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
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 {
Download,
Plus,
Save,
BarChart3,
ClipboardList,
Inbox,
Check,
ChevronsUpDown,
Loader2,
FileSpreadsheet,
Trash2,
} 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 { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
// --- 상수 ---
const TABLE_NAME = "claim_mng";
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;
}
const getClaimTypeStyle = (type: string) => {
switch (type) {
case "불량":
return "bg-rose-100 text-rose-800 border-rose-200";
case "교환":
return "bg-amber-100 text-amber-800 border-amber-200";
case "반품":
return "bg-blue-100 text-blue-800 border-blue-200";
case "배송지연":
return "bg-indigo-100 text-indigo-800 border-indigo-200";
case "기타":
return "bg-gray-100 text-gray-800 border-gray-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const getClaimStatusStyle = (status: string) => {
switch (status) {
case "접수":
return "bg-blue-100 text-blue-800 border-blue-200";
case "처리중":
return "bg-amber-100 text-amber-800 border-amber-200";
case "완료":
return "bg-emerald-100 text-emerald-800 border-emerald-200";
case "취소":
return "bg-rose-100 text-rose-800 border-rose-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const CLAIM_TYPES: ClaimType[] = ["불량", "교환", "반품", "배송지연", "기타"];
const CLAIM_STATUSES: ClaimStatus[] = ["접수", "처리중", "완료", "취소"];
export default function ClaimManagementPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
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);
// --- 데이터 조회 (table-management API + autoFilter로 멀티테넌시 자동 적용) ---
const fetchData = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = 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, // company_code 자동 필터링
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 (err) {
console.error("클레임 조회 실패:", err);
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: { enabled: 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 (e) {
console.error("거래처 목록 조회 실패:", e);
} 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: { enabled: 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 (e) {
console.error("수주 목록 조회 실패:", e);
} 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 {
// company_code, writer, created_date 등 시스템 필드는 제외 (백엔드가 자동 주입)
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]
);
const statCards: {
label: string;
value: number;
gradient: string;
textColor: string;
}[] = [
{
label: "접수",
value: statusCounts["접수"],
gradient: "from-indigo-500 to-purple-600",
textColor: "text-white",
},
{
label: "처리중",
value: statusCounts["처리중"],
gradient: "from-amber-400 to-orange-500",
textColor: "text-white",
},
{
label: "완료",
value: statusCounts["완료"],
gradient: "from-cyan-400 to-blue-500",
textColor: "text-white",
},
{
label: "취소",
value: statusCounts["취소"],
gradient: "from-slate-300 to-slate-400",
textColor: "text-slate-800",
},
];
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
{/* 검색 섹션 — DynamicSearchFilter 사용 */}
<DynamicSearchFilter
tableName={TABLE_NAME}
filterId="sales-claim"
onFilterChange={setSearchFilters}
dataCount={totalCount}
/>
{/* 메인 분할 레이아웃 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 클레임 목록 */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2">
<ClipboardList className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">
{totalCount}
</Badge>
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">No</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Inbox className="w-8 h-8 text-muted-foreground/50" />
<span>{loading ? "데이터를 불러오는 중..." : "등록된 클레임이 없습니다"}</span>
</div>
</TableCell>
</TableRow>
) : (
data.map((claim, idx) => (
<TableRow
key={claim.id}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedClaimNo === claim.claim_no && "bg-primary/5"
)}
onClick={() => handleRowClick(claim.claim_no)}
onDoubleClick={() => openEditModal(claim.claim_no)}
>
<TableCell className="text-center text-muted-foreground">
{idx + 1}
</TableCell>
<TableCell className="font-medium">
{claim.claim_no}
</TableCell>
<TableCell>{claim.claim_date}</TableCell>
<TableCell className="text-center">
<span
className={cn(
"px-2 py-1 rounded-full text-[11px] font-medium border",
getClaimTypeStyle(claim.claim_type)
)}
>
{claim.claim_type}
</span>
</TableCell>
<TableCell className="text-center">
<span
className={cn(
"px-2 py-1 rounded-full text-[11px] font-medium border",
getClaimStatusStyle(claim.claim_status)
)}
>
{claim.claim_status}
</span>
</TableCell>
<TableCell>{claim.customer_name}</TableCell>
<TableCell>{claim.manager_name || "-"}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{claim.order_no || "-"}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[300px]">
{claim.claim_content || "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 처리 현황 */}
<ResizablePanel defaultSize={35} minSize={20}>
<div className="flex flex-col h-full bg-card">
<div className="flex items-center justify-between p-3 border-b shrink-0">
<span className="font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
</span>
</div>
<div className="flex-1 overflow-auto p-4 space-y-5">
{/* 상태별 카드 */}
<div className="grid grid-cols-2 gap-3">
{statCards.map((card) => (
<div
key={card.label}
className={cn(
"rounded-xl p-5 text-center bg-linear-to-br transition-all hover:-translate-y-0.5 hover:shadow-md",
card.gradient,
card.textColor
)}
>
<div className="text-sm font-medium opacity-90 mb-2">
{card.label}
</div>
<div className="text-4xl font-bold">{card.value}</div>
</div>
))}
</div>
{/* 선택된 클레임 상세 */}
{selectedClaim ? (
<div className="space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2 pt-2 border-t">
<ClipboardList className="w-4 h-4" />
- {selectedClaim.claim_no}
</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span className="font-medium">
{selectedClaim.claim_no}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span>{selectedClaim.claim_date}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span
className={cn(
"px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block",
getClaimTypeStyle(selectedClaim.claim_type)
)}
>
{selectedClaim.claim_type}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span
className={cn(
"px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block",
getClaimStatusStyle(selectedClaim.claim_status)
)}
>
{selectedClaim.claim_status}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span>{selectedClaim.customer_name}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span>{selectedClaim.manager_name || "-"}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground text-xs block mb-1">
</span>
<span className="text-muted-foreground">
{selectedClaim.order_no || "-"}
</span>
</div>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<div className="bg-muted/30 p-3 rounded-md border border-border/50 text-sm whitespace-pre-wrap min-h-[60px]">
{selectedClaim.claim_content || "-"}
</div>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1">
</span>
<div className="bg-muted/30 p-3 rounded-md border border-border/50 text-sm whitespace-pre-wrap min-h-[60px]">
{selectedClaim.process_content || "-"}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openEditModal(selectedClaim.claim_no)}
>
<Save className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(selectedClaim.claim_no)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-3">
<BarChart3 className="w-7 h-7 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 클레임 등록/수정 모달 — FullscreenDialog 사용 */}
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "클레임 수정" : "클레임 등록"}
description={isEditMode ? "클레임 정보를 수정합니다." : "새로운 클레임을 등록합니다."}
defaultMaxWidth="max-w-[900px]"
footer={
<div className="flex gap-2 w-full sm:w-auto">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{saving ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
</Button>
</div>
}
>
<div className="flex flex-col md:flex-row gap-5">
{/* 왼쪽: 기본 정보 */}
<div className="md:w-[340px] shrink-0 space-y-4 bg-muted/30 p-4 rounded-lg border border-border/50">
<h3 className="text-sm font-semibold pb-2 border-b">
</h3>
<div>
<Label htmlFor="claim_no" className="text-xs sm:text-sm">
</Label>
<Input
id="claim_no"
value={formData.claim_no || ""}
readOnly
className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed"
/>
</div>
<div>
<Label htmlFor="claim_date" className="text-xs sm:text-sm">
</Label>
<FormDatePicker
value={formData.claim_date || ""}
onChange={(v) => handleFormChange("claim_date", v)}
placeholder="접수일자"
/>
</div>
<div>
<Label htmlFor="claim_type" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.claim_type || ""}
onValueChange={(v) => handleFormChange("claim_type", v)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{CLAIM_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="claim_status" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.claim_status || "접수"}
onValueChange={(v) => handleFormChange("claim_status", v)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLAIM_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Popover open={customerOpen} onOpenChange={setCustomerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={customerOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm 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="거래처 검색..."
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,
customer_code: cust.customerCode,
customer_name: cust.customerName,
}));
setCustomerOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.customer_code === 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>
<Label htmlFor="manager_name" className="text-xs sm:text-sm">
</Label>
<Input
id="manager_name"
value={formData.manager_name || ""}
onChange={(e) => handleFormChange("manager_name", e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Popover open={orderOpen} onOpenChange={setOrderOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={orderOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm 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="수주번호 검색..."
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,
order_no: order.orderNo,
}));
setOrderOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.order_no === 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 className="flex-1 space-y-4 bg-muted/30 p-4 rounded-lg border border-border/50 min-w-0">
<h3 className="text-sm font-semibold pb-2 border-b">
</h3>
<div className="flex flex-col flex-1">
<Label htmlFor="claim_content" className="text-xs sm:text-sm">
<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-xs sm:text-sm"
/>
</div>
<div>
<Label htmlFor="process_content" className="text-xs sm:text-sm">
</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-xs sm:text-sm"
/>
</div>
</div>
</div>
</FullscreenDialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
onSuccess={() => {
fetchData();
toast.success("엑셀 업로드가 완료되었습니다.");
}}
/>
{/* 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>
);
}