Files
wace_rps/frontend/app/(main)/COMPANY_16/sales/sale/page.tsx
T
hjjeong 4175ae77c1 영업관리 wace 컬럼 정합성 강화 + project_mgmt 도입
- project_mgmt 테이블 신규(75컬럼/89건) — wace 운영 동일 schema 추출. 판매·매출관리 메인 테이블
- 판매·매출 SQL을 contract_item 기반 → project_mgmt 메인 + sales_registration LEFT JOIN으로 전면 재작성
  * 요청납기 COALESCE(CI.due_date, T.due_date, CM.due_date) 패턴
  * 환종/serial_no는 sales_registration 우선, contract_item_serial 집계 fallback
  * 판매상태 wace 로직 한글 라벨(미판매/완판/분할판매) 동적 계산
  * 매출관리는 shippingDateRequired EXISTS 필터로 출하 등록된 프로젝트만 표시
- 주문서: order_date 매핑 CONTRACT_DATE → ORDER_DATE 정정, 원화총액·고객사요청사항 SQL 추가
- 견적/주문/판매 page.tsx에서 wace 블록 주석 컬럼 제거(제품구분/국내·해외/접수일/반납사유 등 14개)
- 환종 raw 코드 → contract_currency_name 한글명 바인딩으로 정정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:50:45 +09:00

