Function bodies 369 total
handleStatusChange function · typescript · L162-L245 (84 LOC)src/app/admin/bookings/[id]/page.tsx
async function handleStatusChange(newStatus: string) {
if (!booking || !token) return;
const needsPrice =
newStatus === "quote_confirmed" && !finalPriceInput.trim();
if (needsPrice) {
alert("최종 견적을 입력해주세요");
return;
}
const needsTime =
newStatus === "quote_confirmed" && !confirmedTimeInput;
if (needsTime) {
alert("수거 시간을 확정해주세요");
return;
}
// 슬롯 충돌 경고: 견적 확정 시 시간대 가용 여부 재확인
if (newStatus === "quote_confirmed" && confirmedTimeInput) {
try {
const slotRes = await fetch(`/api/slots?date=${booking.date}&excludeId=${booking.id}`);
const slotData = await slotRes.json();
const slotInfo = (slotData.slots || []).find(
(s: { time: string; available: boolean }) => s.time === confirmedTimeInput
);
if (slotInfo && !slotInfo.available) {
const proceed = confirm(
"선택한 시간대가 이미 마감되었습니다. 그래도 확정하시겠습니까?"
);
if (!proceed) return;
refetchBooking function · typescript · L247-L264 (18 LOC)src/app/admin/bookings/[id]/page.tsx
async function refetchBooking() {
if (!token || !id) return;
try {
const r = await fetch(`/api/admin/bookings/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await r.json();
if (data?.booking) {
setBooking(data.booking);
if (data.booking.finalPrice != null) setFinalPriceInput(String(data.booking.finalPrice));
setAdminMemoInput(data.booking.adminMemo || "");
if (data.booking.confirmedTime) setConfirmedTimeInput(data.booking.confirmedTime);
if (data.booking.confirmedDuration != null) setConfirmedDurationInput(data.booking.confirmedDuration);
if (data.booking.completionPhotos?.length) setCompletionPhotos(data.booking.completionPhotos);
setCrewSizeInput(data.booking.crewSize ?? 1);
}
} catch { /* ignore */ }
}handleSaveCrewSize function · typescript · L266-L289 (24 LOC)src/app/admin/bookings/[id]/page.tsx
async function handleSaveCrewSize() {
if (!booking || !token || crewSizeInput == null) return;
setSaving(true);
try {
const res = await fetch(`/api/admin/bookings/${booking.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ crewSize: crewSizeInput, expectedUpdatedAt: booking.updatedAt }),
});
const data = await res.json();
if (res.ok) {
setBooking(data.booking);
} else if (res.status === 409) {
alert("다른 탭에서 이미 수정되었습니다.");
await refetchBooking();
} else {
alert(data.error || "저장 실패");
}
} catch {
alert("네트워크 오류");
} finally {
setSaving(false);
}
}handleSaveMemo function · typescript · L291-L323 (33 LOC)src/app/admin/bookings/[id]/page.tsx
async function handleSaveMemo() {
if (!booking || !token) return;
setSaving(true);
try {
// adminMemo만 저장 — finalPrice/confirmedTime은 견적 확정 플로우에서만 변경
const body: Record<string, unknown> = {
adminMemo: adminMemoInput,
expectedUpdatedAt: booking.updatedAt,
};
const res = await fetch(`/api/admin/bookings/${booking.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const data = await res.json();
if (res.ok) {
setBooking(data.booking);
alert("저장되었습니다");
} else if (res.status === 409) {
alert("다른 탭에서 이미 수정되었습니다. 최신 데이터를 불러옵니다.");
await refetchBooking();
} else {
alert(data.error || "저장 실패");
}
} catch {
alert("네트워크 오류");
} finally {
setSaving(false);
}
}loadAuditLogs function · typescript · L341-L351 (11 LOC)src/app/admin/bookings/[id]/page.tsx
function loadAuditLogs() {
if (!token || !id) return;
fetch(`/api/admin/bookings/${id}/audit`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => {
if (data.logs) setAuditLogs(data.logs);
})
.catch(() => {});
}formatPhone function · typescript · L40-L45 (6 LOC)src/app/admin/bookings/new/page.tsx
function formatPhone(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 11);
if (digits.length <= 3) return digits;
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
}formatPrice function · typescript · L47-L49 (3 LOC)src/app/admin/bookings/new/page.tsx
function formatPrice(n: number): string {
return n.toLocaleString("ko-KR");
}All rows above produced by Repobility · https://repobility.com
updateField function · typescript · L118-L123 (6 LOC)src/app/admin/bookings/new/page.tsx
function updateField(field: keyof FormData, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
}getItemQty function · typescript · L125-L127 (3 LOC)src/app/admin/bookings/new/page.tsx
function getItemQty(cat: string, name: string): number {
return selectedItems.find((i) => i.category === cat && i.name === name)?.quantity ?? 0;
}updateItemQty function · typescript · L129-L150 (22 LOC)src/app/admin/bookings/new/page.tsx
function updateItemQty(
cat: string,
name: string,
displayName: string,
price: number,
loadingCube: number,
delta: number,
) {
setSelectedItems((prev) => {
const idx = prev.findIndex((i) => i.category === cat && i.name === name);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], quantity: next[idx].quantity + delta };
if (next[idx].quantity <= 0) next.splice(idx, 1);
return next;
}
if (delta > 0) {
return [...prev, { category: cat, name, displayName, price, quantity: 1, loadingCube }];
}
return prev;
});
}addCustomItem function · typescript · L152-L157 (6 LOC)src/app/admin/bookings/new/page.tsx
function addCustomItem() {
const trimmed = customItemName.trim();
if (!trimmed) return;
updateItemQty("직접입력", trimmed, trimmed, 0, 0, 1);
setCustomItemName("");
}validate function · typescript · L174-L198 (25 LOC)src/app/admin/bookings/new/page.tsx
function validate(): boolean {
const next: FormErrors = {};
if (!form.customerName.trim()) {
next.customerName = "고객 이름을 입력해주세요";
}
const phoneDigits = form.phone.replace(/\D/g, "");
if (!phoneDigits) {
next.phone = "전화번호를 입력해주세요";
} else if (phoneDigits.length < 10 || phoneDigits.length > 11) {
next.phone = "올바른 전화번호를 입력해주세요";
}
if (!form.address.trim()) {
next.address = "주소를 입력해주세요";
} else if (!form.area) {
if (!confirm("서비스 지역을 감지하지 못했습니다.\n지역 없이 예약을 생성하시겠습니까?")) {
return false;
}
}
setErrors(next);
return Object.keys(next).length === 0;
}handleSubmit function · typescript · L200-L250 (51 LOC)src/app/admin/bookings/new/page.tsx
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
try {
const priceNum = priceOverride
? Number(priceOverride.replace(/\D/g, ""))
: itemsTotal;
const res = await fetch("/api/admin/bookings", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
customerName: form.customerName.trim(),
phone: formatPhone(form.phone),
address: form.address.trim(),
addressDetail: form.addressDetail.trim(),
area: form.area,
items: selectedItems,
estimatedPrice: priceNum,
date: form.date,
timeSlot: form.timeSlot,
memo: form.memo.trim(),
source: form.source,
hasGroundAccess: form.hasGroundAccess,
}),
});
if (res.status === 401) {
formatDate function · typescript · L93-L97 (5 LOC)src/app/admin/calendar/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()]})`;
}getToday function · typescript · L99-L101 (3 LOC)src/app/admin/calendar/page.tsx
function getToday(): string {
return new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Seoul" });
}Repobility · severity-and-effort ranking · https://repobility.com
addDays function · typescript · L103-L110 (8 LOC)src/app/admin/calendar/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}`;
}getWeekStart function · typescript · L112-L121 (10 LOC)src/app/admin/calendar/page.tsx
function getWeekStart(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
const dayOfWeek = d.getDay(); // 0=일, 1=월, ...
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // 월요일로 이동
d.setDate(d.getDate() + diff);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${dd}`;
}getWeekDays function · typescript · L123-L125 (3 LOC)src/app/admin/calendar/page.tsx
function getWeekDays(mondayStr: string): string[] {
return Array.from({ length: 7 }, (_, i) => addDays(mondayStr, i));
}formatShortDate function · typescript · L127-L130 (4 LOC)src/app/admin/calendar/page.tsx
function formatShortDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return `${d.getMonth() + 1}/${d.getDate()}`;
}timeToHours function · typescript · L135-L138 (4 LOC)src/app/admin/calendar/page.tsx
function timeToHours(timeStr: string): number {
const [h, m] = timeStr.split(":").map(Number);
return h + m / 60;
}timeToPercent function · typescript · L141-L144 (4 LOC)src/app/admin/calendar/page.tsx
function timeToPercent(timeStr: string): number {
const hours = timeToHours(timeStr);
return ((hours - GANTT_START_HOUR) / GANTT_HOURS) * 100;
}pixelOffsetToTime function · typescript · L147-L154 (8 LOC)src/app/admin/calendar/page.tsx
function pixelOffsetToTime(offsetPx: number, totalWidth: number): string {
const ratio = Math.max(0, Math.min(1, offsetPx / totalWidth));
const hours = GANTT_START_HOUR + ratio * GANTT_HOURS;
const h = Math.floor(hours);
// 30분 단위로 반올림
const m = hours - h >= 0.5 ? 30 : 0;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}GanttBlock function · typescript · L165-L248 (84 LOC)src/app/admin/calendar/page.tsx
function GanttBlock({ booking, isUnloading = false, onDragStart, onClick }: GanttBlockProps) {
const time = booking.confirmedTime || booking.timeSlot;
if (!time) return null;
const leftPercent = timeToPercent(time);
const duration = booking.confirmedDuration ?? 1; // 기본 1시간
const widthPercent = (duration / GANTT_HOURS) * 100;
// 범위 벗어나는 블록 클리핑
if (leftPercent >= 100 || leftPercent < 0) return null;
const clampedWidth = Math.min(widthPercent, 100 - leftPercent);
const address = booking.address?.slice(0, 20) ?? "";
const cube = booking.totalLoadingCube ?? 0;
if (isUnloading) {
return (
<div
style={{
position: "absolute",
left: `${leftPercent}%`,
width: `${Math.max(clampedWidth, 3)}%`,
top: "4px",
bottom: "4px",
backgroundColor: "#ECEFF1",
borderLeft: "3px solid #90A4AE",
borderRadius: "4px",
zIndex: 1,
display: "flex",
alignItems: "centRepobility · code-quality intelligence · https://repobility.com
GanttView function · typescript · L260-L579 (320 LOC)src/app/admin/calendar/page.tsx
function GanttView({ drivers, bookings, token, onBookingUpdated, onBookingClick }: GanttViewProps) {
const gridRef = useRef<HTMLDivElement>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [dropTarget, setDropTarget] = useState<{ driverId: string | null; time: string } | null>(null);
const [updating, setUpdating] = useState<string | null>(null); // 업데이트 중인 bookingId
// 기사별 예약 그룹핑
const driverBookingsMap = useMemo(() => {
const map: Record<string, Booking[]> = {};
// 기사 행
for (const d of drivers) {
map[d.id] = bookings.filter((b) => b.driverId === d.id);
}
// 미배차 행
map["__unassigned__"] = bookings.filter((b) => !b.driverId);
return map;
}, [drivers, bookings]);
// unloadingStopAfter를 가진 예약 다음에 하차지 블록 삽입용 맵
const unloadingTargetIds = useMemo(() => {
const set = new Set<string>();
for (const b of bookings) {
if (b.unloadingStopAfter) set.add(b.id);
}
return set;
}, [bookings]);
handleTabChange function · typescript · L199-L203 (5 LOC)src/app/admin/dashboard/page.tsx
function handleTabChange(tabKey: string) {
setActiveTab(tabKey);
setCurrentPage(1);
setSelectedBookings(new Set());
}handleDateChange function · typescript · L206-L210 (5 LOC)src/app/admin/dashboard/page.tsx
function handleDateChange(field: "from" | "to", val: string) {
if (field === "from") setDateFrom(val);
else setDateTo(val);
setCurrentPage(1);
}requestQuickAction function · typescript · L212-L214 (3 LOC)src/app/admin/dashboard/page.tsx
function requestQuickAction(booking: Booking, newStatus: string, label: string) {
setConfirmPending({ bookingId: booking.id, newStatus, label });
}executeQuickAction function · typescript · L216-L241 (26 LOC)src/app/admin/dashboard/page.tsx
async function executeQuickAction(booking: Booking) {
if (!confirmPending) return;
const { newStatus } = confirmPending;
setConfirmPending(null);
setQuickLoading(booking.id);
try {
const res = await fetch(`/api/admin/bookings/${booking.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: newStatus, expectedUpdatedAt: booking.updatedAt }),
});
if (res.ok) {
fetchBookings();
} else {
const data = await res.json();
showToast(data.error || "변경 실패", true);
}
} catch {
showToast("네트워크 오류", true);
} finally {
setQuickLoading(null);
}
}toggleBooking function · typescript · L243-L250 (8 LOC)src/app/admin/dashboard/page.tsx
function toggleBooking(id: string) {
setSelectedBookings((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}toggleAll function · typescript · L252-L258 (7 LOC)src/app/admin/dashboard/page.tsx
function toggleAll() {
if (selectedBookings.size === bookings.length && bookings.length > 0) {
setSelectedBookings(new Set());
} else {
setSelectedBookings(new Set(bookings.map((b) => b.id)));
}
}executeBulkStatusChange function · typescript · L260-L293 (34 LOC)src/app/admin/dashboard/page.tsx
async function executeBulkStatusChange() {
if (!bulkStatus || selectedBookings.size === 0) return;
setBulkConfirmPending(false);
setBulkLoading(true);
let successCount = 0;
let failCount = 0;
for (const bookingId of selectedBookings) {
try {
const bk = bookings.find((b) => b.id === bookingId);
const res = await fetch(`/api/admin/bookings/${bookingId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: bulkStatus, expectedUpdatedAt: bk?.updatedAt }),
});
if (res.ok) successCount++;
else failCount++;
} catch {
failCount++;
}
}
setBulkLoading(false);
setSelectedBookings(new Set());
setBulkStatus("");
fetchBookings();
if (failCount > 0) {
showToast(`${successCount}건 성공, ${failCount}건 실패`, true);
}
}Provenance: Repobility (https://repobility.com) — every score reproducible from /scan/
exportCSV function · typescript · L296-L341 (46 LOC)src/app/admin/dashboard/page.tsx
async function exportCSV() {
try {
const params = new URLSearchParams();
if (activeTab !== "all") params.set("status", activeTab);
if (debouncedSearch) params.set("search", debouncedSearch);
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
params.set("limit", "1000");
const res = await fetch(`/api/admin/bookings?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
const rows: Booking[] = data.bookings || [];
const headers = ["날짜", "시간", "고객명", "전화번호", "지역", "주소", "인원", "사다리", "사다리금액", "품목수", "예상금액", "확정금액", "기사", "상태"];
const csvRows = rows.map((b) => [
b.date,
b.confirmedTime || b.timeSlot,
b.customerName,
b.phone,
b.area,
`${b.address} ${b.addressDetail || ""}`.trim(),
String(b.crewSize),
b.needLadder ? "필요" : "",
b.needLadder && b.lhandleSheetPreview function · typescript · L343-L361 (19 LOC)src/app/admin/dashboard/page.tsx
async function handleSheetPreview() {
if (!sheetURL.trim()) return;
setSheetLoading(true);
try {
const res = await fetch("/api/admin/bookings/sheet-import", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ url: sheetURL, dryRun: true }),
});
const data = await res.json();
if (!res.ok) { showToast(data.error || "미리보기 실패", true); return; }
setSheetRows(data.rows);
setSheetStep("preview");
} catch {
showToast("네트워크 오류", true);
} finally {
setSheetLoading(false);
}
}handleSheetImport function · typescript · L363-L381 (19 LOC)src/app/admin/dashboard/page.tsx
async function handleSheetImport() {
setSheetLoading(true);
try {
const res = await fetch("/api/admin/bookings/sheet-import", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ url: sheetURL, dryRun: false }),
});
const data = await res.json();
if (!res.ok) { showToast(data.error || "임포트 실패", true); return; }
setSheetResult({ succeeded: data.succeeded, failed: data.failed, skipped: data.skipped });
setSheetStep("done");
fetchBookings();
} catch {
showToast("네트워크 오류", true);
} finally {
setSheetLoading(false);
}
}closeSheetImport function · typescript · L383-L389 (7 LOC)src/app/admin/dashboard/page.tsx
function closeSheetImport() {
setShowSheetImport(false);
setSheetURL("");
setSheetStep("input");
setSheetRows([]);
setSheetResult(null);
}calcServiceMins function · typescript · L93-L95 (3 LOC)src/app/admin/dispatch/page.tsx
function calcServiceMins(totalLoadingCube: number | undefined): number {
return Math.max(1, BASE_SERVICE_MINS + Math.round((totalLoadingCube || 0) * CUBE_MINS_PER_M3));
}calcTravelMins function · typescript · L98-L101 (4 LOC)src/app/admin/dispatch/page.tsx
function calcTravelMins(lat1: number, lng1: number, lat2: number, lng2: number): number {
const km = haversine(lat1, lng1, lat2, lng2) * ROUTE_ROAD_FACTOR;
return Math.max(1, Math.round(km / ROUTE_AVG_SPEED_KMH * 60));
}getDriverColor function · typescript · L107-L110 (4 LOC)src/app/admin/dispatch/page.tsx
function getDriverColor(idx: number): string {
const hue = Math.round((idx * 137.508) % 360);
return `hsl(${hue}, 65%, 50%)`;
}getToday function · typescript · L114-L116 (3 LOC)src/app/admin/dispatch/page.tsx
function getToday(): string {
return new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Seoul" });
}All rows above produced by Repobility · https://repobility.com
formatDateShort function · typescript · L118-L124 (7 LOC)src/app/admin/dispatch/page.tsx
function formatDateShort(dateStr: string): string {
// KST 기준 명시적 파싱 (서버/클라이언트 시간대 차이 방어)
const [y, m, d] = dateStr.split("-").map(Number);
const weekdays = ["일", "월", "화", "수", "목", "금", "토"];
const date = new Date(y, m - 1, d);
return `${m}/${d} (${weekdays[date.getDay()]})`;
}getLoadingPercent function · typescript · L126-L129 (4 LOC)src/app/admin/dispatch/page.tsx
function getLoadingPercent(used: number, capacity: number): number {
if (capacity <= 0) return 0;
return Math.min(100, Math.round((used / capacity) * 100));
}itemsSummary function · typescript · L131-L137 (7 LOC)src/app/admin/dispatch/page.tsx
function itemsSummary(items: BookingItem[] | undefined | null): string {
if (!Array.isArray(items) || items.length === 0) return "-";
const first = items[0];
if (!first) return "-";
const label = `${first.category || ""} ${first.name || ""}`.trim() || "품목";
return items.length > 1 ? `${label} 외 ${items.length - 1}종` : label;
}showToast function · typescript · L184-L188 (5 LOC)src/app/admin/dispatch/page.tsx
function showToast(msg: string, type: "success" | "error" | "warning" = "error") {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
setToast({ msg, type });
toastTimerRef.current = setTimeout(() => setToast(null), 3500);
}toggleDriverSlot function · typescript · L494-L503 (10 LOC)src/app/admin/dispatch/page.tsx
function toggleDriverSlot(driverId: string, slot: string) {
setDriverSlotFilters((prev) => {
const current = prev[driverId] ?? [...SLOT_ORDER];
const next = current.includes(slot)
? current.filter((s) => s !== slot)
: [...current, slot];
// 전체 허용 = 필터 없음으로 정규화
return { ...prev, [driverId]: next.length === SLOT_ORDER.length ? [] : next };
});
}toggleCheck function · typescript · L692-L699 (8 LOC)src/app/admin/dispatch/page.tsx
function toggleCheck(id: string) {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}toggleAllUnassigned function · typescript · L702-L710 (9 LOC)src/app/admin/dispatch/page.tsx
function toggleAllUnassigned() {
const unassigned = filteredBookings.filter((b) => !b.driverId);
const allChecked = unassigned.length > 0 && unassigned.every((b) => checkedIds.has(b.id));
if (allChecked) {
setCheckedIds(new Set());
} else {
setCheckedIds(new Set(unassigned.map((b) => b.id)));
}
}scrollToNextUnassigned function · typescript · L714-L722 (9 LOC)src/app/admin/dispatch/page.tsx
function scrollToNextUnassigned(excludeIds: string[]) {
const next = filteredBookingsRef.current.find((b) => !b.driverId && !excludeIds.includes(b.id ?? ""));
if (next?.id) {
const targetId = next.id;
setTimeout(() => {
cardRefs.current.get(targetId)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 50);
}
}Repobility · severity-and-effort ranking · https://repobility.com
handleDispatch function · typescript · L724-L781 (58 LOC)src/app/admin/dispatch/page.tsx
async function handleDispatch(bookingId: string, driverId: string) {
const driver = drivers.find((d) => d.id === driverId);
if (!driver || !token || dispatching) return;
// 배차 직전에 다음 미배차 계산 (옵티미스틱 업데이트 전)
scrollToNextUnassigned([bookingId]);
// 옵티미스틱: 로컬 상태 즉시 반영
const prevBooking = bookings.find((b) => b.id === bookingId);
setBookings((prev) =>
prev.map((b) =>
b.id === bookingId ? { ...b, driverId: driver.id, driverName: driver.name } : b,
),
);
setDriverStats((prev) =>
prev.map((stat) => {
const cube = prevBooking?.totalLoadingCube || 0;
if (stat.driverId === driver.id) {
return { ...stat, assignedCount: stat.assignedCount + 1, totalLoadingCube: stat.totalLoadingCube + cube };
}
if (prevBooking?.driverId && stat.driverId === prevBooking.driverId) {
return { ...stat, assignedCount: Math.max(0, stat.assignedCount - 1), totalLoadingCube: Math.max(0, stat.totalLoadingCubehandleBatchDispatch function · typescript · L784-L864 (81 LOC)src/app/admin/dispatch/page.tsx
async function handleBatchDispatch() {
const driver = drivers.find((d) => d.id === batchDriverId);
if (!driver || !token || checkedIds.size === 0 || dispatching) return;
const targetIds = Array.from(checkedIds).filter((id) =>
filteredBookings.some((b) => b.id === id),
);
// 배차 직전에 다음 미배차 계산
scrollToNextUnassigned(targetIds);
// 옵티미스틱: 로컬 상태 즉시 반영 (재배차 시 기존 기사 차감 포함)
const targetBookings = targetIds.map((id) => bookings.find((bk) => bk.id === id)).filter(Boolean) as Booking[];
const prevDriverDeltas = new Map<string, { count: number; cube: number }>();
// 신규 기사로 실제 이동하는 건만 카운트 (이미 해당 기사 담당인 건 제외 → 중복 카운트 방지)
const newToDriverBookings = targetBookings.filter((b) => b.driverId !== driver.id);
const newToDriverCount = newToDriverBookings.length;
const newToDriverCube = newToDriverBookings.reduce((sum, b) => sum + (b.totalLoadingCube || 0), 0);
targetBookings.forEach((b) => {
const cube = b.totalLoadingCube || 0;
handleUnassign function · typescript · L867-L911 (45 LOC)src/app/admin/dispatch/page.tsx
async function handleUnassign(bookingId: string) {
if (!token || dispatching) return;
// 옵티미스틱: 로컬 상태 즉시 반영
const target = bookings.find((b) => b.id === bookingId);
setBookings((prev) =>
prev.map((b) =>
b.id === bookingId ? { ...b, driverId: null, driverName: null } : b,
),
);
if (target?.driverId) {
setDriverStats((prev) =>
prev.map((stat) =>
stat.driverId === target.driverId
? { ...stat, assignedCount: Math.max(0, stat.assignedCount - 1), totalLoadingCube: Math.max(0, stat.totalLoadingCube - (target.totalLoadingCube || 0)) }
: stat,
),
);
}
setDispatching(true);
try {
const res = await fetch(`/api/admin/bookings/${bookingId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ driverId: null, driverName: null }),
});
if (repage 1 / 8next ›