1128 lines
42 KiB
TypeScript
1128 lines
42 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 { Card, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
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 {
|
|
Search,
|
|
Download,
|
|
Upload,
|
|
Settings,
|
|
RotateCcw,
|
|
Plus,
|
|
Save,
|
|
BarChart3,
|
|
ClipboardList,
|
|
Inbox,
|
|
Check,
|
|
ChevronsUpDown,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
// --- Types ---
|
|
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
|
|
type ClaimStatus = "접수" | "처리중" | "완료" | "취소";
|
|
|
|
interface Claim {
|
|
claimNo: string;
|
|
claimDate: string;
|
|
claimType: ClaimType;
|
|
claimStatus: ClaimStatus;
|
|
customerCode: string;
|
|
customerName: string;
|
|
managerName: string;
|
|
orderNo: string;
|
|
claimContent: string;
|
|
processContent: string;
|
|
}
|
|
|
|
interface CustomerOption {
|
|
customerCode: string;
|
|
customerName: string;
|
|
}
|
|
|
|
interface SalesOrderOption {
|
|
orderNo: string;
|
|
partnerName: string;
|
|
status: string;
|
|
}
|
|
|
|
// --- Sample Data ---
|
|
const initialData: Claim[] = [
|
|
{
|
|
claimNo: "CLM-2025-004",
|
|
claimDate: "2025-11-09",
|
|
claimType: "불량",
|
|
claimStatus: "접수",
|
|
customerCode: "CUST-0001",
|
|
customerName: "주식회사 코아스포트",
|
|
managerName: "김철수",
|
|
orderNo: "SO-2025-0102",
|
|
claimContent: "제품 표면에 스크래치가 발견되었습니다.",
|
|
processContent: "",
|
|
},
|
|
{
|
|
claimNo: "CLM-2025-001",
|
|
claimDate: "2025-01-05",
|
|
claimType: "불량",
|
|
claimStatus: "접수",
|
|
customerCode: "CUST-0002",
|
|
customerName: "(주)현상산업",
|
|
managerName: "김철수",
|
|
orderNo: "SO-2025-0102",
|
|
claimContent: "제품 불량",
|
|
processContent: "",
|
|
},
|
|
{
|
|
claimNo: "CLM-2025-002",
|
|
claimDate: "2025-01-04",
|
|
claimType: "교환",
|
|
claimStatus: "처리중",
|
|
customerCode: "CUST-0003",
|
|
customerName: "대한전섬",
|
|
managerName: "이영희",
|
|
orderNo: "SO-2025-0095",
|
|
claimContent: "규격 불일치",
|
|
processContent: "교환 진행 중",
|
|
},
|
|
{
|
|
claimNo: "CLM-2025-003",
|
|
claimDate: "2025-01-03",
|
|
claimType: "반품",
|
|
claimStatus: "완료",
|
|
customerCode: "CUST-0004",
|
|
customerName: "삼성전자",
|
|
managerName: "박민수",
|
|
orderNo: "SO-2024-1285",
|
|
claimContent: "수량 초과 납품",
|
|
processContent: "반품 완료",
|
|
},
|
|
];
|
|
|
|
const getClaimTypeStyle = (type: ClaimType) => {
|
|
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: ClaimStatus) => {
|
|
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 [data, setData] = useState<Claim[]>(initialData);
|
|
const [selectedClaimNo, setSelectedClaimNo] = useState<string | null>(null);
|
|
|
|
// 검색 상태
|
|
const [searchDateFrom, setSearchDateFrom] = useState("");
|
|
const [searchDateTo, setSearchDateTo] = useState("");
|
|
const [searchClaimType, setSearchClaimType] = useState<string>("all");
|
|
const [searchStatus, setSearchStatus] = useState<string>("all");
|
|
const [searchCustomer, setSearchCustomer] = useState("");
|
|
const [searchClaimNo, setSearchClaimNo] = useState("");
|
|
|
|
// 모달 상태
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const [formData, setFormData] = useState<Partial<Claim>>({});
|
|
|
|
// Combobox 상태
|
|
const [customerOpen, setCustomerOpen] = useState(false);
|
|
const [orderOpen, setOrderOpen] = useState(false);
|
|
|
|
// DB 데이터
|
|
const [customers, setCustomers] = useState<CustomerOption[]>([]);
|
|
const [salesOrders, setSalesOrders] = useState<SalesOrderOption[]>([]);
|
|
const [customersLoading, setCustomersLoading] = useState(false);
|
|
const [ordersLoading, setOrdersLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
}, []);
|
|
|
|
// 거래처 목록 조회
|
|
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);
|
|
} else {
|
|
console.warn("거래처 응답 구조 확인:", JSON.stringify(res.data, null, 2));
|
|
}
|
|
} catch (e) {
|
|
console.error("거래처 목록 조회 실패:", e);
|
|
} finally {
|
|
setCustomersLoading(false);
|
|
}
|
|
}, [customers.length]);
|
|
|
|
// 수주 목록 조회
|
|
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);
|
|
} else {
|
|
console.warn("수주 응답 구조 확인:", JSON.stringify(res.data, null, 2));
|
|
}
|
|
} catch (e) {
|
|
console.error("수주 목록 조회 실패:", e);
|
|
} finally {
|
|
setOrdersLoading(false);
|
|
}
|
|
}, [salesOrders.length]);
|
|
|
|
const filteredData = useMemo(() => {
|
|
return data
|
|
.filter((claim) => {
|
|
if (searchDateFrom && claim.claimDate < searchDateFrom) return false;
|
|
if (searchDateTo && claim.claimDate > searchDateTo) return false;
|
|
if (searchClaimType !== "all" && claim.claimType !== searchClaimType)
|
|
return false;
|
|
if (searchStatus !== "all" && claim.claimStatus !== searchStatus)
|
|
return false;
|
|
if (
|
|
searchCustomer &&
|
|
!claim.customerName
|
|
.toLowerCase()
|
|
.includes(searchCustomer.toLowerCase())
|
|
)
|
|
return false;
|
|
if (
|
|
searchClaimNo &&
|
|
!claim.claimNo.toLowerCase().includes(searchClaimNo.toLowerCase())
|
|
)
|
|
return false;
|
|
return true;
|
|
})
|
|
.sort((a, b) => b.claimDate.localeCompare(a.claimDate));
|
|
}, [
|
|
data,
|
|
searchDateFrom,
|
|
searchDateTo,
|
|
searchClaimType,
|
|
searchStatus,
|
|
searchCustomer,
|
|
searchClaimNo,
|
|
]);
|
|
|
|
// 상태별 카운트
|
|
const statusCounts = useMemo(() => {
|
|
const counts = { 접수: 0, 처리중: 0, 완료: 0, 취소: 0 };
|
|
data.forEach((claim) => {
|
|
if (counts[claim.claimStatus] !== undefined) {
|
|
counts[claim.claimStatus]++;
|
|
}
|
|
});
|
|
return counts;
|
|
}, [data]);
|
|
|
|
const generateClaimNo = useCallback(() => {
|
|
const year = new Date().getFullYear();
|
|
const prefix = `CLM-${year}-`;
|
|
const existingNumbers = data
|
|
.filter((c) => c.claimNo.startsWith(prefix))
|
|
.map((c) => parseInt(c.claimNo.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({
|
|
claimNo: generateClaimNo(),
|
|
claimDate: new Date().toISOString().split("T")[0],
|
|
claimType: undefined,
|
|
claimStatus: "접수",
|
|
customerCode: "",
|
|
customerName: "",
|
|
managerName: "",
|
|
orderNo: "",
|
|
claimContent: "",
|
|
processContent: "",
|
|
});
|
|
setIsModalOpen(true);
|
|
fetchCustomers(true);
|
|
fetchSalesOrders(true);
|
|
};
|
|
|
|
const openEditModal = (claimNo: string) => {
|
|
const claim = data.find((c) => c.claimNo === claimNo);
|
|
if (!claim) return;
|
|
setIsEditMode(true);
|
|
setFormData({ ...claim });
|
|
setIsModalOpen(true);
|
|
fetchCustomers(true);
|
|
fetchSalesOrders(true);
|
|
};
|
|
|
|
const handleFormChange = (field: keyof Claim, value: string) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!formData.claimType || !formData.customerName || !formData.claimContent) {
|
|
alert("필수 항목을 모두 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
const claimData: Claim = {
|
|
claimNo: formData.claimNo || "",
|
|
claimDate: formData.claimDate || new Date().toISOString().split("T")[0],
|
|
claimType: formData.claimType as ClaimType,
|
|
claimStatus: (formData.claimStatus as ClaimStatus) || "접수",
|
|
customerCode: formData.customerCode || "",
|
|
customerName: formData.customerName || "",
|
|
managerName: formData.managerName || "",
|
|
orderNo: formData.orderNo || "",
|
|
claimContent: formData.claimContent || "",
|
|
processContent: formData.processContent || "",
|
|
};
|
|
|
|
if (isEditMode) {
|
|
setData((prev) =>
|
|
prev.map((c) => (c.claimNo === claimData.claimNo ? claimData : c))
|
|
);
|
|
} else {
|
|
setData((prev) => [claimData, ...prev]);
|
|
}
|
|
|
|
setIsModalOpen(false);
|
|
alert("클레임이 저장되었습니다.");
|
|
};
|
|
|
|
const handleResetSearch = () => {
|
|
const today = new Date();
|
|
const thirtyDaysAgo = new Date(today);
|
|
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
setSearchDateFrom(thirtyDaysAgo.toISOString().split("T")[0]);
|
|
setSearchDateTo(today.toISOString().split("T")[0]);
|
|
setSearchClaimType("all");
|
|
setSearchStatus("all");
|
|
setSearchCustomer("");
|
|
setSearchClaimNo("");
|
|
};
|
|
|
|
const selectedClaim = useMemo(
|
|
() => data.find((c) => c.claimNo === 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">
|
|
{/* 검색 섹션 */}
|
|
<Card className="shrink-0">
|
|
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">접수일자</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="date"
|
|
className="w-[140px] h-9"
|
|
value={searchDateFrom}
|
|
onChange={(e) => setSearchDateFrom(e.target.value)}
|
|
/>
|
|
<span className="text-muted-foreground">~</span>
|
|
<Input
|
|
type="date"
|
|
className="w-[140px] h-9"
|
|
value={searchDateTo}
|
|
onChange={(e) => setSearchDateTo(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">클레임 유형</Label>
|
|
<Select value={searchClaimType} onValueChange={setSearchClaimType}>
|
|
<SelectTrigger className="w-[120px] h-9">
|
|
<SelectValue placeholder="전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
{CLAIM_TYPES.map((t) => (
|
|
<SelectItem key={t} value={t}>
|
|
{t}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">처리 상태</Label>
|
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
|
<SelectTrigger className="w-[120px] h-9">
|
|
<SelectValue placeholder="전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
{CLAIM_STATUSES.map((s) => (
|
|
<SelectItem key={s} value={s}>
|
|
{s}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">거래처명</Label>
|
|
<Input
|
|
placeholder="거래처 검색"
|
|
className="w-[150px] h-9"
|
|
value={searchCustomer}
|
|
onChange={(e) => setSearchCustomer(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-muted-foreground">클레임번호</Label>
|
|
<Input
|
|
placeholder="클레임번호 검색"
|
|
className="w-[150px] h-9"
|
|
value={searchClaimNo}
|
|
onChange={(e) => setSearchClaimNo(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9"
|
|
onClick={handleResetSearch}
|
|
>
|
|
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-9">
|
|
<Settings className="w-4 h-4 mr-2" /> 사용자옵션
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-9">
|
|
<Upload className="w-4 h-4 mr-2" /> 엑셀 업로드
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="h-9">
|
|
<Download className="w-4 h-4 mr-2" /> 엑셀 다운로드
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 메인 분할 레이아웃 */}
|
|
<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">
|
|
{filteredData.length}건
|
|
</Badge>
|
|
</div>
|
|
<Button size="sm" onClick={openRegisterModal}>
|
|
<Plus className="w-4 h-4 mr-1.5" /> 클레임 등록
|
|
</Button>
|
|
</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>
|
|
{filteredData.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>등록된 클레임이 없습니다</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredData.map((claim, idx) => (
|
|
<TableRow
|
|
key={claim.claimNo}
|
|
className={cn(
|
|
"cursor-pointer hover:bg-muted/50 transition-colors",
|
|
selectedClaimNo === claim.claimNo && "bg-primary/5"
|
|
)}
|
|
onClick={() => handleRowClick(claim.claimNo)}
|
|
onDoubleClick={() => openEditModal(claim.claimNo)}
|
|
>
|
|
<TableCell className="text-center text-muted-foreground">
|
|
{idx + 1}
|
|
</TableCell>
|
|
<TableCell className="font-medium">
|
|
{claim.claimNo}
|
|
</TableCell>
|
|
<TableCell>{claim.claimDate}</TableCell>
|
|
<TableCell className="text-center">
|
|
<span
|
|
className={cn(
|
|
"px-2 py-1 rounded-full text-[11px] font-medium border",
|
|
getClaimTypeStyle(claim.claimType)
|
|
)}
|
|
>
|
|
{claim.claimType}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<span
|
|
className={cn(
|
|
"px-2 py-1 rounded-full text-[11px] font-medium border",
|
|
getClaimStatusStyle(claim.claimStatus)
|
|
)}
|
|
>
|
|
{claim.claimStatus}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>{claim.customerName}</TableCell>
|
|
<TableCell>{claim.managerName || "-"}</TableCell>
|
|
<TableCell className="text-muted-foreground text-xs">
|
|
{claim.orderNo || "-"}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground truncate max-w-[300px]">
|
|
{claim.claimContent || "-"}
|
|
</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.claimNo}
|
|
</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.claimNo}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs block mb-1">
|
|
접수일자
|
|
</span>
|
|
<span>{selectedClaim.claimDate}</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.claimType)
|
|
)}
|
|
>
|
|
{selectedClaim.claimType}
|
|
</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.claimStatus)
|
|
)}
|
|
>
|
|
{selectedClaim.claimStatus}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs block mb-1">
|
|
거래처명
|
|
</span>
|
|
<span>{selectedClaim.customerName}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground text-xs block mb-1">
|
|
담당자
|
|
</span>
|
|
<span>{selectedClaim.managerName || "-"}</span>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<span className="text-muted-foreground text-xs block mb-1">
|
|
수주번호
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
{selectedClaim.orderNo || "-"}
|
|
</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.claimContent || "-"}
|
|
</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.processContent || "-"}
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => openEditModal(selectedClaim.claimNo)}
|
|
>
|
|
<Save className="w-4 h-4 mr-2" /> 수정
|
|
</Button>
|
|
</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>
|
|
|
|
{/* 클레임 등록/수정 모달 */}
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
{isEditMode ? "클레임 수정" : "클레임 등록"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{isEditMode
|
|
? "클레임 정보를 수정합니다."
|
|
: "새로운 클레임을 등록합니다."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
<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="claimNo" className="text-xs sm:text-sm">
|
|
클레임번호
|
|
</Label>
|
|
<Input
|
|
id="claimNo"
|
|
value={formData.claimNo || ""}
|
|
readOnly
|
|
className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="claimDate" className="text-xs sm:text-sm">
|
|
접수일자
|
|
</Label>
|
|
<Input
|
|
id="claimDate"
|
|
type="date"
|
|
value={formData.claimDate || ""}
|
|
onChange={(e) =>
|
|
handleFormChange("claimDate", e.target.value)
|
|
}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="claimType" className="text-xs sm:text-sm">
|
|
클레임 유형 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Select
|
|
value={formData.claimType || ""}
|
|
onValueChange={(v) => handleFormChange("claimType", 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="claimStatus" className="text-xs sm:text-sm">
|
|
처리 상태
|
|
</Label>
|
|
<Select
|
|
value={formData.claimStatus || "접수"}
|
|
onValueChange={(v) => handleFormChange("claimStatus", 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.customerName || "거래처 선택"}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="거래처 검색..."
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
<CommandList>
|
|
{customersLoading ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
<span className="ml-2 text-xs text-muted-foreground">로딩 중...</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<CommandEmpty className="text-xs sm:text-sm py-4 text-center">
|
|
거래처를 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{customers.map((cust) => (
|
|
<CommandItem
|
|
key={cust.customerCode}
|
|
value={`${cust.customerCode} ${cust.customerName}`}
|
|
onSelect={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
customerCode: cust.customerCode,
|
|
customerName: cust.customerName,
|
|
}));
|
|
setCustomerOpen(false);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
formData.customerCode === cust.customerCode
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{cust.customerName}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{cust.customerCode}
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="managerName" className="text-xs sm:text-sm">
|
|
담당자
|
|
</Label>
|
|
<Input
|
|
id="managerName"
|
|
value={formData.managerName || ""}
|
|
onChange={(e) =>
|
|
handleFormChange("managerName", 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.orderNo || "수주번호 선택"}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="수주번호 검색..."
|
|
className="text-xs sm:text-sm"
|
|
/>
|
|
<CommandList>
|
|
{ordersLoading ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
<span className="ml-2 text-xs text-muted-foreground">로딩 중...</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<CommandEmpty className="text-xs sm:text-sm py-4 text-center">
|
|
수주번호를 찾을 수 없습니다.
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{salesOrders.map((order) => (
|
|
<CommandItem
|
|
key={order.orderNo}
|
|
value={`${order.orderNo} ${order.partnerName}`}
|
|
onSelect={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
orderNo: order.orderNo,
|
|
}));
|
|
setOrderOpen(false);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
formData.orderNo === order.orderNo
|
|
? "opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{order.orderNo}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{order.status}
|
|
{order.partnerName ? ` | ${order.partnerName}` : ""}
|
|
</span>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오른쪽: 상세 내용 */}
|
|
<div 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="claimContent" className="text-xs sm:text-sm">
|
|
클레임 내용 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
id="claimContent"
|
|
value={formData.claimContent || ""}
|
|
onChange={(e) =>
|
|
handleFormChange("claimContent", e.target.value)
|
|
}
|
|
placeholder="클레임 내용을 상세히 입력해주세요"
|
|
className="min-h-[200px] resize-y text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label
|
|
htmlFor="processContent"
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
처리 내용
|
|
</Label>
|
|
<Textarea
|
|
id="processContent"
|
|
value={formData.processContent || ""}
|
|
onChange={(e) =>
|
|
handleFormChange("processContent", e.target.value)
|
|
}
|
|
placeholder="처리 내용을 입력해주세요"
|
|
className="min-h-[150px] resize-y text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<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}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<Save className="w-4 h-4 mr-2" /> 저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|