diff --git a/client/src/lib/overtimePdf.ts b/client/src/lib/overtimePdf.ts index 856b9bb..9097871 100644 --- a/client/src/lib/overtimePdf.ts +++ b/client/src/lib/overtimePdf.ts @@ -35,6 +35,8 @@ export type OvertimeSheetData = { createdAt: string; /** 회사명 (멀티테넌트 — 목록 API 가 내려줌, 없으면 하단 사명 줄 생략) */ companyName?: string | null; + /** 함께 근무자 이름 목록 (선택) */ + companions?: string[] | null; }; function esc(s: string): string { @@ -92,7 +94,7 @@ const SHEET_CSS = ` .otsheet .ot-reason th,.otsheet .ot-reason td{border:1px solid #9AA0A8;} .otsheet .ot-reason .ot-rh{height:40px;background:#F5F6F8;font-size:13.5px;font-weight:600;color:#3A3F46;letter-spacing:3px;text-align:left;padding:0 16px;vertical-align:middle;} .otsheet .ot-reason .ot-rhint{background:#F5F6F8;font-size:11px;font-weight:400;color:#A6ADB6;letter-spacing:0.5px;text-align:right;padding:0 16px;vertical-align:middle;width:200px;} -.otsheet .ot-reason .ot-rb{height:330px;vertical-align:top;padding:18px 20px;font-size:14.5px;line-height:1.8;color:#1F2329;white-space:pre-wrap;word-break:break-word;} +.otsheet .ot-reason .ot-rb{height:304px;vertical-align:top;padding:18px 20px;font-size:14.5px;line-height:1.8;color:#1F2329;white-space:pre-wrap;word-break:break-word;} .otsheet .ot-notes{margin-top:12px;font-size:11px;color:#6B7178;line-height:1.8;letter-spacing:0.2px;} .otsheet .ot-closing{margin-top:26px;text-align:center;font-size:15.5px;letter-spacing:1px;color:#1F2329;line-height:26px;} .otsheet .ot-date{margin-top:18px;text-align:center;font-size:14.5px;letter-spacing:2px;color:#1F2329;line-height:26px;} @@ -177,9 +179,9 @@ export function overtimeSheetHTML(d: OvertimeSheetData): string { - - - + + +

신 청담 당승 인
(인)(인)(인)
 .  .  .  .  .  . 

신 청대 표
(인)(인)
 .  .  .  . 
@@ -197,7 +199,8 @@ export function overtimeSheetHTML(d: OvertimeSheetData): string { 직  급${esc(d.position || "-")} - 근무 일자${esc(fmtDateWithWeekday(d.date))} + 근무 일자${esc(fmtDateWithWeekday(d.date))} + 함께 근무${d.companions && d.companions.length ? esc(d.companions.join(", ")) : ''} 근무 시간소정근로시간 종료 후 ~ ${esc(fmtTimeHM(d.extendedEnd))} 까지 (휴게시간 제외) diff --git a/client/src/lib/previewMock.ts b/client/src/lib/previewMock.ts index 1c02bd1..221c346 100644 --- a/client/src/lib/previewMock.ts +++ b/client/src/lib/previewMock.ts @@ -230,7 +230,7 @@ function demoOvertimes() { // 야근 신청 서식 폼·PDF 버튼 데모 — 줄바꿈 사유 포함(pre-line 렌더 확인용). return { overtimes: [ - { id: "ot1", date: iso(-1, 0).slice(0, 10), extendedEnd: iso(-1, 21), reason: "신규 기능 배포 대응 및 모니터링.\n장애 발생 시 즉시 롤백 대기.", status: "APPROVED", createdAt: iso(-1, 10), user: { name: "김데모", team: "프로덕트팀", position: "매니저" } }, + { id: "ot1", date: iso(-1, 0).slice(0, 10), extendedEnd: iso(-1, 21), reason: "신규 기능 배포 대응 및 모니터링.\n장애 발생 시 즉시 롤백 대기.", status: "APPROVED", createdAt: iso(-1, 10), companions: [{ id: "u-lead-1", name: "이앨리스" }, { id: "u-lead-3", name: "박그레이스" }], user: { name: "김데모", team: "프로덕트팀", position: "매니저" } }, { id: "ot2", date: iso(-3, 0).slice(0, 10), extendedEnd: iso(-3, 22), reason: "월말 정산 마감", status: "PENDING", createdAt: iso(-3, 9), user: { name: "김데모", team: "프로덕트팀", position: "매니저" } }, ], companyName: "주식회사 데모", diff --git a/client/src/pages/AttendancePage.tsx b/client/src/pages/AttendancePage.tsx index 2cf3799..b195182 100644 --- a/client/src/pages/AttendancePage.tsx +++ b/client/src/pages/AttendancePage.tsx @@ -659,8 +659,10 @@ function formatRange(a: string, b: string) { type Overtime = { id: string; date: string; extendedEnd: string; reason?: string | null; status: string; createdAt: string; + companions?: { id: string; name: string }[] | null; user?: { name: string; team: string | null; position: string | null } | null; }; +type MateLite = { id: string; name: string; team?: string | null }; function otTime(iso: string): string { return new Date(iso).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", hour12: false }); } @@ -673,6 +675,13 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) { const [reason, setReason] = useState(""); // 계획내용 줄 카운터 — PDF 계획 박스가 담을 수 있는 실측 줄 수(자동 줄바꿈 포함) 기준. const [planLines, setPlanLines] = useState<{ lines: number; maxLines: number } | null>(null); + // 함께 근무자 선택 — 최대 5명, 본인 제외. 서식 정보표·목록에 표기. + const [mates, setMates] = useState([]); + const [mateOpen, setMateOpen] = useState(false); + const [mateQuery, setMateQuery] = useState(""); + const [mateUsers, setMateUsers] = useState(null); + const mateBtnRef = useRef(null); + const [mateRect, setMateRect] = useState<{ top: number; left: number } | null>(null); const [saving, setSaving] = useState(false); const [pdfBusy, setPdfBusy] = useState(null); const [companyName, setCompanyName] = useState(null); @@ -695,8 +704,12 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) { setSaving(true); try { const extendedEnd = `${date}T${endTime}:00+09:00`; - await api("/api/attendance/overtime", { method: "POST", json: { date, extendedEnd, reason: reason || undefined } }); + await api("/api/attendance/overtime", { + method: "POST", + json: { date, extendedEnd, reason: reason || undefined, companions: mates.length ? mates.map((m) => m.id) : undefined }, + }); setReason(""); + setMates([]); await load(); } catch (e: any) { alertAsync({ title: "신청 실패", description: e?.message ?? "야근 신청에 실패했어요" }); @@ -721,11 +734,36 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) { reason: o.reason, createdAt: o.createdAt, companyName, + companions: (o.companions ?? []).map((c) => c.name), }); } catch (e: any) { alertAsync({ title: "PDF 생성 실패", description: e?.message ?? "신청서 PDF 생성에 실패했어요" }); } finally { setPdfBusy(null); } } + async function openMatePicker() { + const r = mateBtnRef.current?.getBoundingClientRect(); + if (r) setMateRect({ top: Math.round(r.bottom + 6), left: Math.round(Math.max(8, Math.min(r.left, window.innerWidth - 276))) }); + setMateQuery(""); + setMateOpen(true); + if (!mateUsers) { + try { + const res = await api<{ users: MateLite[] }>("/api/users"); + setMateUsers(res.users ?? []); + } catch { setMateUsers([]); } + } + } + // 바깥 클릭으로 피커 닫기 — 팝오버(.otmate-pop) 안 클릭은 유지. + useEffect(() => { + if (!mateOpen) return; + function onDoc(e: MouseEvent) { + const t = e.target as HTMLElement; + if (t.closest(".otmate-pop") || t === mateBtnRef.current) return; + setMateOpen(false); + } + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [mateOpen]); + // 계획내용 입력 제한 — PDF 계획 박스(A4 1페이지 고정 330px)에 실제로 들어가는 줄 수까지만. // 자동 줄바꿈 포함 실측(measurePlanLines)이라 "몇 자"가 아니라 "몇 줄"이 기준. 넘치면 // 들어가는 만큼으로 잘라(clampPlanText) 서식이 2페이지로 넘어가거나 글이 잘리는 일이 없다. @@ -756,6 +794,7 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) { reason, createdAt: new Date().toISOString(), companyName, + companions: mates.map((m) => m.name), }); } catch (e: any) { alertAsync({ title: "PDF 생성 실패", description: e?.message ?? "신청서 PDF 생성에 실패했어요" }); @@ -786,6 +825,19 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) { 까지 (휴게시간 제외) +
함께 근무
+
+ {mates.map((m) => ( + + {m.name} + + + ))} + {mates.length < 5 && ( + + )} + {mates.length === 0 && (선택) 같이 야근하는 동료} +
계획 내용 (업무 내용) = planLines.maxLines ? "otform-lines-full" : ""}`}> @@ -804,6 +856,48 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) {
+ {mateOpen && mateRect && ( + +
+ setMateQuery(e.target.value)} + /> +
+ {mateUsers === null ? ( +
불러오는 중…
+ ) : ( + (() => { + const q = mateQuery.trim().toLowerCase(); + const picked = new Set(mates.map((m) => m.id)); + const items = (mateUsers ?? []) + .filter((m) => m.id !== user?.id && !picked.has(m.id)) + .filter((m) => !q || m.name.toLowerCase().includes(q) || (m.team ?? "").toLowerCase().includes(q)) + .slice(0, 30); + if (!items.length) return
검색 결과가 없어요
; + return items.map((m) => ( + + )); + })() + )} +
+
+
+ )}