fc959d8872
견적관리(36c1f357)와 동일한 패턴을 나머지 3개 메뉴로 확장:
공통 DataGrid props:
- gridId (sales-order / sales-sale / sales-revenue) 로 컬럼 visibility·순서·너비·차트 영속
- showColumnSettings, paginationStyle="range", pageSizeOptions=[10,15,20,50,100]
- onRefresh = fetchList, onDownload = exportToExcel(GRID_COLUMNS 라벨 매핑)
- showChart
도메인별 summaryStats (하단 통계 행):
- 주문: 수주 건수 / 수주수량·수주취소 합계 / 공급가액·부가세·총액·원화총액 합계
- 판매: 판매 라인 / 수주·판매·잔량 수량 합계 / 판매공급가액·판매원화·잔량원화 합계
- 매출: 매출 이력 / 수량 합계 / 공급가액·부가세·총액·원화총액 합계
컬럼 폭 보정:
- ⋮⋮ 드래그 핸들 추가로 좁아진 4글자 한국어 라벨이 잘리지 않도록 95~135px로 확대
- 시스템 컬럼: 주문관리 writer_name (systemColumnKeys 분리)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
375 lines
19 KiB
TypeScript
375 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Save, Loader2, Search, FileCheck2, CheckCircle2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
|
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
|
import { PartSelect } from "@/components/common/PartSelect";
|
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
|
import { PageHeader } from "@/components/common/PageHeader";
|
|
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
|
|
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
|
|
import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesSale";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
|
|
// RevenueListRow → 정규화된 ProjectInfoData (wace 운영판 다이얼로그 1:1)
|
|
const toProjectInfo = (r: RevenueListRow): ProjectInfoData => ({
|
|
orderType: r.order_type_name,
|
|
productType: r.product_type_name,
|
|
area: r.nation_name,
|
|
customer: r.customer,
|
|
paidType: r.payment_type_name,
|
|
regDate: r.receipt_date,
|
|
currency: r.contract_currency_name,
|
|
exchangeRate: r.exchange_rate,
|
|
partNo: r.product_no,
|
|
partName: r.product_name,
|
|
serialNo: r.serial_no,
|
|
reqDelDate: r.request_date,
|
|
customerRequest: r.customer_request,
|
|
returnReason: r.return_reason_name,
|
|
});
|
|
|
|
// wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
|
const GRID_COLUMNS: DataGridColumn[] = [
|
|
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
|
|
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
|
|
{ key: "sales_deadline_date", label: "매출마감", width: "w-[115px]", align: "center" },
|
|
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
|
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
|
|
{ key: "customer", label: "고객사", width: "w-[160px]" },
|
|
{ key: "product_type_name", label: "제품구분", width: "w-[115px]", align: "center" },
|
|
{ key: "product_name", label: "품명", width: "w-[180px]" },
|
|
{ key: "sales_quantity", label: "수량", width: "w-[95px]", align: "right", formatNumber: true },
|
|
{ key: "sales_unit_price", label: "단가", width: "w-[115px]", formatMoney: true },
|
|
{ key: "sales_supply_price", label: "공급가액", width: "w-[130px]", formatMoney: true },
|
|
{ key: "sales_vat", label: "부가세", width: "w-[115px]", formatMoney: true },
|
|
{ key: "sales_total_amount", label: "총액", width: "w-[120px]", formatMoney: true },
|
|
{ key: "sales_total_amount_krw", label: "원화총액", width: "w-[130px]", formatMoney: true },
|
|
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
|
|
{ key: "nation_name", label: "국내/해외", width: "w-[115px]", align: "center" },
|
|
{ key: "sales_currency_name", label: "환종", width: "w-[95px]", align: "center" },
|
|
{ key: "sales_exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
|
|
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
|
{ key: "split_serial_no", label: "분할S/N", width: "w-[140px]" },
|
|
{ key: "product_no", label: "품번", width: "w-[120px]" },
|
|
{ key: "tax_type", label: "과세구분", width: "w-[115px]", align: "center" },
|
|
{ key: "tax_invoice_date", label: "세금계산서발행일", width: "w-[160px]", align: "center" },
|
|
{ key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[180px]" },
|
|
{ key: "loading_date", label: "선적일자", width: "w-[115px]", align: "center" },
|
|
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[115px]", align: "center" },
|
|
/* wace revenueMgmtList.jsp 615~632 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
|
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
|
{ key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
|
{ key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" },
|
|
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
|
|
{ key: "order_status_name", label: "수주상태", width: "w-[90px]", align: "center" },
|
|
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
|
|
{ key: "shipping_method", label: "출하방법", width: "w-[90px]", align: "center" },
|
|
{ key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" },
|
|
{ key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" },
|
|
*/
|
|
];
|
|
|
|
export default function SalesRevenuePage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
|
|
const [rows, setRows] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selected, setSelected] = useState<RevenueListRow | null>(null);
|
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
|
|
|
// 프로젝트번호 셀 클릭 → 프로젝트 상세 정보 다이얼로그 (wace 운영판 1:1)
|
|
const [infoOpen, setInfoOpen] = useState(false);
|
|
const [infoData, setInfoData] = useState<ProjectInfoData | null>(null);
|
|
const columns = useMemo(
|
|
() => GRID_COLUMNS.map((col) =>
|
|
col.key === "project_no"
|
|
? { ...col, onClick: (row: any) => { setInfoData(toProjectInfo(row as RevenueListRow)); setInfoOpen(true); } }
|
|
: col,
|
|
),
|
|
[],
|
|
);
|
|
// wace revenueMgmtList.jsp 활성 11개
|
|
const [searchForm, setSearchForm] = useState({
|
|
orderType: "", poNo: "", customer_objid: "",
|
|
productType: "", search_partObjId: "", nation: "",
|
|
serialNo: "",
|
|
salesDeadlineFrom: "", salesDeadlineTo: "",
|
|
orderDateFrom: "", orderDateTo: "",
|
|
shippingDateFrom: "", shippingDateTo: "",
|
|
});
|
|
|
|
const [deadlineOpen, setDeadlineOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState<DeadlineInfoBody>({
|
|
parent_sale_no: 0, target_objid: "",
|
|
});
|
|
|
|
const fetchList = useCallback(async () => {
|
|
if (!user) return;
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
Object.entries(searchForm).forEach(([k, v]) => { if (v) params[k] = v; });
|
|
const data = await salesSaleApi.revenueList(params);
|
|
const mapped = data.map((r) => ({ ...r, id: String(r.log_id) }));
|
|
setRows(mapped);
|
|
} catch { toast.error("매출 목록 조회 실패"); }
|
|
finally { setLoading(false); }
|
|
}, [user, searchForm]);
|
|
|
|
useEffect(() => { fetchList(); }, [fetchList]);
|
|
|
|
// ─── 하단 통계 ──────────────────────────────────────────────
|
|
// 매출 이력 건수 / 수량 합계 / 공급가액·부가세·총액·원화총액 합계
|
|
const revenueSummary = useMemo(() => {
|
|
const count = rows.length;
|
|
const qtySum = rows.reduce((acc, r) => acc + Number(r.sales_quantity || 0), 0);
|
|
const supplySum = rows.reduce((acc, r) => acc + Number(r.sales_supply_price || 0), 0);
|
|
const vatSum = rows.reduce((acc, r) => acc + Number(r.sales_vat || 0), 0);
|
|
const totalSum = rows.reduce((acc, r) => acc + Number(r.sales_total_amount || 0), 0);
|
|
const krwSum = rows.reduce((acc, r) => acc + Number(r.sales_total_amount_krw || 0), 0);
|
|
const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
const intFmt = (n: number) => n.toLocaleString();
|
|
return [
|
|
{ label: "매출 이력", value: intFmt(count), suffix: "건" },
|
|
{ label: "수량 합계", value: intFmt(qtySum) },
|
|
{ label: "공급가액 합계", value: money(supplySum) },
|
|
{ label: "부가세 합계", value: money(vatSum) },
|
|
{ label: "총액 합계", value: money(totalSum) },
|
|
{ label: "원화총액 합계", value: money(krwSum) },
|
|
];
|
|
}, [rows]);
|
|
|
|
const openDeadline = () => {
|
|
if (!selected) { toast.warning("마감정보를 입력할 행을 선택하세요."); return; }
|
|
setForm({
|
|
log_id: selected.log_id,
|
|
parent_sale_no: selected.parent_sale_no ?? 0,
|
|
target_objid: selected.target_objid,
|
|
sales_deadline_date: selected.sales_deadline_date ?? "",
|
|
tax_type: selected.tax_type ?? "",
|
|
tax_invoice_date: selected.tax_invoice_date ?? "",
|
|
export_decl_no: selected.export_decl_no ?? "",
|
|
loading_date: selected.loading_date ?? "",
|
|
sales_slip_date: selected.sales_slip_date ?? "",
|
|
sales_slip_menu_sq: selected.sales_slip_menu_sq ?? undefined,
|
|
remark: selected.remark ?? "",
|
|
});
|
|
setDeadlineOpen(true);
|
|
};
|
|
|
|
const handleSaveDeadline = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await salesSaleApi.saveDeadlineInfo(form);
|
|
toast.success("마감정보가 저장되었습니다.");
|
|
setDeadlineOpen(false);
|
|
await fetchList();
|
|
} catch (err: any) {
|
|
toast.error(`저장 실패: ${err?.response?.data?.message ?? err.message}`);
|
|
} finally { setSaving(false); }
|
|
};
|
|
|
|
const handleConfirmDeadline = async () => {
|
|
if (checkedIds.length === 0) { toast.warning("매출 마감할 행을 체크하세요."); return; }
|
|
const ok = await confirm("매출 마감 확정", { description: `${checkedIds.length}건의 매출을 마감 확정하시겠습니까?`, variant: "destructive" });
|
|
if (!ok) return;
|
|
try {
|
|
await salesSaleApi.confirmSalesDeadline(checkedIds.map((id) => Number(id)));
|
|
toast.success("매출 마감이 확정되었습니다.");
|
|
setCheckedIds([]);
|
|
await fetchList();
|
|
} catch (err: any) {
|
|
toast.error(err?.response?.data?.message ?? err.message);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => setSearchForm({
|
|
orderType: "", poNo: "", customer_objid: "",
|
|
productType: "", search_partObjId: "", nation: "",
|
|
serialNo: "",
|
|
salesDeadlineFrom: "", salesDeadlineTo: "",
|
|
orderDateFrom: "", orderDateTo: "",
|
|
shippingDateFrom: "", shippingDateTo: "",
|
|
});
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
|
{ConfirmDialogComponent}
|
|
|
|
<PageHeader
|
|
loading={loading}
|
|
onSearch={fetchList}
|
|
onReset={handleReset}
|
|
actions={
|
|
<>
|
|
<Button size="sm" className="h-8 gap-1 bg-blue-600 hover:bg-blue-700 text-white text-xs" onClick={openDeadline} disabled={!selected}>
|
|
<FileCheck2 className="h-3.5 w-3.5" />마감정보입력
|
|
</Button>
|
|
<Button size="sm" className="h-8 gap-1 bg-emerald-600 hover:bg-emerald-700 text-white text-xs" onClick={handleConfirmDeadline} disabled={checkedIds.length === 0}>
|
|
<CheckCircle2 className="h-3.5 w-3.5" />매출마감
|
|
</Button>
|
|
</>
|
|
} />
|
|
|
|
<CompactFilterBar totalText={<>총 {rows.length.toLocaleString()}건 (출하/매출 이력)</>}>
|
|
<CompactFilterField label="주문유형" width={130}>
|
|
<CommCodeSelect groupId="0000167"
|
|
value={searchForm.orderType}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, orderType: v })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="발주번호" width={140}>
|
|
<Input placeholder="발주번호 검색"
|
|
value={searchForm.poNo}
|
|
onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="고객사" width={160}>
|
|
<CustomerSelect
|
|
value={searchForm.customer_objid}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="제품구분" width={130}>
|
|
<CommCodeSelect groupId="0000001"
|
|
value={searchForm.productType}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, productType: v })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="품번" width={130}>
|
|
<PartSelect mode="partNo"
|
|
value={searchForm.search_partObjId}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="품명" width={150}>
|
|
<PartSelect mode="partName"
|
|
value={searchForm.search_partObjId}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="국내/해외" width={120}>
|
|
<CommCodeSelect groupId="0001219"
|
|
value={searchForm.nation}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, nation: v })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="S/N" width={120}>
|
|
<Input value={searchForm.serialNo}
|
|
onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} />
|
|
</CompactFilterField>
|
|
<CompactFilterField label="매출마감" width={280}>
|
|
<CompactDateRange
|
|
from={searchForm.salesDeadlineFrom}
|
|
setFrom={(v) => setSearchForm({ ...searchForm, salesDeadlineFrom: v })}
|
|
to={searchForm.salesDeadlineTo}
|
|
setTo={(v) => setSearchForm({ ...searchForm, salesDeadlineTo: v })}
|
|
/>
|
|
</CompactFilterField>
|
|
<CompactFilterField label="발주일" width={280}>
|
|
<CompactDateRange
|
|
from={searchForm.orderDateFrom}
|
|
setFrom={(v) => setSearchForm({ ...searchForm, orderDateFrom: v })}
|
|
to={searchForm.orderDateTo}
|
|
setTo={(v) => setSearchForm({ ...searchForm, orderDateTo: v })}
|
|
/>
|
|
</CompactFilterField>
|
|
<CompactFilterField label="출하일" width={280}>
|
|
<CompactDateRange
|
|
from={searchForm.shippingDateFrom}
|
|
setFrom={(v) => setSearchForm({ ...searchForm, shippingDateFrom: v })}
|
|
to={searchForm.shippingDateTo}
|
|
setTo={(v) => setSearchForm({ ...searchForm, shippingDateTo: v })}
|
|
/>
|
|
</CompactFilterField>
|
|
</CompactFilterBar>
|
|
|
|
<DataGrid
|
|
gridId="sales-revenue"
|
|
columns={columns}
|
|
data={rows}
|
|
selectedId={selected ? String(selected.log_id) : null}
|
|
onSelect={(id) => setSelected(id ? rows.find((r) => r.id === id) ?? null : null)}
|
|
showCheckbox
|
|
checkedIds={checkedIds}
|
|
onCheckedChange={setCheckedIds}
|
|
onRowDoubleClick={(row) => { setSelected(row); openDeadline(); }}
|
|
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
|
loading={loading}
|
|
showColumnSettings
|
|
paginationStyle="range"
|
|
pageSizeOptions={[10, 15, 20, 50, 100]}
|
|
summaryStats={revenueSummary}
|
|
onRefresh={fetchList}
|
|
onDownload={() => {
|
|
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
|
const exportRows = rows.map((r) => {
|
|
const out: Record<string, any> = {};
|
|
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
|
return out;
|
|
});
|
|
exportToExcel(exportRows, "매출관리.xlsx", "매출관리");
|
|
}}
|
|
showChart
|
|
/>
|
|
|
|
<ProjectInfoDialog open={infoOpen} onOpenChange={setInfoOpen} data={infoData} />
|
|
|
|
{/* 마감정보 입력 Dialog */}
|
|
<Dialog open={deadlineOpen} onOpenChange={setDeadlineOpen}>
|
|
<DialogContent className="max-w-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle>매출 마감정보 입력</DialogTitle>
|
|
<DialogDescription>
|
|
영업번호 {selected?.contract_no} · 출하일 {selected?.shipping_date}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div><Label className="text-xs">매출 마감일</Label>
|
|
<Input type="date" value={form.sales_deadline_date ?? ""} onChange={(e) => setForm({ ...form, sales_deadline_date: e.target.value })} /></div>
|
|
<div><Label className="text-xs">과세구분</Label>
|
|
<Select value={form.tax_type || ""} onValueChange={(v) => setForm({ ...form, tax_type: v })}>
|
|
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="TAXABLE">과세</SelectItem>
|
|
<SelectItem value="ZERO_RATE">영세</SelectItem>
|
|
<SelectItem value="EXEMPT">면세</SelectItem>
|
|
</SelectContent>
|
|
</Select></div>
|
|
<div><Label className="text-xs">세금계산서 발행일</Label>
|
|
<Input type="date" value={form.tax_invoice_date ?? ""} onChange={(e) => setForm({ ...form, tax_invoice_date: e.target.value })} /></div>
|
|
<div><Label className="text-xs">수출신고필증 번호</Label>
|
|
<Input value={form.export_decl_no ?? ""} onChange={(e) => setForm({ ...form, export_decl_no: e.target.value })} /></div>
|
|
<div><Label className="text-xs">선적일자</Label>
|
|
<Input type="date" value={form.loading_date ?? ""} onChange={(e) => setForm({ ...form, loading_date: e.target.value })} /></div>
|
|
<div><Label className="text-xs">매출전표일</Label>
|
|
<Input type="date" value={form.sales_slip_date ?? ""} onChange={(e) => setForm({ ...form, sales_slip_date: e.target.value })} /></div>
|
|
<div><Label className="text-xs">매출전표 menu_sq</Label>
|
|
<Input type="number" value={form.sales_slip_menu_sq ?? ""} onChange={(e) => setForm({ ...form, sales_slip_menu_sq: e.target.value ? Number(e.target.value) : undefined })} /></div>
|
|
<div className="col-span-3"><Label className="text-xs">비고</Label>
|
|
<Textarea rows={2} value={form.remark ?? ""} onChange={(e) => setForm({ ...form, remark: e.target.value })} /></div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeadlineOpen(false)} disabled={saving}>취소</Button>
|
|
<Button onClick={handleSaveDeadline} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|