Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions client/src/lib/overtimePdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type OvertimeSheetData = {
createdAt: string;
/** ํšŒ์‚ฌ๋ช… (๋ฉ€ํ‹ฐํ…Œ๋„ŒํŠธ โ€” ๋ชฉ๋ก API ๊ฐ€ ๋‚ด๋ ค์คŒ, ์—†์œผ๋ฉด ํ•˜๋‹จ ์‚ฌ๋ช… ์ค„ ์ƒ๋žต) */
companyName?: string | null;
/** ํ•จ๊ป˜ ๊ทผ๋ฌด์ž ์ด๋ฆ„ ๋ชฉ๋ก (์„ ํƒ) */
companions?: string[] | null;
};

function esc(s: string): string {
Expand Down Expand Up @@ -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;}
Expand Down Expand Up @@ -177,9 +179,9 @@ export function overtimeSheetHTML(d: OvertimeSheetData): string {
</td>
<td style="width:300px;">
<table class="ot-approve">
<tr><td class="ot-ap-side" rowspan="3">๊ฒฐ<br>์žฌ</td><th class="ot-ap-title">์‹  ์ฒญ</th><th class="ot-ap-title">๋‹ด ๋‹น</th><th class="ot-ap-title">์Šน ์ธ</th></tr>
<tr><td class="ot-ap-sign">(์ธ)</td><td class="ot-ap-sign">(์ธ)</td><td class="ot-ap-sign">(์ธ)</td></tr>
<tr><td class="ot-ap-date">&nbsp;.&nbsp;&nbsp;.&nbsp;</td><td class="ot-ap-date">&nbsp;.&nbsp;&nbsp;.&nbsp;</td><td class="ot-ap-date">&nbsp;.&nbsp;&nbsp;.&nbsp;</td></tr>
<tr><td class="ot-ap-side" rowspan="3">๊ฒฐ<br>์žฌ</td><th class="ot-ap-title">์‹  ์ฒญ</th><th class="ot-ap-title">๋Œ€ ํ‘œ</th></tr>
<tr><td class="ot-ap-sign">(์ธ)</td><td class="ot-ap-sign">(์ธ)</td></tr>
<tr><td class="ot-ap-date">&nbsp;.&nbsp;&nbsp;.&nbsp;</td><td class="ot-ap-date">&nbsp;.&nbsp;&nbsp;.&nbsp;</td></tr>
</table>
</td>
</tr>
Expand All @@ -197,7 +199,8 @@ export function overtimeSheetHTML(d: OvertimeSheetData): string {
<th>์ง&nbsp;&nbsp;๊ธ‰</th><td>${esc(d.position || "-")}</td>
</tr>
<tr>
<th>๊ทผ๋ฌด ์ผ์ž</th><td colspan="5">${esc(fmtDateWithWeekday(d.date))}</td>
<th>๊ทผ๋ฌด ์ผ์ž</th><td>${esc(fmtDateWithWeekday(d.date))}</td>
<th>ํ•จ๊ป˜ ๊ทผ๋ฌด</th><td colspan="3">${d.companions && d.companions.length ? esc(d.companions.join(", ")) : '<span class="ot-dim">โ€”</span>'}</td>
</tr>
<tr>
<th>๊ทผ๋ฌด ์‹œ๊ฐ„</th><td colspan="5"><span class="ot-dim">์†Œ์ •๊ทผ๋กœ์‹œ๊ฐ„ ์ข…๋ฃŒ ํ›„ ~</span> <span class="ot-strong">${esc(fmtTimeHM(d.extendedEnd))}</span> <span class="ot-dim">๊นŒ์ง€ (ํœด๊ฒŒ์‹œ๊ฐ„ ์ œ์™ธ)</span></td>
Expand Down
2 changes: 1 addition & 1 deletion client/src/lib/previewMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "์ฃผ์‹ํšŒ์‚ฌ ๋ฐ๋ชจ",
Expand Down
99 changes: 98 additions & 1 deletion client/src/pages/AttendancePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand All @@ -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<MateLite[]>([]);
const [mateOpen, setMateOpen] = useState(false);
const [mateQuery, setMateQuery] = useState("");
const [mateUsers, setMateUsers] = useState<MateLite[] | null>(null);
const mateBtnRef = useRef<HTMLButtonElement | null>(null);
const [mateRect, setMateRect] = useState<{ top: number; left: number } | null>(null);
const [saving, setSaving] = useState(false);
const [pdfBusy, setPdfBusy] = useState<string | null>(null);
const [companyName, setCompanyName] = useState<string | null>(null);
Expand All @@ -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 ?? "์•ผ๊ทผ ์‹ ์ฒญ์— ์‹คํŒจํ–ˆ์–ด์š”" });
Expand All @@ -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ํŽ˜์ด์ง€๋กœ ๋„˜์–ด๊ฐ€๊ฑฐ๋‚˜ ๊ธ€์ด ์ž˜๋ฆฌ๋Š” ์ผ์ด ์—†๋‹ค.
Expand Down Expand Up @@ -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 ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์–ด์š”" });
Expand Down Expand Up @@ -786,6 +825,19 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) {
<TimePicker value={endTime} onChange={setEndTime} className="w-[110px]" />
<span className="otform-dim whitespace-nowrap">๊นŒ์ง€ (ํœด๊ฒŒ์‹œ๊ฐ„ ์ œ์™ธ)</span>
</div>
<div className="otform-th">ํ•จ๊ป˜ ๊ทผ๋ฌด</div>
<div className="otform-td otform-time flex-wrap gap-1.5 py-2">
{mates.map((m) => (
<span key={m.id} className="otmate-chip">
{m.name}
<button type="button" aria-label={`${m.name} ์ œ์™ธ`} onClick={() => setMates((prev) => prev.filter((x) => x.id !== m.id))}>ร—</button>
</span>
))}
{mates.length < 5 && (
<button type="button" ref={mateBtnRef} className="otmate-add" onClick={openMatePicker}>+ ์ถ”๊ฐ€</button>
)}
{mates.length === 0 && <span className="otform-dim">(์„ ํƒ) ๊ฐ™์ด ์•ผ๊ทผํ•˜๋Š” ๋™๋ฃŒ</span>}
</div>
<div className="otform-th otform-span !justify-start px-3">
๊ณ„ํš ๋‚ด์šฉ (์—…๋ฌด ๋‚ด์šฉ)
<span className={`otform-lines ${planLines && planLines.lines >= planLines.maxLines ? "otform-lines-full" : ""}`}>
Expand All @@ -804,6 +856,48 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) {
</div>
</div>
</div>
{mateOpen && mateRect && (
<Portal>
<div className="otmate-pop" style={{ top: mateRect.top, left: mateRect.left }}>
<input
autoFocus
className="input !py-1.5 !text-[13px] mb-1.5"
placeholder="์ด๋ฆ„ยทํŒ€ ๊ฒ€์ƒ‰"
value={mateQuery}
onChange={(e) => setMateQuery(e.target.value)}
/>
<div className="otmate-list">
{mateUsers === null ? (
<div className="otmate-empty">๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘โ€ฆ</div>
) : (
(() => {
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 <div className="otmate-empty">๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์–ด์š”</div>;
return items.map((m) => (
<button
key={m.id}
type="button"
className="otmate-item"
onClick={() => {
setMates((prev) => (prev.length >= 5 ? prev : [...prev, { id: m.id, name: m.name, team: m.team }]));
setMateOpen(false);
}}
>
<span className="font-semibold">{m.name}</span>
{m.team && <span className="text-ink-400 text-[11.5px] ml-1.5">{m.team}</span>}
</button>
));
})()
)}
</div>
</div>
</Portal>
)}
<div className="flex justify-end items-center gap-2 mt-3">
<button className="btn-ghost" onClick={downloadFormPdf} disabled={pdfBusy === "form"} title="์ž‘์„ฑ ์ค‘์ธ ์„œ์‹์„ ๊ฒฐ์žฌ์šฉ PDF ๋กœ ๋‹ค์šด๋กœ๋“œ">
{pdfBusy === "form" ? "์ƒ์„ฑ ์ค‘โ€ฆ" : "์„œ์‹ PDF"}
Expand All @@ -825,6 +919,9 @@ function OvertimeSection({ isReviewer }: { isReviewer: boolean }) {
<div className="font-bold text-ink-900">{o.date} <span className="text-ink-400 font-medium text-[12px]">โ†’ {otTime(o.extendedEnd)}</span></div>
{/* pre-line + 2์ค„ ํด๋žจํ”„ โ€” ์ค„๋ฐ”๊ฟˆ ์‚ฌ์œ ๊ฐ€ ํ•œ ์ค„๋กœ ๋ญ‰๊ฐœ์ง€์ง€ ์•Š๊ฒŒ(์ „๋ฌธ์€ PDF ๋กœ) */}
{o.reason && <div className="text-[12px] text-ink-500 whitespace-pre-line line-clamp-2">{o.reason}</div>}
{!!o.companions?.length && (
<div className="text-[11.5px] text-ink-400 truncate">ํ•จ๊ป˜ ๊ทผ๋ฌด ยท {o.companions.map((c) => c.name).join(", ")}</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button className="btn-ghost btn-xs" onClick={() => downloadPdf(o)} disabled={pdfBusy === o.id} title="์‹ ์ฒญ์„œ PDF ๋‹ค์šด๋กœ๋“œ">
Expand Down
12 changes: 12 additions & 0 deletions client/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1511,3 +1511,15 @@ body.hinest-chat-fs .hinest-preview-banner {
/* ๊ณ„ํš๋‚ด์šฉ ์ค„ ์นด์šดํ„ฐ โ€” PDF ๊ณ„ํš ๋ฐ•์Šค ์ˆ˜์šฉ๋Ÿ‰(์‹ค์ธก) ๋Œ€๋น„ ํ˜„์žฌ ์ค„ ์ˆ˜. ๊ฝ‰ ์ฐจ๋ฉด ๊ฒฝ๊ณ ์ƒ‰. */
.otform-lines { margin-left: 8px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.3px; color: var(--c-text-muted); white-space: nowrap; }
.otform-lines-full { color: #E5484D; }

/* ํ•จ๊ป˜ ๊ทผ๋ฌด์ž ์นฉยทํ”ผ์ปค (์•ผ๊ทผ ์‹ ์ฒญ ์„œ์‹ ํผ) */
.otmate-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 999px; background: var(--c-surface-3); border: 1px solid var(--c-border); font-size: 12px; font-weight: 600; color: var(--c-text-2); }
.otmate-chip button { color: var(--c-text-muted); font-size: 13px; line-height: 1; padding: 0 1px; }
.otmate-chip button:hover { color: #e5484d; }
.otmate-add { padding: 3px 10px; border-radius: 999px; border: 1px dashed var(--c-border-strong); font-size: 12px; font-weight: 600; color: var(--c-text-3); }
.otmate-add:hover { background: var(--c-surface-3); color: var(--c-text-2); }
.otmate-pop { position: fixed; z-index: 2000; width: 268px; background: var(--c-surface); border: 1px solid var(--c-border); border-radius: 12px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); padding: 8px; }
.otmate-list { max-height: 220px; overflow-y: auto; }
.otmate-item { display: block; width: 100%; text-align: left; padding: 7px 9px; border-radius: 8px; font-size: 13px; color: var(--c-text); }
.otmate-item:hover { background: var(--c-surface-3); }
.otmate-empty { padding: 14px 8px; text-align: center; font-size: 12px; color: var(--c-text-muted); }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- ์•ผ๊ทผ ์‹ ์ฒญ ํ•จ๊ป˜ ๊ทผ๋ฌด์ž ์Šค๋ƒ…์ƒท(JSON [{id,name}]) โ€” ์ถ”๊ฐ€ํ˜• ์ปฌ๋Ÿผ(์šด์˜ RDS ๋Š” ๊ธฐ๋™ ์‹œ migrate deploy ๋ฉฑ๋“ฑ ์ ์šฉ)
ALTER TABLE "OvertimeRequest" ADD COLUMN "companions" TEXT;
1 change: 1 addition & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ model OvertimeRequest {
date String // YYYY-MM-DD (KST) โ€” ์•ผ๊ทผํ•œ/ํ•  ๋‚ ์งœ
extendedEnd DateTime // ์—ฐ์žฅ ์ข…๋ฃŒ์‹œ๊ฐ
reason String?
companions String? // ํ•จ๊ป˜ ๊ทผ๋ฌด์ž ์Šค๋ƒ…์ƒท โ€” JSON [{id,name}] (์‹ ์ฒญ ์‹œ์  ์ด๋ฆ„, ์„œ์‹ ํ‘œ๊ธฐ์šฉ)
status String @default("PENDING") // PENDING | APPROVED | REJECTED
reviewer String? // ์‹ฌ์‚ฌํ•œ ๊ด€๋ฆฌ์ž userId
createdAt DateTime @default(now())
Expand Down
30 changes: 29 additions & 1 deletion server/src/routes/attendance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,20 +335,44 @@ const overtimeSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD"),
extendedEnd: z.string().refine((s) => !Number.isNaN(new Date(s).getTime()), "์œ ํšจํ•œ ์ผ์‹œ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค"),
reason: z.string().max(1000).optional(),
// ํ•จ๊ป˜ ๊ทผ๋ฌด์ž โ€” ์‚ฌ์šฉ์ž ID ๋ชฉ๋ก(์ตœ๋Œ€ 5๋ช…). ์ด๋ฆ„์€ ์„œ๋ฒ„๊ฐ€ ๊ฐ™์€ ํšŒ์‚ฌ ์‚ฌ์šฉ์ž๋กœ ๊ฒ€์ฆํ•ด ์Šค๋ƒ…์ƒท.
companions: z.array(z.string().max(64)).max(5).optional(),
});

/** companions JSON ๋ฌธ์ž์—ด โ†’ [{id,name}]. ์†์ƒ/๋นˆ๊ฐ’์€ ๋นˆ ๋ฐฐ์—ด(์„œ์‹ยท๋ชฉ๋ก ํ‘œ๊ธฐ์šฉ). */
function parseCompanions(raw: string | null): { id: string; name: string }[] {
if (!raw) return [];
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x) => x && typeof x.name === "string") : [];
} catch {
return [];
}
}

router.post("/overtime", async (req, res) => {
const parsed = overtimeSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid input" });
const u = (req as any).user;
const d = parsed.data;
// ํ•จ๊ป˜ ๊ทผ๋ฌด์ž ๊ฒ€์ฆ โ€” ๊ฐ™์€ ํšŒ์‚ฌ์˜ ํ™œ์„ฑ ์‚ฌ์šฉ์ž๋งŒ, ๋ณธ์ธ ์ œ์™ธ. ์ด๋ฆ„์€ DB ๊ฐ’์œผ๋กœ ์Šค๋ƒ…์ƒท
// (ํด๋ผ๊ฐ€ ๋ณด๋‚ธ ์ด๋ฆ„์„ ์‹ ๋ขฐํ•˜์ง€ ์•Š์Œ โ€” ๊ฒฐ์žฌ ์„œ์‹์— ๋ฐ•์ œ๋˜๋Š” ๊ฐ’์ด๋ผ ์œ„ยท๋ณ€์กฐ ์—ฌ์ง€ ์ฐจ๋‹จ).
let companions: { id: string; name: string }[] = [];
const ids = (d.companions ?? []).filter((id) => id !== u.id);
if (ids.length) {
companions = await prisma.user.findMany({
where: { id: { in: ids }, companyId: u.companyId ?? null, active: true },
select: { id: true, name: true },
});
}
const ot = await prisma.overtimeRequest.create({
data: {
userId: u.id,
companyId: u.companyId ?? null,
date: d.date,
extendedEnd: new Date(d.extendedEnd),
reason: d.reason,
companions: companions.length ? JSON.stringify(companions) : null,
},
});
await writeLog(u.id, "OVERTIME_REQUEST", ot.id, `${d.date} โ†’ ${d.extendedEnd}`);
Expand All @@ -375,7 +399,11 @@ router.get("/overtime", async (req, res) => {
const company = u.companyId
? await prisma.company.findUnique({ where: { id: u.companyId }, select: { name: true } })
: null;
res.json({ overtimes: list, companyName: company?.name ?? null });
res.json({
// companions ๋Š” DB ์—” JSON ๋ฌธ์ž์—ด โ€” ํด๋ผ๊ฐ€ ํŒŒ์‹ฑํ•˜์ง€ ์•Š๋„๋ก ๋ฐฐ์—ด๋กœ ํ’€์–ด ๋‚ด๋ ค์ค€๋‹ค.
overtimes: list.map((o) => ({ ...o, companions: parseCompanions(o.companions) })),
companyName: company?.name ?? null,
});
});

router.patch("/overtime/:id", async (req, res) => {
Expand Down
Loading