[현재고 — 캡처/공유 시에만 숨김] - 거래처에 보낼 이미지에서 내부 정보(현재고)가 보이면 안 됨 - 거래명세표 표의 현재고 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:
@@ -12,6 +12,7 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [remember, setRemember] = useState(true); // 기본 ON
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -26,7 +27,7 @@ export default function LoginPage() {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, password }),
|
||||
body: JSON.stringify({ userId, password, remember }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
@@ -175,6 +176,17 @@ export default function LoginPage() {
|
||||
</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
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
||||
@@ -349,10 +349,20 @@ function StatementPreview({
|
||||
try {
|
||||
const mod = await import("html-to-image");
|
||||
if (!statementRef.current) return;
|
||||
const dataUrl = await mod.toPng(statementRef.current, {
|
||||
backgroundColor: "#ffffff",
|
||||
pixelRatio: 2,
|
||||
});
|
||||
// 거래처에 보낼 이미지에서 "현재고"는 내부 정보라 숨긴다.
|
||||
const hideEls = statementRef.current.querySelectorAll<HTMLElement>(".js-no-export");
|
||||
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 file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" });
|
||||
// Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유
|
||||
@@ -487,7 +497,7 @@ function StatementPreview({
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
<div>
|
||||
<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 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-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-20">단가</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"}`}>
|
||||
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
|
||||
</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)}
|
||||
</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 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">
|
||||
<input type="number" min={1} value={qty}
|
||||
onChange={(e) => setQty(Number(e.target.value))}
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { User } from "@/types";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
|
||||
const momo = await verifyMomoCredentials(userId, password);
|
||||
if (momo.success && 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) });
|
||||
}
|
||||
// MOMO 실패 시 FITO 폴백 시도 (관리자 마이그레이션 케이스)
|
||||
@@ -34,7 +35,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const fito = await verifyCredentials(userId, password);
|
||||
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) });
|
||||
}
|
||||
|
||||
@@ -43,7 +44,7 @@ export async function POST(request: NextRequest) {
|
||||
const momo = await verifyMomoCredentials(userId, password);
|
||||
if (momo.success && 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) });
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -7,13 +7,15 @@ const SECRET = new TextEncoder().encode(
|
||||
);
|
||||
|
||||
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 })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SESSION_DURATION}s`)
|
||||
.setExpirationTime(`${duration}s`)
|
||||
.sign(SECRET);
|
||||
|
||||
const cookieStore = await cookies();
|
||||
@@ -21,7 +23,7 @@ export async function createSession(user: User): Promise<string> {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_DURATION,
|
||||
maxAge: duration,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user