d7c645d24c
Build and Push Images / build-and-push (push) Has been cancelled
신규 4개 메뉴 (PageHeader + CompactFilterBar + DataGrid 통일):
- 품질관리/수입검사 요청 (/quality/incoming-request)
- 품질관리/수입검사 관리 (/quality/incoming-mgmt)
- 품질관리/공정검사 관리 (/quality/process-inspection)
- 품질관리/반제품검사 관리 (/quality/semi-product-inspection)
DB 마이그레이션 (docs/migration/quality/):
- 01_quality_tables_from_ilshin.sql — ilshin 운영 5개 테이블 vexplor_rps 정합
(customer_service_mgmt/part/workingtime, inspection_mgmt, delivery_history_defect)
+ ecr_mng 7개 컬럼 동기화 (project_no, customer_cd, equip_name,
design_dept, unit_cd, memo, check_result)
- 02_wace_plm_quality_tables.sql — wace_plm quality.xml 매퍼 호환 신규 5개 테이블
(incoming_inspection_detail/defect, process_inspection_master/detail,
pms_quality_semi_product_inspection) + 인덱스 정의
백엔드:
- qualityRoutes.ts — 4개 메뉴 list 엔드포인트 (실 테이블 조회)
- ecrMngService SELECT_BASE 에 ilshin 신규 7컬럼 노출
- app.ts 라우팅 등록 (/api/quality/*)
프론트:
- DataGrid 4개 신규 페이지 + 그리드 툴바 (차트/엑셀/새로고침/컬럼설정/페이지사이즈)
- customer-cs/cs, ecr/ecr — 견적관리와 동일한 PageHeader + CompactFilterBar
+ DataGrid 패턴으로 리팩토링 (다이얼로그/기존 API 유지)
- ECR 그리드에 신규 6개 컬럼 추가 (설비명/프로젝트번호/고객사/설계부서/조치결과 등)
- AdminPageRenderer 4개 라우트 등록
데이터 복사: ilshin → vexplor_rps (workingtime 5건, inspection_mgmt 1건,
ecr_mng 1건). 나머지 ilshin 운영 테이블은 0건이므로 스키마만 정합.
479 lines
23 KiB
TypeScript
479 lines
23 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 고객 CS 관리 — wace_plm 의 customerMngList.jsp + customerMngFormPopUp.jsp + customerMngDashBoard.jsp 통합 포팅.
|
|
* 그리드/필터바/헤더는 견적관리 페이지와 동일한 PageHeader + CompactFilterBar + DataGrid 패턴.
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Plus, Trash2, Eye, Pencil } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { toast } from "sonner";
|
|
import { customerCsApi, CsItem, CsCategories } from "@/lib/api/customerCs";
|
|
import { cn } from "@/lib/utils";
|
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
|
import { PageHeader } from "@/components/common/PageHeader";
|
|
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
|
|
type Option = { value: string; label: string };
|
|
|
|
// wace_plm customerMngList.jsp 그리드 컬럼 1:1
|
|
const GRID_COLUMNS: DataGridColumn[] = [
|
|
{ key: "mng_number", label: "CS 번호", width: "w-[120px]", frozen: true },
|
|
{ key: "mng_type_title", label: "관리유형", width: "w-[110px]", align: "center" },
|
|
{ key: "product_division_title", label: "제품구분", width: "w-[110px]", align: "center" },
|
|
{ key: "reception_date_title", label: "접수일", width: "w-[115px]", align: "center" },
|
|
{ key: "customer_name", label: "고객", width: "w-[150px]" },
|
|
{ key: "title", label: "제목", width: "w-[260px]" },
|
|
{ key: "event_location", label: "장소", width: "w-[140px]" },
|
|
{ key: "performer_title", label: "담당자", width: "w-[110px]", align: "center" },
|
|
{ key: "action_date_title", label: "조치일", width: "w-[115px]", align: "center" },
|
|
{ key: "measure_type_title", label: "조치유형", width: "w-[110px]", align: "center" },
|
|
{ key: "measure_amount", label: "금액", width: "w-[130px]", align: "right", formatNumber: true },
|
|
{ key: "status_title", label: "상태", width: "w-[110px]", align: "center" },
|
|
];
|
|
|
|
export default function CustomerCsPage() {
|
|
// 카테고리 ID 매핑 (서버에서 받음)
|
|
const [categories, setCategories] = useState<CsCategories | null>(null);
|
|
|
|
// 옵션
|
|
const [mngTypeOpts, setMngTypeOpts] = useState<Option[]>([]);
|
|
const [productDivOpts, setProductDivOpts] = useState<Option[]>([]);
|
|
const [performerOpts, setPerformerOpts] = useState<Option[]>([]);
|
|
const [measureTypeOpts, setMeasureTypeOpts] = useState<Option[]>([]);
|
|
const [statusOpts, setStatusOpts] = useState<Option[]>([]);
|
|
|
|
// 목록 상태
|
|
const [list, setList] = useState<CsItem[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [sumAmount, setSumAmount] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize] = useState(100);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [selected, setSelected] = useState<CsItem | null>(null);
|
|
|
|
// 대시보드
|
|
const [dash, setDash] = useState<Awaited<ReturnType<typeof customerCsApi.dashboard>> | null>(null);
|
|
|
|
// 필터
|
|
const [searchYear, setSearchYear] = useState(String(new Date().getFullYear()));
|
|
const [mngType, setMngType] = useState("");
|
|
const [productDivision, setProductDivision] = useState("");
|
|
const [statusF, setStatusF] = useState("");
|
|
const [performer, setPerformer] = useState("");
|
|
const [measureType, setMeasureType] = useState("");
|
|
const [customerName, setCustomerName] = useState("");
|
|
const [eventLocation, setEventLocation] = useState("");
|
|
const [startReceptionDate, setStartReceptionDate] = useState("");
|
|
const [endReceptionDate, setEndReceptionDate] = useState("");
|
|
|
|
// 모달
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
|
|
// 카테고리 + 옵션 로드 (1회)
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const cats = await customerCsApi.categories();
|
|
setCategories(cats);
|
|
const [mt, pd, pf, mst, st] = await Promise.all([
|
|
customerCsApi.codeOptions(cats.MNG_TYPE),
|
|
customerCsApi.codeOptions(cats.PRODUCT_DIVISION),
|
|
customerCsApi.codeOptions(cats.PERFORMER),
|
|
customerCsApi.codeOptions(cats.MEASURE_TYPE),
|
|
customerCsApi.codeOptions(cats.STATUS),
|
|
]);
|
|
setMngTypeOpts(mt); setProductDivOpts(pd); setPerformerOpts(pf);
|
|
setMeasureTypeOpts(mst); setStatusOpts(st);
|
|
} catch (e) {
|
|
console.error("CS 옵션 로드 실패", e);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const loadList = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await customerCsApi.list({
|
|
searchYear: searchYear || undefined,
|
|
mngType: mngType || undefined,
|
|
productDivision: productDivision || undefined,
|
|
status: statusF || undefined,
|
|
performer: performer || undefined,
|
|
measureType: measureType || undefined,
|
|
customerName: customerName || undefined,
|
|
eventLocation: eventLocation || undefined,
|
|
startReceptionDate: startReceptionDate || undefined,
|
|
endReceptionDate: endReceptionDate || undefined,
|
|
page, pageSize,
|
|
});
|
|
// DataGrid 는 row.id 를 기본 키로 사용
|
|
setList(res.list.map((r) => ({ ...r, id: r.objid } as any)));
|
|
setTotal(res.pagination.total);
|
|
setSumAmount(res.summary.sumMeasureAmount);
|
|
setSelectedIds([]);
|
|
setSelected(null);
|
|
} catch (e: any) {
|
|
toast.error(e?.message || "CS 목록 조회 실패");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchYear, mngType, productDivision, statusF, performer, measureType, customerName, eventLocation, startReceptionDate, endReceptionDate, page, pageSize]);
|
|
|
|
const loadDash = useCallback(async () => {
|
|
try {
|
|
const d = await customerCsApi.dashboard(searchYear || undefined);
|
|
setDash(d);
|
|
} catch (e) {
|
|
console.error("대시보드 로드 실패", e);
|
|
}
|
|
}, [searchYear]);
|
|
|
|
useEffect(() => { loadList(); }, [loadList]);
|
|
useEffect(() => { loadDash(); }, [loadDash]);
|
|
|
|
const yearOptions = useMemo(() => {
|
|
const cur = new Date().getFullYear();
|
|
return Array.from({ length: 6 }, (_, i) => String(cur - i));
|
|
}, []);
|
|
|
|
const handleReset = () => {
|
|
setSearchYear(String(new Date().getFullYear()));
|
|
setMngType(""); setProductDivision(""); setStatusF("");
|
|
setPerformer(""); setMeasureType(""); setCustomerName("");
|
|
setEventLocation(""); setStartReceptionDate(""); setEndReceptionDate("");
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (selectedIds.length === 0) return toast.warning("삭제할 항목을 선택하세요.");
|
|
if (!confirm(`선택된 ${selectedIds.length}건을 삭제하시겠습니까?`)) return;
|
|
try {
|
|
await customerCsApi.remove(selectedIds);
|
|
toast.success("삭제되었습니다.");
|
|
loadList(); loadDash();
|
|
} catch (e: any) {
|
|
toast.error(e?.message || "삭제 실패");
|
|
}
|
|
};
|
|
|
|
const summaryStats = useMemo(() => {
|
|
if (!dash) return undefined;
|
|
return [
|
|
{ label: "총 건수", value: dash.summary.total_count.toLocaleString(), suffix: "건" },
|
|
{ label: "총 조치금액", value: dash.summary.sum_amount.toLocaleString(), suffix: "원" },
|
|
{ label: "유상 합계", value: dash.summary.sum_paid_amount.toLocaleString(), suffix: "원" },
|
|
{ label: "무상 합계", value: dash.summary.sum_free_amount.toLocaleString(), suffix: "원" },
|
|
];
|
|
}, [dash]);
|
|
|
|
// 그리드용 row 변환 — measure_amount 숫자 변환
|
|
const rows = useMemo(() => list.map((r) => ({
|
|
...r,
|
|
measure_amount: r.measure_amount && /^\d+$/.test(r.measure_amount) ? Number(r.measure_amount) : r.measure_amount,
|
|
})), [list]);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
|
<PageHeader
|
|
loading={loading}
|
|
onSearch={() => { setPage(1); loadList(); loadDash(); }}
|
|
onReset={handleReset}
|
|
actions={
|
|
<>
|
|
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs"
|
|
onClick={handleDelete} disabled={selectedIds.length === 0}>
|
|
<Trash2 className="h-3.5 w-3.5" />삭제 ({selectedIds.length})
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs"
|
|
onClick={() => { if (selected) { setDetailOpen(true); } else { toast.warning("상세를 볼 항목을 선택하세요."); } }}
|
|
disabled={!selected}>
|
|
<Eye className="h-3.5 w-3.5" />상세
|
|
</Button>
|
|
<Button size="sm" className="h-8 gap-1 text-xs"
|
|
onClick={() => { setFormOpen(true); }}>
|
|
{selected ? <Pencil className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
|
{selected ? "수정" : "등록"}
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<CompactFilterBar totalText={<>총 {total.toLocaleString()}건 · 합계 {sumAmount.toLocaleString()}원</>}>
|
|
<CompactFilterField label="연도" width={100}>
|
|
<Select value={searchYear || "all"} onValueChange={(v) => setSearchYear(v === "all" ? "" : v)}>
|
|
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
{yearOptions.map((y) => <SelectItem key={y} value={y}>{y}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</CompactFilterField>
|
|
<CompactFilterField label="관리유형" width={140}>
|
|
<CodeSelect value={mngType} onChange={setMngType} options={mngTypeOpts} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="제품구분" width={120}>
|
|
<CodeSelect value={productDivision} onChange={setProductDivision} options={productDivOpts} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="상태" width={120}>
|
|
<CodeSelect value={statusF} onChange={setStatusF} options={statusOpts} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="담당자" width={120}>
|
|
<CodeSelect value={performer} onChange={setPerformer} options={performerOpts} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="조치유형" width={120}>
|
|
<CodeSelect value={measureType} onChange={setMeasureType} options={measureTypeOpts} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="고객명" width={150}>
|
|
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="장소" width={150}>
|
|
<Input value={eventLocation} onChange={(e) => setEventLocation(e.target.value)} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="접수일" width={280}>
|
|
<CompactDateRange
|
|
from={startReceptionDate} setFrom={setStartReceptionDate}
|
|
to={endReceptionDate} setTo={setEndReceptionDate}
|
|
/>
|
|
</CompactFilterField>
|
|
</CompactFilterBar>
|
|
|
|
<DataGrid
|
|
gridId="customer-cs"
|
|
columns={GRID_COLUMNS}
|
|
data={rows}
|
|
loading={loading}
|
|
showCheckbox
|
|
checkedIds={selectedIds}
|
|
onCheckedChange={(ids) => {
|
|
setSelectedIds(ids);
|
|
if (ids.length === 0) { setSelected(null); return; }
|
|
const last = ids[ids.length - 1];
|
|
setSelected(rows.find((r) => r.objid === last) ?? null);
|
|
}}
|
|
selectedId={selected ? selected.objid : null}
|
|
onSelect={(id) => setSelected(rows.find((r) => r.objid === id) ?? null)}
|
|
onRowDoubleClick={(row) => { setSelected(row as CsItem); setDetailOpen(true); }}
|
|
summaryStats={summaryStats}
|
|
emptyMessage="조회된 고객 CS가 없습니다."
|
|
showColumnSettings
|
|
paginationStyle="range"
|
|
pageSizeOptions={[10, 15, 20, 50, 100]}
|
|
showChart
|
|
onRefresh={() => { loadList(); loadDash(); }}
|
|
onDownload={() => {
|
|
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
|
const exportRows = rows.map((r) => {
|
|
const out: Record<string, any> = {};
|
|
GRID_COLUMNS.forEach((c) => { out[c.label] = (r as any)[c.key] ?? ""; });
|
|
return out;
|
|
});
|
|
exportToExcel(exportRows, "고객CS관리.xlsx", "고객CS관리");
|
|
}}
|
|
/>
|
|
|
|
<CsFormModal
|
|
open={formOpen}
|
|
onClose={() => setFormOpen(false)}
|
|
onSaved={() => { setFormOpen(false); loadList(); loadDash(); }}
|
|
editing={selected}
|
|
mngTypeOpts={mngTypeOpts}
|
|
productDivOpts={productDivOpts}
|
|
performerOpts={performerOpts}
|
|
measureTypeOpts={measureTypeOpts}
|
|
statusOpts={statusOpts}
|
|
/>
|
|
<CsDetailModal open={detailOpen} onClose={() => setDetailOpen(false)} item={selected} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 보조 컴포넌트 ────────────────────────────────────────────
|
|
function CodeSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: Option[] }) {
|
|
return (
|
|
<Select value={value || "all"} onValueChange={(v) => onChange(v === "all" ? "" : v)}>
|
|
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
{options.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
// ── 등록/수정 모달 ───────────────────────────────────────────
|
|
function CsFormModal({
|
|
open, onClose, onSaved, editing, mngTypeOpts, productDivOpts, performerOpts, measureTypeOpts, statusOpts,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
editing: CsItem | null;
|
|
mngTypeOpts: Option[]; productDivOpts: Option[]; performerOpts: Option[]; measureTypeOpts: Option[]; statusOpts: Option[];
|
|
}) {
|
|
const isEdit = !!editing?.objid;
|
|
const [form, setForm] = useState({
|
|
title: "", customer_name: "", event_location: "",
|
|
mng_type: "", product_division: "", performer: "", measure_type: "", status: "",
|
|
reception_date: "", action_date: "",
|
|
analysis: "", measure: "", measure_amount: "",
|
|
project_objid: "",
|
|
});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setForm({
|
|
title: editing?.title || "",
|
|
customer_name: editing?.customer_name || "",
|
|
event_location: editing?.event_location || "",
|
|
mng_type: editing?.mng_type || "",
|
|
product_division: editing?.product_division || "",
|
|
performer: editing?.performer || "",
|
|
measure_type: editing?.measure_type || "",
|
|
status: editing?.status || (statusOpts[0]?.value ?? ""),
|
|
reception_date: editing?.reception_date_title || "",
|
|
action_date: editing?.action_date_title || "",
|
|
analysis: editing?.analysis || "",
|
|
measure: editing?.measure || "",
|
|
measure_amount: editing?.measure_amount || "",
|
|
project_objid: (editing as any)?.project_objid || "",
|
|
});
|
|
}
|
|
}, [open, editing, statusOpts]);
|
|
|
|
const setField = (k: keyof typeof form, v: string) => setForm((p) => ({ ...p, [k]: v }));
|
|
|
|
const handleSave = async () => {
|
|
if (!form.title.trim()) return toast.warning("제목을 입력하세요.");
|
|
setSaving(true);
|
|
try {
|
|
await customerCsApi.merge({ objId: editing?.objid, ...form });
|
|
toast.success("저장되었습니다.");
|
|
onSaved();
|
|
} catch (e: any) {
|
|
toast.error(e?.message || "저장 실패");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEdit ? `CS 수정 — ${editing?.mng_number}` : "CS 등록"}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
<Field label="CS 번호">
|
|
<Input value={editing?.mng_number || ""} placeholder={isEdit ? "" : "저장 시 자동 채번됩니다 (YYYY-NNN)"} disabled className="h-8 bg-muted text-xs" />
|
|
</Field>
|
|
<Field label="프로젝트">
|
|
<Input value={form.project_objid} onChange={(e) => setField("project_objid", e.target.value.replace(/[^\d]/g, ""))}
|
|
placeholder="프로젝트 OBJID (숫자)" inputMode="numeric" className="h-8 text-xs" />
|
|
</Field>
|
|
<Field label="관리유형 *"><CodeFormSelect value={form.mng_type} onChange={(v) => setField("mng_type", v)} options={mngTypeOpts} /></Field>
|
|
<Field label="제품구분"><CodeFormSelect value={form.product_division} onChange={(v) => setField("product_division", v)} options={productDivOpts} /></Field>
|
|
<Field label="고객명"><Input value={form.customer_name} onChange={(e) => setField("customer_name", e.target.value)} className="h-8 text-xs" /></Field>
|
|
<Field label="발생 장소"><Input value={form.event_location} onChange={(e) => setField("event_location", e.target.value)} className="h-8 text-xs" /></Field>
|
|
<Field label="접수일"><Input type="date" value={form.reception_date} onChange={(e) => setField("reception_date", e.target.value)} className="h-8 text-xs" /></Field>
|
|
<Field label="조치일"><Input type="date" value={form.action_date} onChange={(e) => setField("action_date", e.target.value)} className="h-8 text-xs" /></Field>
|
|
<Field label="담당자"><CodeFormSelect value={form.performer} onChange={(v) => setField("performer", v)} options={performerOpts} /></Field>
|
|
<Field label="상태 *"><CodeFormSelect value={form.status} onChange={(v) => setField("status", v)} options={statusOpts} /></Field>
|
|
<Field label="조치유형"><CodeFormSelect value={form.measure_type} onChange={(v) => setField("measure_type", v)} options={measureTypeOpts} /></Field>
|
|
<Field label="조치 금액 (원)"><Input value={form.measure_amount} onChange={(e) => setField("measure_amount", e.target.value.replace(/[^\d]/g, ""))} className="h-8 text-xs" inputMode="numeric" /></Field>
|
|
<div className="col-span-2"><Field label="제목 *">
|
|
<Input value={form.title} onChange={(e) => setField("title", e.target.value)} className="h-8 text-xs" />
|
|
</Field></div>
|
|
<div className="col-span-2"><Field label="현상 분석">
|
|
<Textarea value={form.analysis} onChange={(e) => setField("analysis", e.target.value)} className="min-h-[80px] text-xs" rows={4} />
|
|
</Field></div>
|
|
<div className="col-span-2"><Field label="조치 내용">
|
|
<Textarea value={form.measure} onChange={(e) => setField("measure", e.target.value)} className="min-h-[80px] text-xs" rows={4} />
|
|
</Field></div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={onClose} disabled={saving}>취소</Button>
|
|
<Button size="sm" className="h-8 text-xs" onClick={handleSave} disabled={saving}>{saving ? "저장 중..." : "저장"}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label className="text-[11px]">{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CodeFormSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: Option[] }) {
|
|
return (
|
|
<Select value={value || "none"} onValueChange={(v) => onChange(v === "none" ? "" : v)}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">선택 안 함</SelectItem>
|
|
{options.map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
// ── 상세 모달 ────────────────────────────────────────────────
|
|
function CsDetailModal({ open, onClose, item }: { open: boolean; onClose: () => void; item: CsItem | null }) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>CS 상세 — {item?.mng_number}</DialogTitle>
|
|
</DialogHeader>
|
|
{item && (
|
|
<div className="space-y-2 text-xs">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<DField label="CS 번호" value={item.mng_number} />
|
|
<DField label="상태" value={item.status_title} />
|
|
<DField label="관리유형" value={item.mng_type_title} />
|
|
<DField label="제품구분" value={item.product_division_title} />
|
|
<DField label="고객명" value={item.customer_name} />
|
|
<DField label="발생 장소" value={item.event_location} />
|
|
<DField label="접수일" value={item.reception_date_title} />
|
|
<DField label="조치일" value={item.action_date_title} />
|
|
<DField label="담당자" value={item.performer_title} />
|
|
<DField label="조치유형" value={item.measure_type_title} />
|
|
<DField label="조치 금액" value={item.measure_amount && /^\d+$/.test(item.measure_amount) ? `${Number(item.measure_amount).toLocaleString()}원` : (item.measure_amount || "-")} />
|
|
<DField label="작성자" value={item.writer_title} />
|
|
</div>
|
|
<DField label="제목" value={item.title} block />
|
|
<DField label="현상 분석" value={item.analysis || "-"} block />
|
|
<DField label="조치 내용" value={item.measure || "-"} block />
|
|
<DField label="등록일/수정일" value={`${item.regdate_title || "-"} / ${item.editdate_title || "-"}`} />
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={onClose}>닫기</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function DField({ label, value, block }: { label: string; value?: React.ReactNode; block?: boolean }) {
|
|
return (
|
|
<div className={cn("rounded border bg-muted/20 p-1.5", block && "col-span-3")}>
|
|
<div className="text-[10px] font-medium uppercase text-muted-foreground">{label}</div>
|
|
<div className={cn("mt-0.5 text-xs", block && "whitespace-pre-wrap")}>{value || "-"}</div>
|
|
</div>
|
|
);
|
|
}
|