f32861df8b
- 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.
1040 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|