← back to jeonghoon0126__covering-spot

Function bodies 369 total

All specs Real LLM only Function bodies
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-mu
handleDragEnd 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 items
handleUpdate 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-ligh
toggle 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 (!p
GET 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) || !dateReg
POST 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 getBooki
POST 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: M
toCSVExportURL 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: nu
POST 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({ err
Repobility 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.dri
POST 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")}`;
    }
‹ prevpage 2 / 8next ›