Merge pull request 'hjjeong' (#7) from hjjeong into main
Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/7
This commit is contained in:
@@ -18,7 +18,7 @@ let inflight: Promise<SmartSelectOption[]> | null = null;
|
||||
|
||||
// 운영 wace 데이터: contract_mgmt.customer_objid = 'C_' + customer_mng.customer_code
|
||||
// (이전엔 customer_mng.id padded로 매핑했으나 운영 데이터와 어긋났음 — 26C-0801 라온기술/정림유리 미스매치 사례)
|
||||
const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
export const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
if (cached) return cached;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
|
||||
@@ -104,6 +104,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_16/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_16/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/project/progress": dynamic(() => import("@/app/(main)/COMPANY_16/project/progress/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EstimateMailDialog — wace estimateMailFormPopup.jsp 1:1 이식
|
||||
*
|
||||
* 견적관리 그리드 "메일발송" 버튼에서 호출. 발송 흐름:
|
||||
* 1. open 시 mail-info API로 고객사/작성자 자동 채움 (제목/contents 템플릿/cc=writer_email)
|
||||
* 2. 고객사 담당자 체크박스 리스트 표시 → 체크 시 toEmails에 자동 추가
|
||||
* 3. "발송" 클릭 →
|
||||
* - hasBaseEst='N' && hasAddEst='Y': useAddEstOnly='Y'로 API 호출 (PDF 생성 스킵)
|
||||
* - 그 외: 최신 차수 template1/template2 페이지를 hidden iframe으로 렌더 → fn_generateAndUploadPdf로 base64 추출 → API 호출
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Loader2, Send } from "lucide-react";
|
||||
import { salesEstimateApi, EstimateTemplateRow } from "@/lib/api/salesEstimate";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface EstimateMailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** contract_mgmt.objid (영업번호의 헤더 ID) */
|
||||
contractObjid: string | null;
|
||||
/** 그리드의 추가견적(estimate02) 카운트 — hasAddEst 분기에 사용 */
|
||||
addEstCount?: number;
|
||||
/** 그리드의 견적 차수 카운트 — hasBaseEst 분기에 사용 (0이면 견적서 없음) */
|
||||
estStatusCount?: number;
|
||||
/** 발송 완료 후 그리드 갱신 콜백 */
|
||||
onSent?: () => void;
|
||||
}
|
||||
|
||||
interface Manager {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
department: string;
|
||||
is_main: string;
|
||||
}
|
||||
|
||||
export function EstimateMailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
contractObjid,
|
||||
addEstCount = 0,
|
||||
estStatusCount = 0,
|
||||
onSent,
|
||||
}: EstimateMailDialogProps) {
|
||||
const hasAddEst = addEstCount > 0;
|
||||
const hasBaseEst = estStatusCount > 0;
|
||||
// 추가견적만 있는 경우: PDF 생성 스킵
|
||||
const useAddEstOnly = !hasBaseEst && hasAddEst;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [managers, setManagers] = useState<Manager[]>([]);
|
||||
const [checkedEmails, setCheckedEmails] = useState<Record<string, boolean>>({});
|
||||
const [form, setForm] = useState({
|
||||
toEmails: "",
|
||||
ccEmails: "",
|
||||
subject: "",
|
||||
contents: "",
|
||||
});
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
// ─── open 시 mail-info + 담당자 자동 로드 ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open || !contractObjid) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setManagers([]);
|
||||
setCheckedEmails({});
|
||||
setForm({ toEmails: "", ccEmails: "", subject: "", contents: "" });
|
||||
try {
|
||||
const info = await salesEstimateApi.getMailInfo(contractObjid);
|
||||
if (cancelled || !info) return;
|
||||
|
||||
const customerName = info.customer_name ?? "";
|
||||
const contractNo = info.contract_no ?? "";
|
||||
// wace estimateMailFormPopup.jsp fn_generateMailTemplate 1:1
|
||||
const template =
|
||||
`안녕하세요.\n\n` +
|
||||
`${customerName} 귀하께서 요청하신 견적서를 첨부파일로 송부드립니다.\n\n` +
|
||||
`영업번호: ${contractNo}\n\n` +
|
||||
`첨부된 견적서를 검토하신 후 문의사항이 있으시면 연락 주시기 바랍니다.\n\n` +
|
||||
`감사합니다.\n`;
|
||||
|
||||
setForm({
|
||||
toEmails: info.customer_email ?? "",
|
||||
ccEmails: info.writer_email ?? "",
|
||||
subject: `[${customerName}] ${contractNo} 견적서`,
|
||||
contents: template,
|
||||
});
|
||||
|
||||
// 고객사 담당자 리스트 (별도 API)
|
||||
if (info.customer_objid) {
|
||||
try {
|
||||
const mgrs = await salesEstimateApi.getMailManagers(info.customer_objid);
|
||||
if (!cancelled) setManagers(mgrs);
|
||||
} catch {
|
||||
// 담당자 조회 실패는 무시 — 수신인 직접 입력 가능
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error("계약 정보를 불러올 수 없습니다: " + (e?.message ?? ""));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, contractObjid]);
|
||||
|
||||
// ─── 담당자 체크박스 → toEmails 자동 추가 ─────────────────────────────────
|
||||
function toggleManager(email: string, checked: boolean) {
|
||||
setCheckedEmails((prev) => ({ ...prev, [email]: checked }));
|
||||
setForm((prev) => {
|
||||
const current = prev.toEmails
|
||||
.split(/[,;]/)
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
if (checked && !current.includes(email)) {
|
||||
return { ...prev, toEmails: current.concat(email).join(", ") };
|
||||
}
|
||||
if (!checked && current.includes(email)) {
|
||||
return { ...prev, toEmails: current.filter((e) => e !== email).join(", ") };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 최신 차수 견적서를 hidden iframe으로 렌더 → PDF base64 추출 ───────────
|
||||
const generatePdfBase64 = useCallback(async (latest: EstimateTemplateRow): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const templateType = latest.template_type === "2" ? "2" : "1";
|
||||
const url = `/COMPANY_16/sales/estimate/template${templateType}/pop/${encodeURIComponent(
|
||||
contractObjid!,
|
||||
)}?templateObjid=${encodeURIComponent(latest.objid)}`;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText =
|
||||
"position:absolute;left:-9999px;top:-9999px;width:900px;height:1200px;border:none;";
|
||||
iframe.src = url;
|
||||
iframeRef.current = iframe;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 1200; // 100ms × 1200 = 120초
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
|
||||
} catch {}
|
||||
iframeRef.current = null;
|
||||
};
|
||||
|
||||
// 데이터 로딩 완료 대기 (wace estimateMailFormPopup.jsp의 dataLoaded 폴링과 동일)
|
||||
const checkDataLoaded = () => {
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) {
|
||||
cleanup();
|
||||
reject(new Error("견적서 데이터 로딩 시간이 초과되었습니다. (120초)"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const w = iframe.contentWindow as any;
|
||||
if (w && w.dataLoaded === true && typeof w.fn_generateAndUploadPdf === "function") {
|
||||
w.fn_generateAndUploadPdf((pdfBase64: string) => {
|
||||
cleanup();
|
||||
if (pdfBase64) resolve(pdfBase64);
|
||||
else reject(new Error("PDF 생성에 실패했습니다."));
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// cross-origin은 아니지만, 초기에는 contentWindow가 빈 상태일 수 있음
|
||||
}
|
||||
setTimeout(checkDataLoaded, 100);
|
||||
};
|
||||
|
||||
iframe.addEventListener("load", () => checkDataLoaded());
|
||||
|
||||
// 전체 타임아웃 (180초) — wace와 동일
|
||||
setTimeout(() => {
|
||||
if (iframeRef.current) {
|
||||
cleanup();
|
||||
reject(new Error("PDF 생성 시간이 초과되었습니다. (180초)"));
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
}, [contractObjid]);
|
||||
|
||||
// ─── 발송 ────────────────────────────────────────────────────────────────
|
||||
async function handleSend() {
|
||||
if (!contractObjid) return;
|
||||
const toEmails = form.toEmails.trim();
|
||||
const subject = form.subject.trim();
|
||||
const contents = form.contents.trim();
|
||||
|
||||
if (toEmails === "") { toast.error("수신인을 입력해주세요."); return; }
|
||||
if (subject === "") { toast.error("제목을 입력해주세요."); return; }
|
||||
if (contents === "") { toast.error("내용을 입력해주세요."); return; }
|
||||
|
||||
// 이메일 형식 검증
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const emails = toEmails.split(/[,;]/).map((e) => e.trim()).filter(Boolean);
|
||||
for (const e of emails) {
|
||||
if (!emailPattern.test(e)) {
|
||||
toast.error(`올바른 이메일 형식이 아닙니다: ${e}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirm("견적서를 발송하시겠습니까?")) return;
|
||||
|
||||
setSending(true);
|
||||
setProgress("발송 준비 중...");
|
||||
try {
|
||||
let pdfBase64: string | undefined;
|
||||
|
||||
if (!useAddEstOnly) {
|
||||
// 견적 PDF 생성 필요 — 최신 차수 조회 후 iframe으로 렌더
|
||||
setProgress("최신 차수 견적서 조회 중...");
|
||||
const list = await salesEstimateApi.listTemplates(contractObjid);
|
||||
if (!list || list.length === 0) {
|
||||
toast.error("견적서를 찾을 수 없습니다.");
|
||||
setSending(false);
|
||||
setProgress("");
|
||||
return;
|
||||
}
|
||||
const latest = list[0];
|
||||
|
||||
setProgress("견적서 PDF 생성 중... (최대 3분)");
|
||||
pdfBase64 = await generatePdfBase64(latest);
|
||||
}
|
||||
|
||||
setProgress("메일 발송 중...");
|
||||
const result = await salesEstimateApi.sendMail({
|
||||
contractObjid,
|
||||
toEmails,
|
||||
ccEmails: form.ccEmails.trim() || undefined,
|
||||
subject,
|
||||
contents,
|
||||
pdfBase64,
|
||||
useAddEstOnly: useAddEstOnly ? "Y" : "N",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(`발송 실패: ${result.message}`);
|
||||
} else {
|
||||
toast.success(result.message || "견적서가 성공적으로 발송되었습니다.");
|
||||
onOpenChange(false);
|
||||
onSent?.();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`발송 실패: ${e?.response?.data?.message ?? e?.message ?? "알 수 없는 오류"}`);
|
||||
} finally {
|
||||
setSending(false);
|
||||
setProgress("");
|
||||
}
|
||||
}
|
||||
|
||||
// 다이얼로그 닫힐 때 iframe 정리
|
||||
useEffect(() => {
|
||||
if (!open && iframeRef.current) {
|
||||
try { iframeRef.current.parentNode?.removeChild(iframeRef.current); } catch {}
|
||||
iframeRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const pdfNoticeText = useAddEstOnly
|
||||
? "PDF 첨부: 추가견적 PDF가 첨부됩니다."
|
||||
: hasAddEst
|
||||
? "PDF 첨부: 최종 차수 견적서 + 추가견적 PDF가 합본으로 첨부됩니다."
|
||||
: "PDF 첨부: 최종 차수 견적서가 자동으로 첨부됩니다.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!sending) onOpenChange(o); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>견적서 메일 발송</DialogTitle>
|
||||
<DialogDescription>{pdfNoticeText}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 mr-2 animate-spin" />
|
||||
계약 정보를 불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 고객사 담당자 체크박스 리스트 */}
|
||||
<div>
|
||||
<Label className="text-xs">고객사 담당자 선택</Label>
|
||||
<div className="border rounded-md p-2 bg-muted/30 max-h-[150px] overflow-y-auto text-sm">
|
||||
{managers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center py-2">
|
||||
등록된 담당자가 없습니다. 수신인을 직접 입력해주세요.
|
||||
</div>
|
||||
) : (
|
||||
managers.map((m, i) => {
|
||||
const email = m.email ?? "";
|
||||
const id = `manager_${i}_${email}`;
|
||||
return (
|
||||
<label key={id} htmlFor={id} className="flex items-center gap-2 py-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={!!checkedEmails[email]}
|
||||
disabled={email === ""}
|
||||
onChange={(e) => toggleManager(email, e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{m.name}
|
||||
{email && <span className="text-muted-foreground"> ({email})</span>}
|
||||
{m.is_main === "Y" && <span className="ml-1 text-[10px] px-1 bg-blue-100 dark:bg-blue-900 rounded">대표</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div>
|
||||
<Label className="text-xs">수신인 (To) <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={form.toEmails}
|
||||
onChange={(e) => setForm({ ...form, toEmails: e.target.value })}
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">여러 개는 쉼표(,) 또는 세미콜론(;)으로 구분</p>
|
||||
</div>
|
||||
|
||||
{/* CC */}
|
||||
<div>
|
||||
<Label className="text-xs">참조 (CC)</Label>
|
||||
<Input
|
||||
value={form.ccEmails}
|
||||
onChange={(e) => setForm({ ...form, ccEmails: e.target.value })}
|
||||
placeholder="참조 이메일 주소 (선택사항)"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">작성자 이메일이 자동으로 참조에 추가됩니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<Label className="text-xs">제목 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<Label className="text-xs">내용 <span className="text-red-500">*</span></Label>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={form.contents}
|
||||
onChange={(e) => setForm({ ...form, contents: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sending && progress && (
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{progress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>취소</Button>
|
||||
<Button onClick={handleSend} disabled={loading || sending}>
|
||||
{sending ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Send className="w-4 h-4 mr-1" />}
|
||||
발송
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user