diff --git a/backend/src/__tests__/due-nudge.test.ts b/backend/src/__tests__/due-nudge.test.ts index 19c537f..7ebf7ac 100644 --- a/backend/src/__tests__/due-nudge.test.ts +++ b/backend/src/__tests__/due-nudge.test.ts @@ -19,9 +19,11 @@ describe('언제 보내는가', () => { return nudgeKindFor(i.days, i.state); }; - it('🔴 3일 전 하루만 — 매일 보내면 재촉이 무뎌진다', () => { - expect(kindOf('2026-09-07')).toBe('soon'); // 3일 전 - expect(kindOf('2026-09-06')).toBeNull(); // 2일 전 — 안 보낸다 + it('🔴 임박 첫날 하루만 — 매일 보내면 재촉이 무뎌진다', () => { + // 임박 기준은 `DUE_SOON_DAYS`(2026-09-16 지시로 3일 → 7일). 그 첫날에만 한 번 간다 + expect(kindOf('2026-09-11')).toBe('soon'); // 7일 전 + expect(kindOf('2026-09-10')).toBeNull(); // 6일 전 — 안 보낸다 + expect(kindOf('2026-09-07')).toBeNull(); // 3일 전 — 안 보낸다 expect(kindOf('2026-09-05')).toBeNull(); // 1일 전 — 안 보낸다 }); diff --git a/backend/src/__tests__/pnl.test.ts b/backend/src/__tests__/pnl.test.ts index f99860d..76eb677 100644 --- a/backend/src/__tests__/pnl.test.ts +++ b/backend/src/__tests__/pnl.test.ts @@ -208,6 +208,31 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다', expect((await request(app).post(`/api/v1/pnl/${id}/void`).set('Cookie', looker).send({ reason: 'x' })).status).toBe(403); }, 30_000); + it('🔴 임시저장 — 발행일 없이 적어 두면 「입력 필요」에 남고, 다시 열면 그대로 열린다', async () => { + const id = await acceptedQuote(); + // 발행일 없이 저장한다(임시저장) + const draft = await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper) + .send({ biz_name: '임시상사', capital: 7_777_000, memo: '확인 중', cost: 1_000_000 }); + expect(draft.status, JSON.stringify(draft.body)).toBe(200); + expect(draft.body.data.invoice_on, '발행일 없이 저장했는데 날짜가 생겼다').toBeNull(); + + const v = await request(app).get('/api/v1/pnl').set('Cookie', keeper); + const p = v.body.data.pending.find((x: { quote_id: number }) => x.quote_id === id); + expect(p, '임시저장한 건이 입력 필요에서 사라졌다').toBeTruthy(); + // ⚠️ 적어 둔 값이 실려 와야 다시 열었을 때 그대로 열린다 + expect(p.draft).toMatchObject({ biz_name: '임시상사', capital: 7_777_000, memo: '확인 중', cost: 1_000_000 }); + + // 발행일을 적으면 그제야 그 달 표로 내려간다 + await request(app).put(`/api/v1/pnl/${id}`).set('Cookie', keeper).send({ invoice_on: '2026-09-18' }); + const sep = await request(app).get('/api/v1/pnl?month=2026-09').set('Cookie', keeper); + const row = sep.body.data.rows.find((r: { quote_id: number }) => r.quote_id === id); + expect(row, '등록했는데 표에 없다').toBeTruthy(); + // 임시저장해 둔 값이 그대로 따라 내려간다 + expect(row.biz_name).toBe('임시상사'); + expect(row.capital).toBe(7_777_000); + expect(sep.body.data.pending.some((x: { quote_id: number }) => x.quote_id === id)).toBe(false); + }, 60_000); + it('🔴 요약 — 이번 달과 전체를 한 번에 주고, 삭제한 줄은 빼고 센다', async () => { const ym = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 7); const before = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper); diff --git a/backend/src/services/due-nudge.ts b/backend/src/services/due-nudge.ts index 782d945..da99b4b 100644 --- a/backend/src/services/due-nudge.ts +++ b/backend/src/services/due-nudge.ts @@ -6,7 +6,7 @@ * 특장사에게는 아무도 알려 주지 않았다. 지금까지 알림은 **채팅·배정 때만** 갔다. * * ## 언제 - * - 납기 **3일 전** 한 번 + * - 납기 **7일 전** 한 번(`DUE_SOON_DAYS`) * - 납기 **당일** 한 번 * - 납기가 **지난 동안 날마다** 한 번 — 지난 건은 재촉이 목적이라 하루치로 끝내지 않는다 * @@ -36,7 +36,7 @@ export function nudgeKindFor(days: number, state: string): NudgeKind | null { if (state === 'overdue') return 'overdue'; if (state === 'soon') { if (days === 0) return 'today'; - if (days === DUE_SOON_DAYS) return 'soon'; // 3일 전 하루만 — 매일 보내면 재촉이 무뎌진다 + if (days === DUE_SOON_DAYS) return 'soon'; // 임박 첫날 하루만 — 매일 보내면 재촉이 무뎌진다 } return null; } diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index fa65921..b2e403a 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -49,6 +49,18 @@ export interface PnlPending { /** 계약서에서 가져온 값들 — 「입력 필요」 칸에 그대로 보여 준다(고칠 수 없다) */ supply_default: number | null; deposit_default: number; + /** + * **임시저장해 둔 값** — 아직 등록하지 않았지만 적어 둔 것이 있으면 여기 실린다. + * 없으면 null. 다시 열었을 때 적어 둔 대로 열려야 임시저장이 뜻을 갖는다. + */ + draft: { + biz_name: string | null; + capital: number; + deposit_paid_on: string | null; + capital_paid_on: string | null; + cost: number; + memo: string | null; + } | null; } const n = (v: bigint | number | null | undefined): number => Number(v ?? 0); @@ -176,7 +188,7 @@ export async function pnlPending(): Promise { id: true, quote_no: true, created_at: true, customer: { select: { name: true } }, order: { select: { accepted_at: true } }, - pnl: { select: { supply_amount: true, deposit: true } }, + pnl: { select: { supply_amount: true, deposit: true, biz_name: true, capital: true, deposit_paid_on: true, capital_paid_on: true, cost: true, memo: true } }, }, // 수락이 오래된 것부터 — 화면에서 다시 정렬하지 않게 여기서 못 박는다 orderBy: { order: { accepted_at: 'asc' } }, @@ -199,6 +211,14 @@ export async function pnlPending(): Promise { accepted_on: day(q.order?.accepted_at ?? null) ?? day(q.created_at), supply_default: supply, deposit_default: deposit, + draft: kept ? { + biz_name: kept.biz_name, + capital: n(kept.capital), + deposit_paid_on: day(kept.deposit_paid_on), + capital_paid_on: day(kept.capital_paid_on), + cost: n(kept.cost), + memo: kept.memo, + } : null, }); } return out; diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index 9d9f47c..2983f65 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -37,6 +37,15 @@ export interface PnlPending { accepted_on: string | null supply_default: number | null deposit_default: number + /** 임시저장해 둔 값 — 다시 열면 적어 둔 대로 열린다. 없으면 null */ + draft: { + biz_name: string | null + capital: number + deposit_paid_on: string | null + capital_paid_on: string | null + cost: number + memo: string | null + } | null } export interface PnlView { diff --git a/frontend/src/components/PnlTab.tsx b/frontend/src/components/PnlTab.tsx index 8826def..527a625 100644 --- a/frontend/src/components/PnlTab.tsx +++ b/frontend/src/components/PnlTab.tsx @@ -74,6 +74,28 @@ export function PnlTab() { setView(v => v && ({ ...v, rows: v.rows.map(r => (r.quote_id === saved.quote_id ? saved : r)) })) }, []) + /** + * **임시저장** — 적은 것을 붙잡아 두되 줄은 「입력 필요」에 그대로 남는다. + * 화면도 옮기지 않는다(발행일이 없으면 들어갈 달이 없고, 있어도 아직 등록한 것이 아니다). + */ + const draft = useCallback(async (quoteId: number, patch: PnlPatch) => { + const saved = await savePnl(quoteId, patch) + setView(v => v && ({ + ...v, + // 적어 둔 값이 목록에도 비치게 한다 — 다시 펴면 그 값으로 열린다 + pending: v.pending.map(p => (p.quote_id === quoteId + ? { + ...p, supply_default: saved.supply_amount, deposit_default: saved.deposit, + draft: { + biz_name: saved.biz_name, capital: saved.capital, cost: saved.cost, memo: saved.memo, + deposit_paid_on: saved.deposit_paid_on, capital_paid_on: saved.capital_paid_on, + }, + } + : p)), + })) + return saved + }, []) + const rows = view?.rows ?? [] // **삭제된 줄은 합계에서 뺀다.** 표에는 회색으로 남지만 그 달 숫자는 아니다 const alive = useMemo(() => rows.filter(r => !r.voided_at), [rows]) @@ -92,7 +114,7 @@ export function PnlTab() { {/* ② 입력 필요 — 맨 위에 따로 둔다. 여기서 발행일을 적으면 그 달 표로 들어간다 */} - + {/* ③ 그 달의 표 */}
@@ -197,9 +219,10 @@ function SummaryBar({ total }: { total: ReturnType }) { /* ── 입력 필요 ─────────────────────────────────────────────────────────── */ -function PendingBox({ pending, canEdit, isMobile, onFile }: { +function PendingBox({ pending, canEdit, isMobile, onFile, onDraft }: { pending: PnlPending[]; canEdit: boolean; isMobile: boolean onFile: (quoteId: number, patch: PnlPatch) => Promise + onDraft: (quoteId: number, patch: PnlPatch) => Promise }) { const [open, setOpen] = useState(true) /** 한 번에 한 건만 편다 — 스무 건이 동시에 펴지면 그 아래 표가 화면 밖으로 밀린다 */ @@ -217,7 +240,6 @@ function PendingBox({ pending, canEdit, isMobile, onFile }: { {open ? '▾' : '▸'} {t('입력 필요')} 0 ? s.cardCountWarn : s.cardCount}>{tf('{0}건', pending.length)} - {t('여기서 다 적고 저장하면 아래 그 달 표로 내려갑니다')} {open && (pending.length === 0 ? (
{t('발행일을 적어야 할 건이 없습니다.')}
@@ -234,6 +256,7 @@ function PendingBox({ pending, canEdit, isMobile, onFile }: { onToggle={() => setEditing(v => (v === p.quote_id ? null : p.quote_id))} // 저장이 끝나면 목록으로 돌아온다 — 다음 건을 바로 고를 수 있어야 한다 onFile={async (id, patch) => { const r = await onFile(id, patch); setEditing(null); return r }} + onDraft={onDraft} /> ))} @@ -262,39 +285,56 @@ interface Draft { * * 발행일이 없으면 내려갈 달이 정해지지 않으므로 그때만 저장을 막는다. 나머지는 나중에 채워도 된다. */ -function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { +function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile, onDraft }: { item: PnlPending; canEdit: boolean; isMobile: boolean open: boolean; onToggle: () => void onFile: (quoteId: number, patch: PnlPatch) => Promise + onDraft: (quoteId: number, patch: PnlPatch) => Promise }) { + // 임시저장해 둔 것이 있으면 **그대로 열린다** — 적어 두고 다시 못 보면 임시저장이 뜻이 없다 const [d, setD] = useState(() => ({ - invoice_on: '', biz_name: '', capital: 0, - deposit_paid_on: '', capital_paid_on: '', - cost: 0, memo: '', + invoice_on: '', + biz_name: item.draft?.biz_name ?? '', + capital: item.draft?.capital ?? 0, + deposit_paid_on: item.draft?.deposit_paid_on ?? '', + capital_paid_on: item.draft?.capital_paid_on ?? '', + cost: item.draft?.cost ?? 0, + memo: item.draft?.memo ?? '', })) /** 계약서에서 오는 값 — 보여만 준다 */ const fixed = { supply_amount: item.supply_default ?? 0, deposit: item.deposit_default } - const [busy, setBusy] = useState(false) + const [busy, setBusy] = useState<'draft' | 'file' | null>(null) const [err, setErr] = useState('') - const set = (k: K, v: Draft[K]) => setD(x => ({ ...x, [k]: v })) + const [saved, setSaved] = useState(false) + const set = (k: K, v: Draft[K]) => { setSaved(false); setD(x => ({ ...x, [k]: v })) } const calc = deriveP({ ...fixed, capital: d.capital, cost: d.cost }) - const save = async () => { - if (!d.invoice_on || busy) return - setBusy(true); setErr('') + /** + * **임시저장**과 **등록**은 적는 내용이 같고 발행일만 다르다. + * · 임시저장 — 발행일이 없어도 된다. 적은 것을 붙잡아 두고 **여기 그대로 남는다** + * · 등록 — 발행일이 있어야 한다. 아래 그 달 표로 내려간다 + */ + const write = async (mode: 'draft' | 'file') => { + if (busy) return + if (mode === 'file' && !d.invoice_on) return + setBusy(mode); setErr('') try { - await onFile(item.quote_id, { - invoice_on: d.invoice_on, + const patch = { + invoice_on: d.invoice_on || null, biz_name: d.biz_name.trim() || null, capital: d.capital, deposit_paid_on: d.deposit_paid_on || null, capital_paid_on: d.capital_paid_on || null, cost: d.cost, memo: d.memo.trim() || null, - }) + } + if (mode === 'file') { await onFile(item.quote_id, patch); return } + // 임시저장 — 줄은 「입력 필요」에 남는다. 화면을 옮기지 않는다 + await onDraft(item.quote_id, patch) + setSaved(true); setBusy(null) } catch (e) { setErr(e instanceof Error ? e.message : t('저장하지 못했습니다')) - setBusy(false) + setBusy(null) } } @@ -314,14 +354,14 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { <>
- set('invoice_on', v)} disabled={!canEdit || busy} + set('invoice_on', v)} disabled={!canEdit || !!busy} ariaLabel={t('세금계산서 발행일')} style={s.dateInput} clearable /> {/* 계약서에서 오는 값 — 보여만 준다(고쳐야 하는 예외가 생기면 그때 다시 본다) */} {item.customer ?? '—'} {/* 사업자명은 따로 적는다 — 적는 건도 있고 안 적는 건도 있다 */} - set('biz_name', e.target.value)} /> {won(fixed.supply_amount)} @@ -330,42 +370,53 @@ function PendingRow({ item, canEdit, isMobile, open, onToggle, onFile }: { {won(calc.gross)} {won(fixed.deposit)} - set('capital', v)} /> + set('capital', v)} /> {won(calc.pay_diff)} - set('deposit_paid_on', v)} disabled={!canEdit || busy} + set('deposit_paid_on', v)} disabled={!canEdit || !!busy} ariaLabel={t('계약금 입금일')} style={s.dateInput} clearable /> - set('capital_paid_on', v)} disabled={!canEdit || busy} + set('capital_paid_on', v)} disabled={!canEdit || !!busy} ariaLabel={t('캐피탈 입금일')} style={s.dateInput} clearable /> {/* 원가는 **한 칸**이다 — 별도 시스템이 하나로 내려 줄 자리고, 그때까지는 손으로 적는다 */} - set('cost', v)} /> + set('cost', v)} /> + {/* + 수익과 수익률을 한 칸에 「금액 · %」로 붙이면 **휴대폰에서 칸을 삐져나간다**(제보). + 금액만으로도 자릿수가 길다 — 갈라서 각자 한 칸씩 준다. + */} - {won(calc.profit)} · {pct(calc.margin)} + {won(calc.profit)} + + + {pct(calc.margin)} - set('memo', e.target.value)} />
{err && {err}} - {/* 발행일이 없으면 내려갈 달이 없다 — 그때만 막는다 */} - {!d.invoice_on && {t('세금계산서 발행일을 적어야 아래로 내려갑니다')}} - + {saved && !err && {t('임시저장했습니다')}} + {/* 발행일이 없으면 들어갈 달이 정해지지 않는다 — 등록만 막는다(임시저장은 된다) */} + {!d.invoice_on && {t('세금계산서 발행일을 입력해야 등록 가능합니다')}} + + disabled={!d.invoice_on || !canEdit || !!busy} onClick={() => void write('file')} + >{busy === 'file' ? t('등록 중…') : t('등록')}
)} @@ -705,10 +756,10 @@ function RowCard({ row, canEdit, onWrite, onVoid }: { put({ invoice_on: v })} /> put({ biz_name: v })} /> {/* 계약서에서 오는 값 — 보여만 준다 */} - {won(row.supply_amount)} - {won(d.vat)} - {won(d.gross)} - {won(row.deposit)} + {won(row.supply_amount)} + {won(d.vat)} + {won(d.gross)} + {won(row.deposit)} put({ capital: v })} /> put({ deposit_paid_on: v })} /> put({ capital_paid_on: v })} /> @@ -716,7 +767,7 @@ function RowCard({ row, canEdit, onWrite, onVoid }: { put({ cost: v })} /> - {won(d.profit)} + {won(d.profit)} put({ memo: v })} /> {canEdit && !dead && ( @@ -780,12 +831,36 @@ const CELL: React.CSSProperties = { /** 못 적는 칸 — 테두리는 그대로 두고 흐리게. 적을 수 있는 칸처럼 보이면 눌러 보다가 헛수고한다 */ const OFF: React.CSSProperties = { background: 'var(--soft, #F7F7F4)', color: 'var(--muted)', cursor: 'default' } -/** 계산해서 보여만 주는 값 — 적는 칸과 **같은 자리**를 차지한다(테두리만 없다) */ +/** + * 계산해서 보여만 주는 값 — 적는 칸과 **같은 자리**를 차지한다. + * + * ⚠️ **표에서는 테두리가 없다.** 열 몇 칸이 전부 네모가 되면 표가 격자로 뒤덮인다. + * 「입력 필요」 폼에서는 반대다 — 아래 `readOnlyBoxed` 를 쓴다. + */ const readOnly = (base: React.CSSProperties): React.CSSProperties => ({ ...base, border: '1px solid transparent', background: 'none', - display: 'flex', alignItems: 'center', justifyContent: 'flex-end', ...cellNum, + /* + * ⚠️ flex 로 오른쪽에 붙이지 않는다. `justify-content: flex-end` 는 값이 칸보다 길면 + * **왼쪽으로 새어 나가** 카드 밖까지 나간다(휴대폰에서 실제로 그랬다). + * 글줄로 두고 오른쪽 정렬하면, 넘칠 때도 칸 안에 머문다. + */ + display: 'block', textAlign: 'right', lineHeight: base.height as string, + overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + fontVariantNumeric: 'tabular-nums', +}) + +/** + * 「입력 필요」 폼의 **자동 기입 칸** — 테두리를 두르되 **속은 채우지 않는다**(2026-09-16 지시). + * + * 테두리가 없으면 적는 칸들 사이에서 글자만 떠 있어 정돈되지 않아 보였다(제보). + * 속을 채우지 않는 이유는, 채우면 「비활성」처럼 보여 **값이 없는 칸**과 헷갈리기 때문이다. + * 테두리만으로 「칸은 칸인데 손대는 칸은 아니다」가 읽힌다. + */ +const readOnlyBoxed = (base: React.CSSProperties): React.CSSProperties => ({ + ...readOnly(base), border: 'var(--hairline)', }) + const s: Record = { root: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-3)' }, card: cardBase, @@ -822,13 +897,18 @@ const s: Record = { pendItem: { borderTop: 'var(--hairline)' }, pendItemOpen: { borderTop: 'var(--hairline)', background: 'var(--soft, #FAFAF7)', borderRadius: 'var(--r-sm)', padding: '2px 6px 8px' }, pendHead: { display: 'flex', alignItems: 'baseline', gap: 8, width: '100%', border: 'none', background: 'none', padding: '7px 0', cursor: 'pointer', fontFamily: 'inherit', fontSize: 'var(--fs-label)', textAlign: 'left', flexWrap: 'wrap' }, - pendCta: { fontSize: 'var(--fs-caption)', color: 'var(--dark)', border: 'var(--hairline)', borderRadius: 999, padding: '1px 8px', whiteSpace: 'nowrap' }, + /* + * **버튼은 줄마다 같은 자리에 선다.** 앞 글자 길이에 따라 좌우로 흔들리면 줄을 훑을 때 + * 눈이 계속 옮겨 다닌다(제보). 오른쪽 끝에 붙여 세로로 한 줄이 되게 한다. + * ⚠️ 앞으로 목록 줄에 붙는 단추도 이렇게 한다 — 자리를 내용이 정하게 두지 않는다. + */ + pendCta: { marginLeft: 'auto', flexShrink: 0, fontSize: 'var(--fs-caption)', color: 'var(--dark)', border: 'var(--hairline)', borderRadius: 999, padding: '1px 10px', whiteSpace: 'nowrap' }, entryGrid: { display: 'grid', gap: 8, padding: '6px 0 8px' }, entryFoot: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, flexWrap: 'wrap', paddingTop: 6, borderTop: 'var(--hairline)' }, pendNo: { fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, pendName: { color: 'var(--body)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, pendSub: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', whiteSpace: 'nowrap' }, - pendAmount: { ...cellNum, color: 'var(--body)', fontSize: 'var(--fs-caption)' }, + pendAmount: { ...cellNum, color: 'var(--body)', fontSize: 'var(--fs-caption)', minWidth: 118 }, tableWrap: { overflowX: 'auto' }, // 폭을 못 박는다 — 숫자 칸은 잘리지 않을 만큼, 남는 폭은 비고가 먹는다 @@ -859,11 +939,15 @@ const s: Record = { textInputWide: CONTROL, dateInput: { ...CONTROL, minWidth: 0, justifyContent: 'space-between' }, costBtn: { ...CONTROL, cursor: 'pointer', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, - calc: { ...readOnly(CONTROL), color: 'var(--body)' }, + calc: { ...readOnlyBoxed(CONTROL), color: 'var(--body)' }, /** 글자로 보여 주는 값(고객명 등) — 숫자가 아니라 왼쪽에 붙인다 */ - calcText: { ...readOnly(CONTROL), color: 'var(--dark)', fontWeight: 700, justifyContent: 'flex-start', textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', display: 'block', lineHeight: 'var(--h-control)' }, - calcStrong: { ...readOnly(CONTROL), color: 'var(--dark)', fontWeight: 700 }, - calcWarn: { ...readOnly(CONTROL), color: 'var(--req)', fontWeight: 700 }, + // 휴대폰 카드(= 월별 표의 휴대폰 모습)는 테두리를 두르지 않는다 — 폼만 두른다(지시) + calcPlain: { ...readOnly(CONTROL), color: 'var(--body)' }, + calcPlainStrong: { ...readOnly(CONTROL), color: 'var(--dark)', fontWeight: 700 }, + calcPlainWarn: { ...readOnly(CONTROL), color: 'var(--req)', fontWeight: 700 }, + calcText: { ...readOnlyBoxed(CONTROL), color: 'var(--dark)', fontWeight: 700, textAlign: 'left' }, + calcStrong: { ...readOnlyBoxed(CONTROL), color: 'var(--dark)', fontWeight: 700 }, + calcWarn: { ...readOnlyBoxed(CONTROL), color: 'var(--req)', fontWeight: 700 }, // ── 표(PC·태블릿) — 칸이 열셋이라 한 단계 촘촘하게 ── moneyCell: { ...CELL, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }, @@ -934,4 +1018,5 @@ const s: Record = { muted: { fontSize: 'var(--fs-caption)', color: 'var(--muted)' }, empty: { fontSize: 'var(--fs-label)', color: 'var(--muted)', padding: '10px 0' }, err: { fontSize: 'var(--fs-label)', color: 'var(--warn)' }, + savedNote: { fontSize: 'var(--fs-caption)', color: 'var(--lime-ink)', fontWeight: 700 }, } diff --git a/frontend/src/components/dashboard/AdminDashboard.tsx b/frontend/src/components/dashboard/AdminDashboard.tsx index 087406e..d5e65dd 100644 --- a/frontend/src/components/dashboard/AdminDashboard.tsx +++ b/frontend/src/components/dashboard/AdminDashboard.tsx @@ -572,7 +572,11 @@ function LateCard({ orders, contracted, onGo }: {
  • {o.quote.customer?.name ?? '—'} {o.maker_org?.name ?? '—'} - + {/* + 지난 건은 빨강, **아직 안 지난 건은 초록**(브랜드) — 둘 다 빨가면 어느 것이 이미 + 늦은 건지 한눈에 안 갈린다(2026-09-16 지시). 「오늘」은 아직 지나지 않았으니 초록이다. + */} + {days < 0 ? tf('{0}일 경과', -days) : days === 0 ? t('오늘') : tf('{0}일 남음', days)}
  • @@ -868,6 +872,7 @@ const s: Record = { listOwner: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', maxWidth: '40%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, listSub: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', whiteSpace: 'nowrap' }, listLate: { color: 'var(--req)', fontSize: 'var(--fs-caption)', fontWeight: 700, whiteSpace: 'nowrap' }, + listSoon: { color: 'var(--lime-ink)', fontSize: 'var(--fs-caption)', fontWeight: 700, whiteSpace: 'nowrap' }, tagWarn: { color: 'var(--req)', fontSize: 'var(--fs-caption)', border: '1px solid var(--req)', borderRadius: 4, padding: '0 4px', whiteSpace: 'nowrap' }, // 마이페이지 고르개도 같은 규칙 — 칸을 똑같이 나눈다 boardBar: { display: 'inline-flex', width: 264, border: 'var(--hairline)', borderRadius: 999, overflow: 'hidden', background: '#fff', alignSelf: 'flex-start' }, diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index c8c74dd..19f0266 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -16,6 +16,8 @@ export const EN: Record = { '명': '', '건': '', '예정': 'Planned', + '임시저장했습니다': 'Saved as draft', + '세금계산서 발행일을 입력해야 등록 가능합니다': 'An invoice date is required to register', '이번 달 손익': 'P&L this month', '발행': 'Invoiced', '주문·제작': 'Orders & build', @@ -76,8 +78,6 @@ export const EN: Record = { '줄은 지워지지 않습니다. 회색으로 남고 합계에서만 빠지며, 사유가 그 줄 위에 적힙니다.': 'The row is not deleted. It stays greyed out, drops out of the totals, and the reason is shown over it.', '입력': 'Enter', '접기': 'Collapse', - '여기서 다 적고 저장하면 아래 그 달 표로 내려갑니다': 'Fill it in here and save — the row drops into that month below', - '세금계산서 발행일을 적어야 아래로 내려갑니다': 'An invoice date is needed before the row can drop below', '손익': 'P&L', '손익을 불러오지 못했습니다': 'Could not load the P&L', '입력 필요': 'Needs input', diff --git a/shared/process/due.test.ts b/shared/process/due.test.ts index a7feff6..0ddae5e 100644 --- a/shared/process/due.test.ts +++ b/shared/process/due.test.ts @@ -16,9 +16,9 @@ describe('납기 상태', () => { it(`🔴 ${DUE_SOON_DAYS}일 전부터 알린다`, () => { expect(dueInfo('2026-09-05', NOW).label).toBe('납기 1일 전'); - expect(dueInfo('2026-09-07', NOW)).toMatchObject({ state: 'soon', label: '납기 3일 전' }); - // 나흘 남으면 아직 조용히 둔다 — 늘 빨가면 빨강이 뜻을 잃는다 - expect(dueInfo('2026-09-08', NOW)).toMatchObject({ state: 'normal', label: '' }); + expect(dueInfo('2026-09-11', NOW)).toMatchObject({ state: 'soon', label: '납기 7일 전' }); + // 여드레 남으면 아직 조용히 둔다 — 늘 알리면 알림이 뜻을 잃는다 + expect(dueInfo('2026-09-12', NOW)).toMatchObject({ state: 'normal', label: '' }); }); it('🔴 납기가 없으면 「없음」 — 화면이 깨지지 않는다', () => { @@ -51,10 +51,10 @@ describe('정렬 — 급한 것이 위로', () => { }); describe('강조 여부', () => { - it('🔴 지났거나 사흘 안이면 알린다', () => { + it(`🔴 지났거나 ${DUE_SOON_DAYS}일 안이면 알린다`, () => { expect(dueNeedsAttention(dueInfo('2026-09-01', NOW))).toBe(true); - expect(dueNeedsAttention(dueInfo('2026-09-07', NOW))).toBe(true); - expect(dueNeedsAttention(dueInfo('2026-09-08', NOW))).toBe(false); + expect(dueNeedsAttention(dueInfo('2026-09-11', NOW)), '이레 안은 알린다').toBe(true); + expect(dueNeedsAttention(dueInfo('2026-09-12', NOW)), '여드레 남으면 조용히 둔다').toBe(false); expect(dueNeedsAttention(dueInfo(null, NOW))).toBe(false); }); }); diff --git a/shared/process/due.ts b/shared/process/due.ts index 7ceee6d..e79f4c2 100644 --- a/shared/process/due.ts +++ b/shared/process/due.ts @@ -5,13 +5,16 @@ * 없는 일이 생긴다. 납기는 두 사람이 같은 것을 보고 이야기해야 하는 값이다. */ -/** 며칠 전부터 빨갛게 알릴지 */ -export const DUE_SOON_DAYS = 3; +/** + * 며칠 전부터 「임박」으로 볼지 — **7일**(2026-09-16 지시. 그 전에는 사흘이었다). + * 여유(normal)는 8일 이상 남은 것이다. + */ +export const DUE_SOON_DAYS = 7; export type DueState = /** 납기가 지났다 — 가장 강한 표시, 가장 위 */ | 'overdue' - /** 사흘 안으로 다가왔다 */ + /** 이레 안으로 다가왔다 */ | 'soon' /** 아직 여유가 있다 */ | 'normal'