241 lines
14 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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, Truck } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale";
// wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
{ key: "request_date", label: "요청납기", width: "w-[100px]", align: "center" },
{ key: "shipping_date", label: "출하일", width: "w-[100px]", align: "center" },
{ key: "customer", label: "고객사", width: "w-[160px]" },
{ key: "product_name", label: "품명", width: "w-[180px]" },
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "sales_quantity", label: "판매수량", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "remaining_quantity", label: "잔량", width: "w-[80px]", align: "right", formatNumber: true },
{ key: "sales_unit_price", label: "판매단가", width: "w-[110px]", formatMoney: true },
{ key: "sales_supply_price", label: "판매공급가액", width: "w-[130px]", formatMoney: true },
{ key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true },
{ key: "sales_total_amount", label: "판매총액", width: "w-[120px]", formatMoney: true },
{ key: "sales_total_amount_krw", label: "판매원화총액", width: "w-[130px]", formatMoney: true },
{ key: "remaining_amount_krw", label: "잔량원화총액", width: "w-[130px]", formatMoney: true },
{ key: "order_status_name", label: "수주상태", width: "w-[90px]", align: "center" },
{ key: "sales_status", label: "판매상태", width: "w-[100px]", align: "center" },
{ key: "production_status", label: "생산상태", width: "w-[100px]", align: "center" },
{ key: "shipping_order_status", label: "출하지시상태", width: "w-[110px]", align: "center" },
{ key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
{ key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" },
{ key: "sales_exchange_rate", label: "환율", width: "w-[80px]", 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: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
];
export default function SalesSalePage() {
const { user } = useAuth();
const [rows, setRows] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<SaleListRow | null>(null);
const [searchForm, setSearchForm] = useState({
poNo: "", customer_objid: "", serialNo: "", shippingStatus: "",
orderDateFrom: "", orderDateTo: "",
});
const [registerOpen, setRegisterOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<SaleRegisterBody>({
project_no: "", sales_currency: "KRW", sales_exchange_rate: 1,
});
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.saleList(params);
const mapped = data.map((r, i) => ({ ...r, id: `${r.project_no}-${r.contract_item_objid}-${i}` }));
setRows(mapped);
} catch { toast.error("판매 목록 조회 실패"); }
finally { setLoading(false); }
}, [user, searchForm]);
useEffect(() => { fetchList(); }, [fetchList]);
const openRegister = () => {
if (!selected) { toast.warning("판매등록할 행을 선택하세요."); return; }
setForm({
project_no: selected.project_no,
shipping_order_status: selected.shipping_order_status ?? "PENDING",
serial_no: selected.serial_no ?? "",
sales_quantity: Number(selected.sales_quantity ?? selected.order_quantity ?? 0),
sales_unit_price: Number(selected.sales_unit_price ?? 0),
sales_supply_price: Number(selected.sales_supply_price ?? 0),
sales_vat: Number(selected.sales_vat ?? 0),
sales_total_amount: Number(selected.sales_total_amount ?? 0),
sales_currency: selected.sales_currency ?? "KRW",
sales_exchange_rate: Number(selected.sales_exchange_rate ?? 1),
shipping_date: selected.shipping_date ?? "",
shipping_method: selected.shipping_method ?? "",
manager_user_id: selected.manager_user_id ?? "",
incoterms: selected.incoterms ?? "",
has_split_shipment: selected.has_split_shipment ?? false,
});
setRegisterOpen(true);
};
const handleRegister = async () => {
setSaving(true);
try {
// 자동 계산
const qty = Number(form.sales_quantity ?? 0);
const price = Number(form.sales_unit_price ?? 0);
const supply = form.sales_supply_price && form.sales_supply_price > 0 ? form.sales_supply_price : qty * price;
const vat = form.sales_vat && form.sales_vat > 0 ? form.sales_vat : Math.round(supply * 0.1 * 100) / 100;
const total = form.sales_total_amount && form.sales_total_amount > 0 ? form.sales_total_amount : supply + vat;
await salesSaleApi.registerSale({ ...form, sales_supply_price: supply, sales_vat: vat, sales_total_amount: total });
toast.success("판매가 등록되었습니다.");
setRegisterOpen(false);
await fetchList();
} catch (err: any) {
toast.error(`저장 실패: ${err?.response?.data?.message ?? err.message}`);
} finally { setSaving(false); }
};
return (
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="text-sm text-muted-foreground"> {rows.length} ( )</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={openRegister} disabled={!selected}>
<Truck className="w-4 h-4 mr-1" />/
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 border rounded-md bg-muted/30">
<div><Label className="text-xs"></Label>
<Input className="h-9" value={searchForm.poNo} onChange={(e) => setSearchForm({ ...searchForm, poNo: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input className="h-9" value={searchForm.serialNo} onChange={(e) => setSearchForm({ ...searchForm, serialNo: e.target.value })} /></div>
<div className="flex gap-1 items-end">
<div className="flex-1"><Label className="text-xs"> ~</Label>
<Input type="date" className="h-9" value={searchForm.orderDateFrom} onChange={(e) => setSearchForm({ ...searchForm, orderDateFrom: e.target.value })} /></div>
<div className="flex-1"><Label className="text-xs">~</Label>
<Input type="date" className="h-9" value={searchForm.orderDateTo} onChange={(e) => setSearchForm({ ...searchForm, orderDateTo: e.target.value })} /></div>
</div>
<div><Label className="text-xs"></Label>
<Select value={searchForm.shippingStatus || "all"}
onValueChange={(v) => setSearchForm({ ...searchForm, shippingStatus: v === "all" ? "" : v })}>
<SelectTrigger className="h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="PENDING"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
<SelectItem value="CANCELLED"></SelectItem>
</SelectContent>
</Select></div>
</div>
<DataGrid
columns={GRID_COLUMNS}
data={rows}
selectedId={selected ? `${selected.project_no}-${selected.contract_item_objid}-0` : null}
onSelect={(id) => setSelected(id ? rows.find((r) => r.id === id) ?? null : null)}
onRowDoubleClick={(row) => { setSelected(row); openRegister(); }}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
loading={loading}
/>
{/* 출하지시/판매등록 Dialog */}
<Dialog open={registerOpen} onOpenChange={setRegisterOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> / </DialogTitle>
<DialogDescription>{selected?.contract_no} · {selected?.product_name}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-3">
<div><Label className="text-xs"></Label>
<Input type="number" value={form.sales_quantity ?? 0}
onChange={(e) => setForm({ ...form, sales_quantity: Number(e.target.value) })} /></div>
<div><Label className="text-xs"></Label>
<Input type="number" step="0.01" value={form.sales_unit_price ?? 0}
onChange={(e) => setForm({ ...form, sales_unit_price: Number(e.target.value) })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.sales_currency ?? "KRW"} onChange={(e) => setForm({ ...form, sales_currency: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input type="number" step="0.0001" value={form.sales_exchange_rate ?? 1}
onChange={(e) => setForm({ ...form, sales_exchange_rate: Number(e.target.value) })} /></div>
<div><Label className="text-xs"> ()</Label>
<Input type="number" step="0.01" value={form.sales_supply_price ?? 0}
onChange={(e) => setForm({ ...form, sales_supply_price: Number(e.target.value) })} /></div>
<div><Label className="text-xs"> ( 10%)</Label>
<Input type="number" step="0.01" value={form.sales_vat ?? 0}
onChange={(e) => setForm({ ...form, sales_vat: Number(e.target.value) })} /></div>
<div><Label className="text-xs"> ()</Label>
<Input type="number" step="0.01" value={form.sales_total_amount ?? 0}
onChange={(e) => setForm({ ...form, sales_total_amount: Number(e.target.value) })} /></div>
<div><Label className="text-xs"></Label>
<Input type="date" value={form.shipping_date ?? ""} onChange={(e) => setForm({ ...form, shipping_date: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Select value={form.shipping_method ?? ""} onValueChange={(v) => setForm({ ...form, shipping_method: v })}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="DIRECT"></SelectItem>
<SelectItem value="PARCEL"></SelectItem>
</SelectContent>
</Select></div>
<div><Label className="text-xs"> </Label>
<Select value={form.shipping_order_status ?? "PENDING"} onValueChange={(v) => setForm({ ...form, shipping_order_status: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="PENDING"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
<SelectItem value="CANCELLED"></SelectItem>
</SelectContent>
</Select></div>
<div><Label className="text-xs"> ID</Label>
<Input value={form.manager_user_id ?? ""} onChange={(e) => setForm({ ...form, manager_user_id: e.target.value })} /></div>
<div><Label className="text-xs"></Label>
<Input value={form.incoterms ?? ""} onChange={(e) => setForm({ ...form, incoterms: e.target.value })} placeholder="EXW/FOB/CIF" /></div>
<div className="col-span-3"><Label className="text-xs"> ( )</Label>
<Input value={form.serial_no ?? ""} onChange={(e) => setForm({ ...form, serial_no: e.target.value })} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegisterOpen(false)} disabled={saving}></Button>
<Button onClick={handleRegister} 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>
);
}