feat: 거래명세표 캡쳐 시 현재고 숨김 + 로그인 유지(30일) 체크박스
Deploy momo-erp / deploy (push) Successful in 53s

[현재고 — 캡처/공유 시에만 숨김]
- 거래처에 보낼 이미지에서 내부 정보(현재고)가 보이면 안 됨
- 거래명세표 표의 현재고 th/td 와 재고 부족 경고 박스에 .js-no-export 클래스 추가
- captureAndShare 안에서 toPng 직전 임시로 display:none → 캡처 후 복원
- 화면에서는 그대로 보이고, 다운받은 PNG/공유 이미지에서만 빠짐

[로그인 유지 — 30일 세션]
- /api/auth/login 요청 body 에 remember 추가
- /lib/session.ts createSession(user, remember=false) — 24시간(기본) / 30일(remember=true)
- 로그인 폼에 [✓ 로그인 유지 (30일)] 체크박스 (기본 ON, 나이 많은 사용자 친화)
- 체크 해제하면 24시간 세션 유지 (기존 동작)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-08 11:13:28 +09:00
parent 3cbb28bbbd
commit 3a0400a0c2
4 changed files with 42 additions and 17 deletions
+13 -1
View File
@@ -12,6 +12,7 @@ export default function LoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPw, setShowPw] = useState(false); const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [remember, setRemember] = useState(true); // 기본 ON
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -26,7 +27,7 @@ export default function LoginPage() {
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, password }), body: JSON.stringify({ userId, password, remember }),
}); });
const data = await res.json(); const data = await res.json();
@@ -175,6 +176,17 @@ export default function LoginPage() {
</div> </div>
</div> </div>
<label className="flex items-center gap-2.5 cursor-pointer select-none py-1">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="w-5 h-5 accent-emerald-600 cursor-pointer"
/>
<span className="text-sm text-slate-700 font-semibold"> (30)</span>
<span className="text-xs text-slate-400 ml-auto"> </span>
</label>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
+18 -8
View File
@@ -349,10 +349,20 @@ function StatementPreview({
try { try {
const mod = await import("html-to-image"); const mod = await import("html-to-image");
if (!statementRef.current) return; if (!statementRef.current) return;
const dataUrl = await mod.toPng(statementRef.current, { // 거래처에 보낼 이미지에서 "현재고"는 내부 정보라 숨긴다.
backgroundColor: "#ffffff", const hideEls = statementRef.current.querySelectorAll<HTMLElement>(".js-no-export");
pixelRatio: 2, const prev: { el: HTMLElement; display: string }[] = [];
}); hideEls.forEach((el) => { prev.push({ el, display: el.style.display }); el.style.display = "none"; });
let dataUrl: string;
try {
dataUrl = await mod.toPng(statementRef.current, {
backgroundColor: "#ffffff",
pixelRatio: 2,
});
} finally {
// 복원
prev.forEach((p) => { p.el.style.display = p.display; });
}
const blob = await (await fetch(dataUrl)).blob(); const blob = await (await fetch(dataUrl)).blob();
const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" }); const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" });
// Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유 // Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유
@@ -487,7 +497,7 @@ function StatementPreview({
</div> </div>
{lowStock.length > 0 && ( {lowStock.length > 0 && (
<div className="border border-rose-200 bg-rose-50 rounded p-2 text-[11px] text-rose-700 flex items-start gap-2"> <div className="border border-rose-200 bg-rose-50 rounded p-2 text-[11px] text-rose-700 flex items-start gap-2 js-no-export">
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" /> <AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
<div> <div>
<b> {lowStock.length}</b> : <b> {lowStock.length}</b> :
@@ -526,7 +536,7 @@ function StatementPreview({
<th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th> <th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th>
<th className="border border-slate-300 px-1.5 py-1.5 text-left"></th> <th className="border border-slate-300 px-1.5 py-1.5 text-left"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-12"></th> <th className="border border-slate-300 px-1.5 py-1.5 w-12"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-14"></th> <th className="border border-slate-300 px-1.5 py-1.5 w-14 js-no-export"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-14"></th> <th className="border border-slate-300 px-1.5 py-1.5 w-14"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-20"></th> <th className="border border-slate-300 px-1.5 py-1.5 w-20"></th>
<th className="border border-slate-300 px-1.5 py-1.5"></th> <th className="border border-slate-300 px-1.5 py-1.5"></th>
@@ -568,7 +578,7 @@ function StatementPreview({
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}> <td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"} {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
</td> </td>
<td className={`border border-slate-300 px-1.5 py-1 text-right ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}> <td className={`border border-slate-300 px-1.5 py-1 text-right js-no-export ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}>
{isExtra ? "-" : fmt(it.STOCK_QTY)} {isExtra ? "-" : fmt(it.STOCK_QTY)}
</td> </td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td> <td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
@@ -671,7 +681,7 @@ function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark }
/> />
</td> </td>
<td className="border border-slate-300 px-1.5 py-1 text-center text-rose-700"></td> <td className="border border-slate-300 px-1.5 py-1 text-center text-rose-700"></td>
<td className="border border-slate-300 px-1.5 py-1 text-right text-slate-400">-</td> <td className="border border-slate-300 px-1.5 py-1 text-right text-slate-400 js-no-export">-</td>
<td className="border border-slate-300 px-1 py-1 text-right"> <td className="border border-slate-300 px-1 py-1 text-right">
<input type="number" min={1} value={qty} <input type="number" min={1} value={qty}
onChange={(e) => setQty(Number(e.target.value))} onChange={(e) => setQty(Number(e.target.value))}
+5 -4
View File
@@ -6,7 +6,8 @@ import type { User } from "@/types";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const { userId, password } = body; const { userId, password, remember } = body as { userId?: string; password?: string; remember?: boolean };
const keepLoggedIn = !!remember;
if (!userId || !password) { if (!userId || !password) {
return NextResponse.json( return NextResponse.json(
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
const momo = await verifyMomoCredentials(userId, password); const momo = await verifyMomoCredentials(userId, password);
if (momo.success && momo.user) { if (momo.success && momo.user) {
const sessionUser: User = momoToSessionUser(momo.user); const sessionUser: User = momoToSessionUser(momo.user);
await createSession(sessionUser); await createSession(sessionUser, keepLoggedIn);
return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) }); return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) });
} }
// MOMO 실패 시 FITO 폴백 시도 (관리자 마이그레이션 케이스) // MOMO 실패 시 FITO 폴백 시도 (관리자 마이그레이션 케이스)
@@ -34,7 +35,7 @@ export async function POST(request: NextRequest) {
const fito = await verifyCredentials(userId, password); const fito = await verifyCredentials(userId, password);
if (fito.success && fito.user) { if (fito.success && fito.user) {
await createSession(fito.user); await createSession(fito.user, keepLoggedIn);
return NextResponse.json({ success: true, user: fito.user, redirectTo: landingFor(fito.user) }); return NextResponse.json({ success: true, user: fito.user, redirectTo: landingFor(fito.user) });
} }
@@ -43,7 +44,7 @@ export async function POST(request: NextRequest) {
const momo = await verifyMomoCredentials(userId, password); const momo = await verifyMomoCredentials(userId, password);
if (momo.success && momo.user) { if (momo.success && momo.user) {
const sessionUser: User = momoToSessionUser(momo.user); const sessionUser: User = momoToSessionUser(momo.user);
await createSession(sessionUser); await createSession(sessionUser, keepLoggedIn);
return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) }); return NextResponse.json({ success: true, user: sessionUser, redirectTo: landingFor(sessionUser) });
} }
} }
+6 -4
View File
@@ -7,13 +7,15 @@ const SECRET = new TextEncoder().encode(
); );
const SESSION_COOKIE = "plm-session"; const SESSION_COOKIE = "plm-session";
const SESSION_DURATION = 24 * 60 * 60; // 24시간 (기존 1440분과 동일) const SESSION_DURATION_DEFAULT = 24 * 60 * 60; // 24시간 (기)
const SESSION_DURATION_REMEMBER = 30 * 24 * 60 * 60; // 30일 (로그인 유지)
export async function createSession(user: User): Promise<string> { export async function createSession(user: User, remember = false): Promise<string> {
const duration = remember ? SESSION_DURATION_REMEMBER : SESSION_DURATION_DEFAULT;
const token = await new SignJWT({ user }) const token = await new SignJWT({ user })
.setProtectedHeader({ alg: "HS256" }) .setProtectedHeader({ alg: "HS256" })
.setIssuedAt() .setIssuedAt()
.setExpirationTime(`${SESSION_DURATION}s`) .setExpirationTime(`${duration}s`)
.sign(SECRET); .sign(SECRET);
const cookieStore = await cookies(); const cookieStore = await cookies();
@@ -21,7 +23,7 @@ export async function createSession(user: User): Promise<string> {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
maxAge: SESSION_DURATION, maxAge: duration,
path: "/", path: "/",
}); });