Function bodies 369 total
moveDate function · typescript · L977-L984 (8 LOC)src/app/admin/dispatch/page.tsx
function moveDate(delta: number) {
const [y, m, d] = selectedDate.split("-").map(Number);
const date = new Date(y, m - 1, d + delta);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
setSelectedDate(`${yyyy}-${mm}-${dd}`);
}SortableFlatBookingCard function · typescript · L1863-L1924 (62 LOC)src/app/admin/dispatch/page.tsx
function SortableFlatBookingCard({
cardRef,
booking,
isSelected,
isChecked,
driverColor,
driverStats,
dispatching,
onCheck,
onClick,
onDispatch,
onUnassign,
}: SortableFlatBookingCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: booking.id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }}
className="flex"
>
{/* 드래그 핸들 컬럼 (좌측) — 배차 해제 버튼과 겹치지 않도록 별도 컬럼 */}
<button
{...listeners}
{...attributes}
className="flex flex-col items-center justify-center gap-0.5 w-6 flex-shrink-0 cursor-grab active:cursor-grabbing touch-none text-text-muted hover:text-text-primary hover:bg-fill-tint transition-colors border-r border-border-light/50"
title="순서 변경"
tabIndex={-1}
aria-hidden="true"
>
{booking.routeOrder != null && (
DragHandle function · typescript · L2099-L2118 (20 LOC)src/app/admin/dispatch/page.tsx
function DragHandle({ listeners, attributes }: { listeners?: SyntheticListenerMap; attributes?: DraggableAttributes }) {
return (
<button
{...listeners}
{...attributes}
className="cursor-grab active:cursor-grabbing p-0.5 text-text-muted hover:text-text-primary touch-none flex-shrink-0"
title="순서 변경"
tabIndex={-1}
>
<svg width="12" height="14" viewBox="0 0 12 14" fill="none">
<circle cx="4" cy="3" r="1.2" fill="currentColor"/>
<circle cx="8" cy="3" r="1.2" fill="currentColor"/>
<circle cx="4" cy="7" r="1.2" fill="currentColor"/>
<circle cx="8" cy="7" r="1.2" fill="currentColor"/>
<circle cx="4" cy="11" r="1.2" fill="currentColor"/>
<circle cx="8" cy="11" r="1.2" fill="currentColor"/>
</svg>
</button>
);
}SortableBookingRow function · typescript · L2121-L2159 (39 LOC)src/app/admin/dispatch/page.tsx
function SortableBookingRow({
booking,
color,
}: {
booking: DriverPlan["bookings"][number];
color: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: booking.id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
>
<div className="flex items-center gap-2 py-1">
<DragHandle listeners={listeners} attributes={attributes} />
<span
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0"
style={{ background: color }}
>
{booking.routeOrder}
</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{booking.customerName}</div>
<div className="text-[11px] text-text-muhandleDragEnd function · typescript · L2186-L2194 (9 LOC)src/app/admin/dispatch/page.tsx
function handleDragEnd(driverId: string, event: DragEndEvent, driverBookings: DriverPlan["bookings"]) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = driverBookings.findIndex((b) => b.id === active.id);
const newIndex = driverBookings.findIndex((b) => b.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const newOrder = arrayMove(driverBookings, oldIndex, newIndex);
onReorder(driverId, newOrder.map((b) => b.id));
}LegLoadBars function · typescript · L2423-L2467 (45 LOC)src/app/admin/dispatch/page.tsx
function LegLoadBars({ driverPlan, color }: { driverPlan: DriverPlan; color: string }) {
// 하차지를 기준으로 레그 분할
const legs: { bookings: typeof driverPlan.bookings; load: number }[] = [];
let currentLeg: typeof driverPlan.bookings = [];
let currentLoad = 0;
const stopOrders = new Set(driverPlan.unloadingStops.map((s) => s.afterRouteOrder));
for (const b of driverPlan.bookings) {
currentLeg.push(b);
currentLoad += b.loadCube;
if (stopOrders.has(b.routeOrder)) {
legs.push({ bookings: currentLeg, load: currentLoad });
currentLeg = [];
currentLoad = 0;
}
}
if (currentLeg.length > 0) {
legs.push({ bookings: currentLeg, load: currentLoad });
}
const cap = driverPlan.vehicleCapacity;
return (
<div className="space-y-1">
{legs.map((leg, i) => {
const pct = cap > 0 ? Math.min(100, Math.round((leg.load / cap) * 100)) : 0;
const isOver = leg.load > cap;
return (
<div key={i} className="flex itemshandleUpdate function · typescript · L2501-L2524 (24 LOC)src/app/admin/dispatch/page.tsx
async function handleUpdate(id: string) {
if (!editName.trim() || !editAddress.trim() || updating) return;
setUpdating(true);
try {
const res = await fetch("/api/admin/unloading-points", {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ id, name: editName.trim(), address: editAddress.trim() }),
});
if (res.ok) {
// 저장 요청한 id와 현재 editingId가 같을 때만 닫음 (저장 중 다른 항목 수정 시작한 경우 보호)
setEditingId((current) => current === id ? null : current);
onToast("수정 완료", "success");
onRefresh();
} else {
const data = await res.json().catch(() => ({}));
onToast(data.error || "수정 실패", "error");
}
} catch {
onToast("네트워크 오류", "error");
} finally {
setUpdating(false);
}
}Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
handleCreate function · typescript · L2526-L2549 (24 LOC)src/app/admin/dispatch/page.tsx
async function handleCreate() {
if (!newName.trim() || !newAddress.trim() || creating) return;
setCreating(true);
try {
const res = await fetch("/api/admin/unloading-points", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: newName.trim(), address: newAddress.trim() }),
});
if (res.ok) {
setNewName("");
setNewAddress("");
onToast("하차지가 추가되었습니다", "success");
onRefresh();
} else {
const data = await res.json().catch(() => ({}));
onToast(data.error || "생성 실패");
}
} catch {
onToast("네트워크 오류");
} finally {
setCreating(false);
}
}handleDelete function · typescript · L2551-L2572 (22 LOC)src/app/admin/dispatch/page.tsx
async function handleDelete(id: string) {
setDeleteConfirmId(null);
setDeletingId(id);
try {
const res = await fetch("/api/admin/unloading-points", {
method: "DELETE",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ id }),
});
if (res.ok) {
onToast("삭제 완료", "success");
onRefresh();
} else {
const data = await res.json().catch(() => ({}));
onToast(data.error || "삭제 실패");
}
} catch {
onToast("네트워크 오류");
} finally {
setDeletingId(null);
}
}handleToggleActive function · typescript · L2574-L2591 (18 LOC)src/app/admin/dispatch/page.tsx
async function handleToggleActive(point: UnloadingPoint) {
try {
const res = await fetch("/api/admin/unloading-points", {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ id: point.id, active: !point.active }),
});
if (res.ok) {
onToast(point.active ? "비활성화되었습니다" : "활성화되었습니다", "success");
onRefresh();
} else {
const data = await res.json().catch(() => ({}));
onToast(data.error || "변경 실패");
}
} catch {
onToast("네트워크 오류");
}
}getToday function · typescript · L43-L45 (3 LOC)src/app/admin/driver/page.tsx
function getToday(): string {
return new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Seoul" });
}addDays function · typescript · L47-L54 (8 LOC)src/app/admin/driver/page.tsx
function addDays(dateStr: string, days: number): string {
const d = new Date(dateStr + "T00:00:00");
d.setDate(d.getDate() + days);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}formatDate function · typescript · L56-L60 (5 LOC)src/app/admin/driver/page.tsx
function formatDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
const weekdays = ["일", "월", "화", "수", "목", "금", "토"];
return `${d.getFullYear()}. ${d.getMonth() + 1}. ${d.getDate()}. (${weekdays[d.getDay()]})`;
}nextHour function · typescript · L62-L65 (4 LOC)src/app/admin/driver/page.tsx
function nextHour(time: string): string {
const hour = parseInt(time.split(":")[0], 10) + 1;
return `${String(hour).padStart(2, "0")}:00`;
}WorkDayToggle function · typescript · L75-L104 (30 LOC)src/app/admin/driver/page.tsx
function WorkDayToggle({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const selected = new Set(value ? value.split(",") : []);
function toggle(day: string) {
const next = new Set(selected);
if (next.has(day)) next.delete(day);
else next.add(day);
onChange(ALL_WORK_DAYS.filter((d) => next.has(d)).join(","));
}
return (
<div className="flex gap-1">
{ALL_WORK_DAYS.map((day) => {
const active = selected.has(day);
return (
<button
key={day}
type="button"
onClick={() => toggle(day)}
className={`flex-1 h-8 text-xs font-semibold rounded-sm border transition-colors ${
active
? "bg-primary text-white border-primary"
: "bg-bg-warm text-text-muted border-border-light"
} ${day === "토" || day === "일" ? (active ? "bg-primary/80" : "text-text-muted/60") : ""}`}
>
{day}
</button>
Repobility analyzer · published findings · https://repobility.com
toggle function · typescript · L77-L82 (6 LOC)src/app/admin/driver/page.tsx
function toggle(day: string) {
const next = new Set(selected);
if (next.has(day)) next.delete(day);
else next.add(day);
onChange(ALL_WORK_DAYS.filter((d) => next.has(d)).join(","));
}WorkSlotToggle function · typescript · L115-L171 (57 LOC)src/app/admin/driver/page.tsx
function WorkSlotToggle({ value, onChange }: { value: string; onChange: (v: string) => void }) {
// 빈 문자열 = 모든 슬롯 가능 (ALL)
const selected = new Set(value ? value.split(",").map((s) => s.trim()).filter(Boolean) : []);
const isAll = selected.size === 0;
function toggle(slot: string) {
const next = new Set(selected);
if (next.has(slot)) {
next.delete(slot);
} else {
next.add(slot);
}
// 모두 선택되거나 모두 해제 → 빈 문자열(전체)로 정규화
if (next.size === 0 || next.size === SLOT_ORDER.length) {
onChange("");
} else {
onChange(SLOT_ORDER.filter((s) => next.has(s)).join(","));
}
}
function setAll() {
onChange("");
}
return (
<div className="flex gap-1">
<button
type="button"
onClick={setAll}
className={`px-2 h-8 text-xs font-semibold rounded-sm border transition-colors ${
isAll
? "bg-primary text-white border-primary"
: "bg-bg-warm text-text-muted border-border-lightoggle function · typescript · L120-L133 (14 LOC)src/app/admin/driver/page.tsx
function toggle(slot: string) {
const next = new Set(selected);
if (next.has(slot)) {
next.delete(slot);
} else {
next.add(slot);
}
// 모두 선택되거나 모두 해제 → 빈 문자열(전체)로 정규화
if (next.size === 0 || next.size === SLOT_ORDER.length) {
onChange("");
} else {
onChange(SLOT_ORDER.filter((s) => next.has(s)).join(","));
}
}setAll function · typescript · L135-L137 (3 LOC)src/app/admin/driver/page.tsx
function setAll() {
onChange("");
}WorkSlotChips function · typescript · L175-L198 (24 LOC)src/app/admin/driver/page.tsx
function WorkSlotChips({ value }: { value: string }) {
if (!value) {
return (
<div className="flex gap-1 mt-1">
<span className="text-[11px] px-2 py-0.5 rounded-sm border bg-primary/10 text-primary border-primary/30 font-medium">
전체 슬롯
</span>
</div>
);
}
const selected = new Set(value.split(",").map((s) => s.trim()).filter(Boolean));
return (
<div className="flex gap-1 mt-1">
{SLOT_ORDER.filter((s) => selected.has(s)).map((slot) => (
<span
key={slot}
className="text-[11px] px-2 py-0.5 rounded-sm border bg-primary/10 text-primary border-primary/30 font-medium"
>
{SLOT_LABELS[slot]}
</span>
))}
</div>
);
}WorkDayChips function · typescript · L202-L223 (22 LOC)src/app/admin/driver/page.tsx
function WorkDayChips({ value }: { value: string }) {
const selected = new Set(value ? value.split(",") : []);
return (
<div className="flex gap-1 mt-2">
{ALL_WORK_DAYS.map((day) => {
const active = selected.has(day);
return (
<span
key={day}
className={`flex-1 h-7 flex items-center justify-center text-[11px] font-semibold rounded-sm border ${
active
? "bg-primary/10 text-primary border-primary/30"
: "bg-bg-warm text-text-muted/50 border-border-light/50"
}`}
>
{day}
</span>
);
})}
</div>
);
}showToast function · typescript · L279-L283 (5 LOC)src/app/admin/driver/page.tsx
function showToast(msg: string) {
if (toastTimer.current) clearTimeout(toastTimer.current);
setToast(msg);
toastTimer.current = setTimeout(() => setToast(null), 3000);
}handleCreateDriver function · typescript · L333-L377 (45 LOC)src/app/admin/driver/page.tsx
async function handleCreateDriver() {
if (!newName.trim()) return;
setSaving(true);
try {
const res = await fetch("/api/admin/drivers", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: newName.trim(),
phone: newPhone.replace(/-/g, "").trim() || undefined,
vehicleType: newVehicleType,
vehicleCapacity: VEHICLE_CAPACITY[newVehicleType] || 4.8,
licensePlate: newLicensePlate.trim() || undefined,
workDays: newWorkDays,
workSlots: newWorkSlots,
initialLoadCube: newInitialLoadCube || undefined,
startAddress: newStartAddress.trim() || undefined,
endAddress: newEndAddress.trim() || undefined,
}),
});
if (res.ok) {
setNewName("");
setNewPhone("");
setNewVehicleType("1톤");
setNewLicensePlate("");
Want this analysis on your repo? https://repobility.com/scan/
handleUpdateDriver function · typescript · L381-L416 (36 LOC)src/app/admin/driver/page.tsx
async function handleUpdateDriver(id: string) {
setSaving(true);
try {
const res = await fetch("/api/admin/drivers", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
id,
name: editName.trim() || undefined,
phone: editPhone.replace(/-/g, "").trim() || undefined,
vehicleType: editVehicleType,
vehicleCapacity: VEHICLE_CAPACITY[editVehicleType] || 4.8,
licensePlate: editLicensePlate.trim() || undefined,
workDays: editWorkDays,
workSlots: editWorkSlots,
initialLoadCube: editInitialLoadCube,
startAddress: editStartAddress.trim() || null,
endAddress: editEndAddress.trim() || null,
}),
});
if (res.ok) {
setEditingId(null);
fetchDrivers();
} else {
const data = await res.json();
showToast(handleBlockSlot function · typescript · L469-L490 (22 LOC)src/app/admin/driver/page.tsx
async function handleBlockSlot(timeStart: string) {
const timeEnd = nextHour(timeStart);
const driverId = selectedDriverId !== "all" ? selectedDriverId : undefined;
setSlotActionLoading(timeStart);
try {
const res = await fetch("/api/admin/blocked-slots", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ date: slotMgmtDate, timeStart, timeEnd, reason: "관리자 수동 차단", driverId: driverId || null }),
});
if (res.ok) {
fetchBlockedSlots();
} else {
const data = await res.json();
showToast(data.error || "차단 실패");
}
} catch {
showToast("네트워크 오류");
} finally {
setSlotActionLoading(null);
}
}handleUnblockSlot function · typescript · L492-L510 (19 LOC)src/app/admin/driver/page.tsx
async function handleUnblockSlot(slotId: string, timeStart: string) {
setSlotActionLoading(timeStart);
try {
const res = await fetch(`/api/admin/blocked-slots?id=${slotId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
fetchBlockedSlots();
} else {
const data = await res.json();
showToast(data.error || "해제 실패");
}
} catch {
showToast("네트워크 오류");
} finally {
setSlotActionLoading(null);
}
}handleToggleActive function · typescript · L538-L557 (20 LOC)src/app/admin/driver/page.tsx
async function handleToggleActive(driver: Driver) {
try {
const res = await fetch("/api/admin/drivers", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ id: driver.id, active: !driver.active }),
});
if (res.ok) {
fetchDrivers();
} else {
const data = await res.json();
showToast(data.error || "변경 실패");
}
} catch {
showToast("네트워크 오류");
}
}AdminLoginPage function · typescript · L10-L168 (159 LOC)src/app/admin/page.tsx
export default function AdminLoginPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [googleReady, setGoogleReady] = useState(false);
const handleGoogleResponse = useCallback(
async (credential: string) => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/admin/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ googleToken: credential }),
});
const data = await res.json();
if (res.ok && data.token) {
sessionStorage.setItem("admin_token", data.token);
if (data.admin?.name) {
sessionStorage.setItem("admin_name", data.admin.name);
}
const returnUrl = sessionStorage.getItem("admin_return_url");
sessionStorage.removeItem("admin_return_url");
handleLogin function · typescript · L94-L119 (26 LOC)src/app/admin/page.tsx
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
if (!password.trim()) return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/admin/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (res.ok && data.token) {
sessionStorage.setItem("admin_token", data.token);
const returnUrl = sessionStorage.getItem("admin_return_url");
sessionStorage.removeItem("admin_return_url");
router.push(returnUrl || "/admin/dashboard");
} else {
setError(data.error || "인증 실패");
}
} catch {
setError("네트워크 오류");
} finally {
setLoading(false);
}
}getSecret function · typescript · L8-L14 (7 LOC)src/app/api/admin/auth/route.ts
function getSecret(): string {
const secret = process.env.ADMIN_PASSWORD;
if (!secret) {
throw new Error("ADMIN_PASSWORD 환경변수가 설정되지 않았습니다");
}
return secret;
}extractRawToken function · typescript · L36-L43 (8 LOC)src/app/api/admin/auth/route.ts
function extractRawToken(req: NextRequest): string | null {
const authHeader =
req.headers.get("Authorization") || req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice(7);
}
return req.nextUrl.searchParams.get("token");
}Repobility · code-quality intelligence · https://repobility.com
validateToken function · typescript · L48-L67 (20 LOC)src/app/api/admin/auth/route.ts
export function validateToken(req: NextRequest): boolean {
const raw = extractRawToken(req);
if (!raw) return false;
const idx = raw.lastIndexOf(":");
if (idx === -1) return false;
const payload = raw.slice(0, idx);
const sig = raw.slice(idx + 1);
const exp = Number(payload.split(":")[0]);
if (!Number.isFinite(exp) || exp < Date.now()) return false;
const expected = crypto
.createHmac("sha256", getSecret())
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}POST function · typescript · L99-L168 (70 LOC)src/app/api/admin/auth/route.ts
export async function POST(req: NextRequest) {
try {
const body = await req.json();
// Google OAuth 로그인
if (body.googleToken) {
const googleUser = await verifyGoogleToken(body.googleToken);
if (!googleUser) {
return NextResponse.json(
{ error: "@covering.app 계정만 로그인 가능합니다" },
{ status: 401 },
);
}
const admin = await getOrCreateAdmin(googleUser.email, googleUser.name);
if (!admin) {
return NextResponse.json(
{ error: "관리자 등록 실패" },
{ status: 500 },
);
}
// admin_users 테이블의 role 사용
const role = (admin.role === "operator" ? "operator" : "admin") as AdminRole;
const { token, expiresIn } = createToken(admin.id, admin.email, role);
return NextResponse.json({
token,
expiresIn,
admin: { email: admin.email, name: admin.name, role },
});
}
// 비밀번호 로그인 (레거시) - admin 역할
const { password } = body;
if (!pGET function · typescript · L23-L91 (69 LOC)src/app/api/admin/blocked-slots/route.ts
export async function GET(req: NextRequest) {
try {
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const date = req.nextUrl.searchParams.get("date");
const dateFrom = req.nextUrl.searchParams.get("dateFrom");
const dateTo = req.nextUrl.searchParams.get("dateTo");
const driverIdParam = req.nextUrl.searchParams.get("driverId");
if (driverIdParam && !uuidRegex.test(driverIdParam)) {
return NextResponse.json(
{ error: "유효하지 않은 driverId 형식입니다" },
{ status: 400 },
);
}
const driverId = driverIdParam || undefined;
let slots;
if (date) {
if (!dateRegex.test(date)) {
return NextResponse.json(
{ error: "date 형식이 올바르지 않습니다 (YYYY-MM-DD)" },
{ status: 400 },
);
}
slots = await getBlockedSlots(date, driverId);
} else if (dateFrom && dateTo) {
if (!dateRegex.test(dateFrom) || !dateRegPOST function · typescript · L93-L141 (49 LOC)src/app/api/admin/blocked-slots/route.ts
export async function POST(req: NextRequest) {
try {
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const body = await req.json();
const parsed = createBlockedSlotSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0].message },
{ status: 400 },
);
}
const { date, timeStart, timeEnd, reason, driverId } = parsed.data;
// timeStart < timeEnd 검증
if (timeStart >= timeEnd) {
return NextResponse.json(
{ error: "종료 시간은 시작 시간 이후여야 합니다" },
{ status: 400 },
);
}
const { adminEmail } = getAdminFromToken(req);
const slot = await createBlockedSlot({
date,
timeStart,
timeEnd,
reason,
createdBy: adminEmail || undefined,
driverId: driverId ?? null,
});
return NextResponse.json({ slot }, { status: 201 });
} catch (e) {DELETE function · typescript · L143-L182 (40 LOC)src/app/api/admin/blocked-slots/route.ts
export async function DELETE(req: NextRequest) {
try {
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const id = req.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "id 파라미터가 필요합니다" },
{ status: 400 },
);
}
if (!uuidRegex.test(id)) {
return NextResponse.json(
{ error: "유효하지 않은 id 형식입니다" },
{ status: 400 },
);
}
const deleted = await deleteBlockedSlot(id);
if (!deleted) {
return NextResponse.json(
{ error: "슬롯을 찾을 수 없습니다" },
{ status: 404 },
);
}
return NextResponse.json({ success: true });
} catch (e) {
console.error("[admin/blocked-slots/DELETE]", e);
return NextResponse.json(
{ error: "삭제 실패" },
{ status: 500 },
);
}
}GET function · typescript · L5-L37 (33 LOC)src/app/api/admin/bookings/[id]/audit/route.ts
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const { id } = await params;
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
return NextResponse.json({ error: "유효하지 않은 id 형식입니다" }, { status: 400 });
}
const { data, error } = await supabase
.from("admin_audit_log")
.select("id, admin_email, action, details, created_at")
.eq("booking_id", id)
.order("created_at", { ascending: false })
.limit(50);
if (error) {
return NextResponse.json(
{ error: "조회 실패", detail: String(error) },
{ status: 500 },
);
}
return NextResponse.json({ logs: data || [] });
}GET function · typescript · L26-L54 (29 LOC)src/app/api/admin/bookings/[id]/route.ts
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const { id } = await params;
const booking = await getBookingByIdAdmin(id);
if (!booking) {
return NextResponse.json(
{ error: "예약을 찾을 수 없습니다" },
{ status: 404 },
);
}
return NextResponse.json({ booking });
} catch (e) {
console.error("[admin/bookings/[id]/GET]", e);
return NextResponse.json(
{ error: "조회 실패" },
{ status: 500 },
);
}
}PUT function · typescript · L56-L282 (227 LOC)src/app/api/admin/bookings/[id]/route.ts
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const { id } = await params;
const body = await req.json();
// Zod 서버사이드 검증
const parsed = BookingUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "입력값이 올바르지 않습니다", fields: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
}
// 역할 기반 권한 검사
const { adminId, adminEmail, role } = getAdminFromToken(req);
// Zod 파싱 완료 후 parsed.data 사용 (raw body 대신 검증/변환된 값 사용)
const data = parsed.data;
// payment_completed 상태 변경은 admin만 가능
if (data.status === "payment_completed" && !hasPermission(role, "payment_confirm")) {
return NextResponse.json(
{ error: "정산 완료 권한이 없습니다" },
{ status: 403 },
);
}
// 가격Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
PATCH function · typescript · L20-L51 (32 LOC)src/app/api/admin/bookings/route-order/route.ts
export async function PATCH(req: NextRequest) {
try {
if (!validateToken(req)) {
return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
}
let body: unknown;
try { body = await req.json(); } catch {
return NextResponse.json({ error: "유효하지 않은 JSON입니다" }, { status: 400 });
}
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const results = await Promise.allSettled(
parsed.data.map(({ id, routeOrder }) =>
updateBooking(id, { routeOrder } as Partial<Booking>),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
return NextResponse.json({
updated: parsed.data.length - failed,
...(failed > 0 ? { failed } : {}),
});
} catch (e) {
console.error("[bookings/route-order/PATCH]", e);
return NextResponse.json({ error: "순서 저장 실패" }, { status: 500 });
GET function · typescript · L12-L62 (51 LOC)src/app/api/admin/bookings/route.ts
export async function GET(req: NextRequest) {
try {
// 인증 확인
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const searchParams = req.nextUrl.searchParams;
const status = searchParams.get("status") || undefined;
const dateFrom = searchParams.get("dateFrom") || undefined;
const dateTo = searchParams.get("dateTo") || undefined;
const search = searchParams.get("search") || undefined;
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = Math.min(
parseInt(searchParams.get("limit") || "50", 10),
1000,
);
// status가 "all"이면 필터 없이 조회
const filterStatus = status === "all" ? undefined : status;
const { bookings, total } = await getBookingsPaginated({
status: filterStatus,
dateFrom,
dateTo,
search,
page,
limit,
});
// 상태별 카운트 (전체 기준, status 컬럼만 조회)
const counts = await getBookiPOST function · typescript · L64-L160 (97 LOC)src/app/api/admin/bookings/route.ts
export async function POST(req: NextRequest) {
try {
if (!validateToken(req)) {
return NextResponse.json(
{ error: "인증이 필요합니다" },
{ status: 401 },
);
}
const body = await req.json();
// 필수 필드 검증
if (!body.customerName?.trim()) {
return NextResponse.json({ error: "고객 이름은 필수입니다" }, { status: 400 });
}
if (!body.phone?.trim()) {
return NextResponse.json({ error: "전화번호는 필수입니다" }, { status: 400 });
}
if (!body.address?.trim()) {
return NextResponse.json({ error: "주소는 필수입니다" }, { status: 400 });
}
// items: BookingItem[] 수신 (없으면 빈 배열)
const items: Booking["items"] = Array.isArray(body.items)
? body.items.map((i: { category: string; name: string; displayName: string; price: number; quantity: number; loadingCube: number }) => ({
category: String(i.category || ""),
name: String(i.name || ""),
displayName: String(i.displayName || i.name || ""),
price: MtoCSVExportURL function · typescript · L11-L18 (8 LOC)src/app/api/admin/bookings/sheet-import/route.ts
function toCSVExportURL(url: string): string | null {
const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
if (!match) return null;
const spreadsheetId = match[1];
const gidMatch = url.match(/[#&?]gid=([0-9]+)/);
const gid = gidMatch ? gidMatch[1] : "0";
return `https://docs.google.com/spreadsheets/d/${spreadsheetId}/export?format=csv&gid=${gid}`;
}parseCSVLine function · typescript · L21-L42 (22 LOC)src/app/api/admin/bookings/sheet-import/route.ts
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
if (line[i] === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (line[i] === "," && !inQuotes) {
result.push(current);
current = "";
} else {
current += line[i];
}
}
result.push(current);
return result;
}parseCSV function · typescript · L96-L141 (46 LOC)src/app/api/admin/bookings/sheet-import/route.ts
function parseCSV(csvText: string): SheetRow[] {
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) return [];
const rawHeaders = parseCSVLine(lines[0]);
const fieldKeys = rawHeaders.map((h) => {
const normalized = h.trim().toLowerCase().replace(/\s+/g, "");
return HEADER_MAP[normalized] ?? null;
});
const rows: SheetRow[] = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
const raw: Record<string, string> = {};
fieldKeys.forEach((key, idx) => {
if (key && values[idx] !== undefined) raw[key] = values[idx].trim();
});
// 완전히 빈 행 스킵
if (Object.values(raw).every((v) => !v)) continue;
const row: SheetRow = {
rowIndex: i + 1, // 사람이 읽는 행 번호 (헤더=1, 데이터 시작=2)
customerName: raw.customerName || undefined,
phone: raw.phone || undefined,
address: raw.address || undefined,
addressDetail: raw.addressDetail || undefined,
date: raw.date |rowToBooking function · typescript · L143-L179 (37 LOC)src/app/api/admin/bookings/sheet-import/route.ts
function rowToBooking(row: SheetRow): Booking {
const now = new Date().toISOString();
const adminMemoParts = [
row.itemsDescription ? `[품목] ${row.itemsDescription}` : "",
].filter(Boolean);
return {
id: uuidv4(),
date: row.date || "",
timeSlot: row.timeSlot || "",
area: row.area || "",
items: [],
totalPrice: row.estimatedPrice ? parseInt(row.estimatedPrice.replace(/,/g, ""), 10) || 0 : 0,
crewSize: 1,
needLadder: false,
ladderPrice: 0,
customerName: row.customerName!,
phone: row.phone!,
address: row.address!,
addressDetail: row.addressDetail || "",
memo: row.memo || "",
status: "pending",
createdAt: now,
updatedAt: now,
hasElevator: false,
hasParking: false,
hasGroundAccess: false,
estimateMin: row.estimatedPrice ? parseInt(row.estimatedPrice.replace(/,/g, ""), 10) || 0 : 0,
estimateMax: row.estimatedPrice ? parseInt(row.estimatedPrice.replace(/,/g, ""), 10) || 0 : 0,
finalPrice: nuPOST function · typescript · L181-L256 (76 LOC)src/app/api/admin/bookings/sheet-import/route.ts
export async function POST(req: NextRequest) {
if (!validateToken(req)) {
return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
}
try {
const body = await req.json();
const { url, dryRun = true } = body as { url: string; dryRun?: boolean };
if (!url?.trim()) {
return NextResponse.json({ error: "구글 시트 URL을 입력해주세요" }, { status: 400 });
}
const csvURL = toCSVExportURL(url.trim());
if (!csvURL) {
return NextResponse.json(
{ error: "유효한 구글 시트 URL이 아닙니다. 'https://docs.google.com/spreadsheets/d/...' 형식이어야 합니다." },
{ status: 400 },
);
}
// Google Sheets에서 CSV 가져오기 (공개 시트 필요)
const sheetRes = await fetch(csvURL, { signal: AbortSignal.timeout(10000) });
if (!sheetRes.ok) {
if (sheetRes.status === 403) {
return NextResponse.json(
{ error: "시트 접근 권한이 없습니다. '링크가 있는 모든 사용자에게 보기 권한'으로 공유해주세요." },
{ status: 403 },
);
}
return NextResponse.json({ errRepobility analyzer · published findings · https://repobility.com
POST function · typescript · L21-L121 (101 LOC)src/app/api/admin/dispatch-auto/optimize-route/route.ts
export async function POST(req: NextRequest) {
try {
if (!validateToken(req)) {
return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
}
let body: unknown;
try { body = await req.json(); } catch {
return NextResponse.json({ error: "유효하지 않은 JSON입니다" }, { status: 400 });
}
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
const { date, driverId } = parsed.data;
const [allBookings, allDrivers, unloadingPoints] = await Promise.all([
getBookings(date),
getDrivers(true), // 활성 기사만 조회 (비활성 기사 경로 최적화 방지)
getUnloadingPoints(true),
]);
const driver = allDrivers.find((d) => d.id === driverId);
if (!driver) {
return NextResponse.json({ error: "기사를 찾을 수 없습니다" }, { status: 404 });
}
// 해당 기사의 배차된 주문 중 좌표 있는 것만 최적화 대상
const driverBookings = allBookings.filter(
(b) => b.driPOST function · typescript · L39-L331 (293 LOC)src/app/api/admin/dispatch-auto/route.ts
export async function POST(req: NextRequest) {
try {
if (!validateToken(req)) {
return NextResponse.json({ error: "인증이 필요합니다" }, { status: 401 });
}
const body = await req.json();
const parsed = AutoDispatchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "잘못된 요청", details: parsed.error.format() },
{ status: 400 },
);
}
const { date, driverSlotFilters } = parsed.data;
// 배차 날짜 요일 계산 (KST 기준)
// workDays = "월,화,수,목,금" 등 쉼표 구분 한국어 요일
const KO_DAYS = ["일", "월", "화", "수", "목", "금", "토"];
const [dy, dm, dd] = date.split("-").map(Number);
const dayOfWeek = KO_DAYS[new Date(dy, dm - 1, dd).getDay()];
// 병렬 조회
const [allBookings, allDrivers, unloadingPoints] = await Promise.all([
getBookings(date),
getDrivers(true),
getUnloadingPoints(true),
]);
// 해당 요일에 근무하는 기사만 필터링 (workDays 미설정 시 항상 근무로 간주)
const drivers = allDrivers.filter((d) => {secsToHHMM function · typescript · L234-L239 (6 LOC)src/app/api/admin/dispatch-auto/route.ts
function secsToHHMM(secs: number): string {
const normalized = ((secs % 86400) + 86400) % 86400; // 음수 방지
const h = Math.floor(normalized / 3600);
const m = Math.floor((normalized % 3600) / 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}