Subversion Repositories SmartDukaan

Rev

Rev 36632 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

package com.spice.profitmandi.web.controller;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;
import com.spice.profitmandi.common.model.CustomRetailer;
import com.spice.profitmandi.common.web.util.ResponseSender;
import com.spice.profitmandi.dao.entity.auth.AuthUser;
import com.spice.profitmandi.dao.entity.fofo.FofoStore;
import com.spice.profitmandi.dao.entity.logistics.PublicHolidays;
import com.spice.profitmandi.dao.entity.user.AuthUserLocation;
import com.spice.profitmandi.dao.entity.user.BeatPlan;
import com.spice.profitmandi.dao.entity.user.BeatPlanDay;
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
import com.spice.profitmandi.dao.repository.cs.CsService;
import com.spice.profitmandi.dao.repository.dtr.*;
import com.spice.profitmandi.dao.repository.logistics.PublicHolidaysRepository;
import com.spice.profitmandi.service.user.RetailerService;
import com.spice.profitmandi.web.model.LoginDetails;
import com.spice.profitmandi.web.util.CookiesProcessor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Type;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;

@Controller
@Transactional(rollbackFor = Throwable.class)
public class BeatPlanController {
        private static final Logger LOGGER = LogManager.getLogger(BeatPlanController.class);
        private static final String[] BEAT_COLORS = {
                        "#3498DB", "#E74C3C", "#2ECC71", "#9B59B6", "#F39C12",
                        "#1ABC9C", "#E67E22", "#34495E", "#16A085", "#C0392B"
        };
        @Autowired
        private CsService csService;
        @Autowired
        private AuthRepository authRepository;
        @Autowired
        private FofoStoreRepository fofoStoreRepository;
        @Autowired
        private RetailerService retailerService;
        @Autowired
        private BeatPlanRepository beatPlanRepository;
        @Autowired
        private BeatPlanDayRepository beatPlanDayRepository;
        @Autowired
        private AuthUserLocationRepository authUserLocationRepository;
        @Autowired
        private LeadRepository leadRepository;
        @Autowired
        private PublicHolidaysRepository publicHolidaysRepository;
        @Autowired
        private com.spice.profitmandi.service.GeocodingService geocodingService;
        @Autowired
        private CookiesProcessor cookiesProcessor;
        @Autowired
        private ResponseSender responseSender;

        @GetMapping(value = "/beatPlan")
        public String beatPlan(HttpServletRequest request, Model model) {
                EscalationType[] escalationTypes = EscalationType.values();
                model.addAttribute("escalationTypes", escalationTypes);
                return "beat-plan";
        }

        @GetMapping(value = "/beatPlanWindow")
        public String beatPlanWindow(HttpServletRequest request, Model model) {
                EscalationType[] escalationTypes = EscalationType.values();
                model.addAttribute("escalationTypes", escalationTypes);
                return "beat-plan-window";
        }

        @GetMapping(value = "/beatPlan/getAuthUsers")
        public ResponseEntity<?> getAuthUsers(
                        @RequestParam int categoryId,
                        @RequestParam EscalationType escalationType) {

                List<AuthUser> authUsers = csService.getAuthUserByCategoryId(categoryId, escalationType);
                List<Map<String, Object>> result = authUsers.stream()
                                .filter(au -> au.getActive())
                                .map(au -> {
                                        Map<String, Object> map = new HashMap<>();
                                        map.put("id", au.getId());
                                        map.put("name", au.getFirstName() + " " + au.getLastName());
                                        return map;
                                })
                                .collect(Collectors.toList());

                return responseSender.ok(result);
        }

        @GetMapping(value = "/beatPlan/getBaseLocation")
        public ResponseEntity<?> getBaseLocation(@RequestParam int authUserId) {
                AuthUserLocation baseLoc = authUserLocationRepository.selectLatestByAuthUserIdAndType(authUserId, "BASE");
                if (baseLoc == null) {
                        return responseSender.ok(new HashMap<>());
                }
                Map<String, Object> result = new HashMap<>();
                result.put("id", baseLoc.getId());
                result.put("locationName", baseLoc.getLocationName());
                result.put("latitude", baseLoc.getLatitude());
                result.put("longitude", baseLoc.getLongitude());
                result.put("address", baseLoc.getAddress());
                return responseSender.ok(result);
        }

        @PostMapping(value = "/beatPlan/saveBaseLocation")
        public ResponseEntity<?> saveBaseLocation(
                        @RequestParam int authUserId,
                        @RequestParam String locationName,
                        @RequestParam String latitude,
                        @RequestParam String longitude,
                        @RequestParam(required = false) String address) {

                AuthUserLocation loc = new AuthUserLocation();
                loc.setAuthUserId(authUserId);
                loc.setLocationType("BASE");
                loc.setLocationName(locationName);
                loc.setLatitude(latitude);
                loc.setLongitude(longitude);
                loc.setAddress(address);
                loc.setCreatedTimestamp(LocalDateTime.now());
                authUserLocationRepository.persist(loc);

                Map<String, Object> result = new HashMap<>();
                result.put("status", true);
                result.put("id", loc.getId());
                return responseSender.ok(result);
        }

        @GetMapping(value = "/beatPlan/getPartners")
        public ResponseEntity<?> getPartners(
                        @RequestParam int authUserId,
                        @RequestParam int categoryId,
                        @RequestParam(required = false) String startLat,
                        @RequestParam(required = false) String startLng) throws ProfitMandiBusinessException {

                Map<Integer, List<Integer>> pp = csService.getAuthUserIdPartnerIdMapping();
                List<Integer> fofoIds = pp.get(authUserId);


                if (fofoIds.isEmpty()) {
                        Map<String, Object> empty = new HashMap<>();
                        empty.put("partners", new ArrayList<>());
                        return responseSender.ok(empty);
                }

                List<FofoStore> fofoStores = fofoStoreRepository.selectByRetailerIds(fofoIds);
                Map<Integer, CustomRetailer> retailerMap = retailerService.getFofoRetailers(fofoIds);

                // Build partner data list with addresses
                List<Map<String, Object>> partners = new ArrayList<>();
                List<String> addressesToGeocode = new ArrayList<>();
                List<FofoStore> activeStores = new ArrayList<>();

                for (FofoStore store : fofoStores) {
                        if (!store.isActive() || store.isClosed()) continue;
                        activeStores.add(store);

                        CustomRetailer retailer = retailerMap.get(store.getId());

                        Map<String, Object> partnerData = new HashMap<>();
                        partnerData.put("fofoId", store.getId());
                        partnerData.put("code", store.getCode());
                        partnerData.put("outletName", store.getOutletName());
                        partnerData.put("type", "partner");

                        String addressStr = null;
                        if (retailer != null) {
                                partnerData.put("businessName", retailer.getBusinessName());
                                if (retailer.getAddress() != null) {
                                        addressStr = retailer.getAddress().getAddressString();
                                        partnerData.put("address", addressStr);
                                }
                        }

                        addressesToGeocode.add(addressStr);
                        partners.add(partnerData);
                }

                // Geocode all addresses in parallel (cached eternally in Redis, so only first call hits Google)
                java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(
                                Math.min(10, partners.size()));
                List<java.util.concurrent.Future<double[]>> futures = new ArrayList<>();

                for (String addr : addressesToGeocode) {
                        futures.add(executor.submit(() -> {
                                if (addr != null && !addr.isEmpty()) {
                                        try {
                                                return geocodingService.geocodeAddress(addr);
                                        } catch (Exception e) {
                                                return null;
                                        }
                                }
                                return null;
                        }));
                }

                // Collect results
                for (int i = 0; i < partners.size(); i++) {
                        Map<String, Object> partnerData = partners.get(i);
                        String lat = null, lng = null;

                        try {
                                double[] coords = futures.get(i).get(10, java.util.concurrent.TimeUnit.SECONDS);
                                if (coords != null) {
                                        lat = String.valueOf(coords[0]);
                                        lng = String.valueOf(coords[1]);
                                }
                        } catch (Exception e) {
                                LOGGER.warn("Geocoding timeout/error for partner {}", partnerData.get("code"));
                        }

                        partnerData.put("latitude", lat);
                        partnerData.put("longitude", lng);
                }

                executor.shutdown();

                // Sort by nearest neighbor from start location if provided, otherwise from first partner
                if (startLat != null && startLng != null && !startLat.isEmpty() && !startLng.isEmpty()) {
                        partners = sortByNearestNeighborFromStart(partners, Double.parseDouble(startLat), Double.parseDouble(startLng));
                } else {
                        partners = sortByNearestNeighbor(partners);
                }

                Map<String, Object> response = new HashMap<>();
                response.put("partners", partners);
                return responseSender.ok(response);
        }

        @PostMapping(value = "/beatPlan/submitPlan")
        public ResponseEntity<?> submitPlan(
                        HttpServletRequest request,
                        @RequestParam int authUserId,
                        @RequestParam String planData) throws Exception {

                LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
                AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());

                Gson gson = new Gson();
                Type type = new TypeToken<Map<String, Object>>() {
                }.getType();
                Map<String, Object> plan = gson.fromJson(planData, type);

                List<Map<String, Object>> days = (List<Map<String, Object>>) plan.get("days");
                List<String> dates = (List<String>) plan.get("dates");

                // Build visit fingerprint to detect duplicates
                List<Integer> newVisitIds = new ArrayList<>();
                for (Map<String, Object> day : days) {
                        List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
                        if (visits != null) {
                                for (Map<String, Object> v : visits) {
                                        newVisitIds.add(((Number) v.get("id")).intValue());
                                }
                        }
                }
                Collections.sort(newVisitIds);

                // Check existing beats for this auth user
                List<BeatPlanDay> existingBeatDays = beatPlanDayRepository.selectByAuthUserIdAndDateRange(
                                authUserId, LocalDate.of(2000, 1, 1), LocalDate.of(2099, 12, 31));
                Set<String> existingGroups = existingBeatDays.stream()
                                .map(BeatPlanDay::getPlanGroupId).filter(g -> g != null).collect(Collectors.toSet());

                for (String existingGroup : existingGroups) {
                        List<BeatPlan> existingVisits = beatPlanRepository.selectByPlanGroupId(existingGroup);
                        List<Integer> existingIds = existingVisits.stream()
                                        .map(BeatPlan::getFofoId).sorted().collect(Collectors.toList());
                        if (existingIds.equals(newVisitIds)) {
                                // Duplicate found — return existing planGroupId
                                Map<String, Object> response = new HashMap<>();
                                response.put("status", true);
                                response.put("planGroupId", existingGroup);
                                response.put("duplicate", true);
                                response.put("message", "Beat already exists with same visits");
                                return responseSender.ok(response);
                        }
                }

                String planGroupId = UUID.randomUUID().toString();
                String beatName = plan.get("beatName") != null ? (String) plan.get("beatName") : "Beat";
                String beatColor = BEAT_COLORS[Math.abs(planGroupId.hashCode()) % BEAT_COLORS.length];

                for (int d = 0; d < days.size(); d++) {
                        Map<String, Object> day = days.get(d);
                        int dayNumber = d + 1;
                        LocalDate planDate = (dates != null && d < dates.size() && dates.get(d) != null)
                                        ? LocalDate.parse(dates.get(d), DateTimeFormatter.ISO_DATE)
                                        : null;

                        // Save day-level info
                        BeatPlanDay bpDay = new BeatPlanDay();
                        bpDay.setPlanGroupId(planGroupId);
                        bpDay.setAuthUserId(authUserId);
                        bpDay.setDayNumber(dayNumber);
                        bpDay.setPlanDate(planDate);
                        bpDay.setBeatName(beatName);
                        bpDay.setBeatColor(beatColor);
                        bpDay.setStartLocationName((String) day.get("startLocationName"));
                        bpDay.setStartLatitude((String) day.get("startLatitude"));
                        bpDay.setStartLongitude((String) day.get("startLongitude"));
                        bpDay.setEndAction((String) day.get("endAction"));
                        bpDay.setStayLocationName((String) day.get("stayLocationName"));
                        bpDay.setStayLatitude((String) day.get("stayLatitude"));
                        bpDay.setStayLongitude((String) day.get("stayLongitude"));
                        if (day.get("totalDistanceKm") != null) {
                                bpDay.setTotalDistanceKm(((Number) day.get("totalDistanceKm")).doubleValue());
                        }
                        if (day.get("totalTimeMins") != null) {
                                bpDay.setTotalTimeMins(((Number) day.get("totalTimeMins")).intValue());
                        }
                        bpDay.setCreatedBy(currentUser.getId());
                        bpDay.setCreatedTimestamp(LocalDateTime.now());
                        bpDay.setActive(true);
                        beatPlanDayRepository.persist(bpDay);

                        // Save visits for this day
                        List<Map<String, Object>> visits = (List<Map<String, Object>>) day.get("visits");
                        if (visits != null) {
                                for (int i = 0; i < visits.size(); i++) {
                                        Map<String, Object> visit = visits.get(i);
                                        BeatPlan bp = new BeatPlan();
                                        bp.setAuthUserId(authUserId);
                                        bp.setFofoId(((Number) visit.get("id")).intValue());
                                        bp.setVisitType((String) visit.get("type"));
                                        bp.setDayNumber(dayNumber);
                                        bp.setPlanGroupId(planGroupId);
                                        bp.setPlanDate(planDate);
                                        bp.setSequenceOrder(i);
                                        bp.setCreatedBy(currentUser.getId());
                                        bp.setCreatedTimestamp(LocalDateTime.now());
                                        bp.setUpdatedTimestamp(LocalDateTime.now());
                                        bp.setActive(true);
                                        beatPlanRepository.persist(bp);
                                }
                        }
                }

                Map<String, Object> response = new HashMap<>();
                response.put("status", true);
                response.put("planGroupId", planGroupId);
                response.put("message", "Beat plan submitted successfully");
                return responseSender.ok(response);
        }

        // ============ CALENDAR ENDPOINTS ============

        @PostMapping(value = "/beatPlan/delete")
        public ResponseEntity<?> deleteBeat(@RequestParam String planGroupId) {
                beatPlanDayRepository.deleteByPlanGroupId(planGroupId);
                beatPlanRepository.deleteByPlanGroupId(planGroupId);

                Map<String, Object> response = new HashMap<>();
                response.put("status", true);
                response.put("message", "Beat deleted");
                return responseSender.ok(response);
        }

        @GetMapping(value = "/beatPlan/calendar")
        public ResponseEntity<?> getCalendar(
                        @RequestParam int authUserId,
                        @RequestParam String month) {

                YearMonth ym = YearMonth.parse(month);
                LocalDate startDate = ym.atDay(1);
                LocalDate endDate = ym.atEndOfMonth();

                // Get holidays
                List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
                List<Map<String, String>> holidayList = holidays.stream().map(h -> {
                        Map<String, String> m = new HashMap<>();
                        m.put("date", h.getDate().toString());
                        m.put("occasion", h.getOccasion());
                        return m;
                }).collect(Collectors.toList());

                // Get beats for this month (includes null-date/unscheduled ones)
                List<BeatPlanDay> beatDays = beatPlanDayRepository.selectByAuthUserIdAndDateRange(authUserId, startDate, endDate);

                // Group by planGroupId
                Map<String, List<BeatPlanDay>> grouped = beatDays.stream()
                                .filter(d -> d.getPlanGroupId() != null)
                                .collect(Collectors.groupingBy(BeatPlanDay::getPlanGroupId));

                LocalDate today = LocalDate.now();
                List<Map<String, Object>> scheduledBeats = new ArrayList<>();

                for (Map.Entry<String, List<BeatPlanDay>> entry : grouped.entrySet()) {
                        List<BeatPlanDay> days = entry.getValue();
                        BeatPlanDay first = days.get(0);

                        // Determine status
                        boolean allNullDates = days.stream().allMatch(d -> d.getPlanDate() == null);
                        boolean hasToday = days.stream().anyMatch(d -> d.getPlanDate() != null && d.getPlanDate().equals(today));
                        boolean allPast = !allNullDates && days.stream().filter(d -> d.getPlanDate() != null).allMatch(d -> d.getPlanDate().isBefore(today));
                        boolean allFuture = !allNullDates && days.stream().filter(d -> d.getPlanDate() != null).allMatch(d -> d.getPlanDate().isAfter(today));

                        String status;
                        if (allNullDates) status = "unscheduled";
                        else if (hasToday) status = "running";
                        else if (allPast) status = "completed";
                        else if (allFuture) status = "scheduled";
                        else status = "scheduled"; // mixed past/future

                        // Get visit counts per day
                        Map<String, Object> beat = new HashMap<>();
                        beat.put("planGroupId", entry.getKey());
                        beat.put("beatName", first.getBeatName() != null ? first.getBeatName() : "Beat");
                        beat.put("beatColor", first.getBeatColor() != null ? first.getBeatColor() : "#3498DB");
                        beat.put("status", status);

                        List<Map<String, Object>> dayInfoList = new ArrayList<>();
                        for (BeatPlanDay d : days) {
                                Map<String, Object> dayInfo = new HashMap<>();
                                dayInfo.put("dayNumber", d.getDayNumber());
                                dayInfo.put("planDate", d.getPlanDate() != null ? d.getPlanDate().toString() : null);
                                dayInfo.put("totalKm", d.getTotalDistanceKm());
                                dayInfo.put("totalMins", d.getTotalTimeMins());

                                // Count visits for this day
                                List<BeatPlan> visits = beatPlanRepository.selectByPlanGroupId(entry.getKey()).stream()
                                                .filter(bp -> bp.getDayNumber() == d.getDayNumber())
                                                .collect(Collectors.toList());
                                dayInfo.put("visitCount", visits.size());
                                dayInfoList.add(dayInfo);
                        }
                        beat.put("days", dayInfoList);
                        scheduledBeats.add(beat);
                }

                // Build blocked dates (Sundays + holidays)
                Set<String> blockedDates = new HashSet<>();
                for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
                        if (d.getDayOfWeek() == DayOfWeek.SUNDAY) {
                                blockedDates.add(d.toString());
                        }
                }
                for (PublicHolidays h : holidays) {
                        blockedDates.add(h.getDate().toString());
                }

                Map<String, Object> response = new HashMap<>();
                response.put("holidays", holidayList);
                response.put("scheduledBeats", scheduledBeats);
                response.put("blockedDates", blockedDates);

                return responseSender.ok(response);
        }

        @PostMapping(value = "/beatPlan/scheduleOnCalendar")
        public ResponseEntity<?> scheduleOnCalendar(
                        HttpServletRequest request,
                        @RequestParam String planGroupId,
                        @RequestParam String dates,
                        @RequestParam(required = false) String beatName,
                        @RequestParam(required = false) String beatColor) throws Exception {

                Gson gson = new Gson();
                List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
                }.getType());

                List<BeatPlanDay> beatDays = beatPlanDayRepository.selectByPlanGroupId(planGroupId);
                if (beatDays.isEmpty()) {
                        return responseSender.badRequest("No beat found for this plan group");
                }

                // Check running beat — can't reschedule
                LocalDate today = LocalDate.now();
                boolean isRunning = beatDays.stream().anyMatch(d -> d.getPlanDate() != null && d.getPlanDate().equals(today));
                if (isRunning) {
                        return responseSender.badRequest("Cannot reschedule a running beat");
                }

                // Auto-assign color if not provided
                if (beatColor == null || beatColor.isEmpty()) {
                        beatColor = BEAT_COLORS[Math.abs(planGroupId.hashCode()) % BEAT_COLORS.length];
                }

                // Update dates on beat_plan_day
                beatDays.sort(Comparator.comparingInt(BeatPlanDay::getDayNumber));
                for (int i = 0; i < beatDays.size() && i < dateList.size(); i++) {
                        BeatPlanDay day = beatDays.get(i);
                        day.setPlanDate(LocalDate.parse(dateList.get(i)));
                        if (beatName != null) day.setBeatName(beatName);
                        day.setBeatColor(beatColor);
                        beatPlanDayRepository.persist(day);
                }

                // Update dates on beat_plan records too
                List<BeatPlan> beatPlans = beatPlanRepository.selectByPlanGroupId(planGroupId);
                for (BeatPlan bp : beatPlans) {
                        for (int i = 0; i < beatDays.size() && i < dateList.size(); i++) {
                                if (bp.getDayNumber() == beatDays.get(i).getDayNumber()) {
                                        bp.setPlanDate(LocalDate.parse(dateList.get(i)));
                                        beatPlanRepository.persist(bp);
                                }
                        }
                }

                Map<String, Object> response = new HashMap<>();
                response.put("status", true);
                response.put("message", "Beat scheduled successfully");
                return responseSender.ok(response);
        }

        @PostMapping(value = "/beatPlan/repeatBeat")
        public ResponseEntity<?> repeatBeat(
                        HttpServletRequest request,
                        @RequestParam String sourcePlanGroupId,
                        @RequestParam int authUserId,
                        @RequestParam String dates) throws Exception {

                LoginDetails loginDetails = cookiesProcessor.getCookiesObject(request);
                AuthUser currentUser = authRepository.selectByEmailOrMobile(loginDetails.getEmailId());

                Gson gson = new Gson();
                List<String> dateList = gson.fromJson(dates, new TypeToken<List<String>>() {
                }.getType());

                // Get source beat days and visits
                List<BeatPlanDay> sourceDays = beatPlanDayRepository.selectByPlanGroupId(sourcePlanGroupId);
                List<BeatPlan> sourceVisits = beatPlanRepository.selectByPlanGroupId(sourcePlanGroupId);

                if (sourceDays.isEmpty()) {
                        return responseSender.badRequest("Source beat not found");
                }

                String newPlanGroupId = UUID.randomUUID().toString();
                String beatName = sourceDays.get(0).getBeatName();
                String beatColor = sourceDays.get(0).getBeatColor();

                // Copy days with new dates
                sourceDays.sort(Comparator.comparingInt(BeatPlanDay::getDayNumber));
                for (int i = 0; i < sourceDays.size() && i < dateList.size(); i++) {
                        BeatPlanDay src = sourceDays.get(i);
                        BeatPlanDay newDay = new BeatPlanDay();
                        newDay.setPlanGroupId(newPlanGroupId);
                        newDay.setAuthUserId(authUserId);
                        newDay.setDayNumber(src.getDayNumber());
                        newDay.setPlanDate(LocalDate.parse(dateList.get(i)));
                        newDay.setBeatName(beatName);
                        newDay.setBeatColor(beatColor);
                        newDay.setStartLocationName(src.getStartLocationName());
                        newDay.setStartLatitude(src.getStartLatitude());
                        newDay.setStartLongitude(src.getStartLongitude());
                        newDay.setEndAction(src.getEndAction());
                        newDay.setStayLocationName(src.getStayLocationName());
                        newDay.setStayLatitude(src.getStayLatitude());
                        newDay.setStayLongitude(src.getStayLongitude());
                        newDay.setTotalDistanceKm(src.getTotalDistanceKm());
                        newDay.setTotalTimeMins(src.getTotalTimeMins());
                        newDay.setCreatedBy(currentUser.getId());
                        newDay.setCreatedTimestamp(LocalDateTime.now());
                        newDay.setActive(true);
                        beatPlanDayRepository.persist(newDay);
                }

                // Copy visits with new plan group and dates
                for (BeatPlan srcVisit : sourceVisits) {
                        BeatPlan newVisit = new BeatPlan();
                        newVisit.setAuthUserId(authUserId);
                        newVisit.setFofoId(srcVisit.getFofoId());
                        newVisit.setVisitType(srcVisit.getVisitType());
                        newVisit.setDayNumber(srcVisit.getDayNumber());
                        newVisit.setPlanGroupId(newPlanGroupId);
                        newVisit.setSequenceOrder(srcVisit.getSequenceOrder());
                        newVisit.setCreatedBy(currentUser.getId());
                        newVisit.setCreatedTimestamp(LocalDateTime.now());
                        newVisit.setUpdatedTimestamp(LocalDateTime.now());
                        newVisit.setActive(true);

                        // Set date from new dateList
                        int dayIdx = srcVisit.getDayNumber() - 1;
                        if (dayIdx >= 0 && dayIdx < dateList.size()) {
                                newVisit.setPlanDate(LocalDate.parse(dateList.get(dayIdx)));
                        }
                        beatPlanRepository.persist(newVisit);
                }

                Map<String, Object> response = new HashMap<>();
                response.put("status", true);
                response.put("planGroupId", newPlanGroupId);
                response.put("message", "Beat repeated successfully");
                return responseSender.ok(response);
        }

        @GetMapping(value = "/beatPlan/availableSlots")
        public ResponseEntity<?> getAvailableSlots(
                        @RequestParam int authUserId,
                        @RequestParam String month,
                        @RequestParam int daysNeeded) {

                YearMonth ym = YearMonth.parse(month);
                LocalDate startDate = ym.atDay(1);
                LocalDate endDate = ym.atEndOfMonth();
                LocalDate today = LocalDate.now();

                // Blocked: Sundays + holidays + already scheduled dates
                Set<LocalDate> blocked = new HashSet<>();

                for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
                        if (d.getDayOfWeek() == DayOfWeek.SUNDAY) blocked.add(d);
                        if (!d.isAfter(today)) blocked.add(d); // past dates blocked
                }

                List<PublicHolidays> holidays = publicHolidaysRepository.selectAllBetweenDates(startDate, endDate);
                for (PublicHolidays h : holidays) blocked.add(h.getDate());

                List<BeatPlanDay> existingBeats = beatPlanDayRepository.selectByAuthUserIdAndDateRange(authUserId, startDate, endDate);
                for (BeatPlanDay bd : existingBeats) {
                        if (bd.getPlanDate() != null) blocked.add(bd.getPlanDate());
                }

                // Find earliest available slots
                List<String> available = new ArrayList<>();
                for (LocalDate d = startDate.isAfter(today) ? startDate : today.plusDays(1);
                         !d.isAfter(endDate) && available.size() < daysNeeded;
                         d = d.plusDays(1)) {
                        if (!blocked.contains(d)) {
                                available.add(d.toString());
                        }
                }

                Map<String, Object> response = new HashMap<>();
                response.put("suggestedDates", available);
                response.put("totalAvailable", available.size());
                return responseSender.ok(response);
        }

        // --- Sorting helpers ---

        private List<Map<String, Object>> sortByNearestNeighborFromStart(
                        List<Map<String, Object>> partners, double startLat, double startLng) {

                List<Map<String, Object>> withCoords = new ArrayList<>();
                List<Map<String, Object>> withoutCoords = new ArrayList<>();

                for (Map<String, Object> p : partners) {
                        if (hasValidCoords(p)) {
                                withCoords.add(p);
                        } else {
                                withoutCoords.add(p);
                        }
                }

                List<Map<String, Object>> sorted = new ArrayList<>();
                double currentLat = startLat;
                double currentLng = startLng;

                while (!withCoords.isEmpty()) {
                        int nearestIdx = 0;
                        double nearestDist = Double.MAX_VALUE;
                        for (int i = 0; i < withCoords.size(); i++) {
                                double lat = Double.parseDouble(withCoords.get(i).get("latitude").toString());
                                double lng = Double.parseDouble(withCoords.get(i).get("longitude").toString());
                                double dist = haversine(currentLat, currentLng, lat, lng);
                                if (dist < nearestDist) {
                                        nearestDist = dist;
                                        nearestIdx = i;
                                }
                        }
                        Map<String, Object> nearest = withCoords.remove(nearestIdx);
                        sorted.add(nearest);
                        currentLat = Double.parseDouble(nearest.get("latitude").toString());
                        currentLng = Double.parseDouble(nearest.get("longitude").toString());
                }

                sorted.addAll(withoutCoords);
                return sorted;
        }

        private List<Map<String, Object>> sortByNearestNeighbor(List<Map<String, Object>> partners) {
                List<Map<String, Object>> withCoords = new ArrayList<>();
                List<Map<String, Object>> withoutCoords = new ArrayList<>();

                for (Map<String, Object> p : partners) {
                        if (hasValidCoords(p)) {
                                withCoords.add(p);
                        } else {
                                withoutCoords.add(p);
                        }
                }

                List<Map<String, Object>> sorted = new ArrayList<>();
                if (!withCoords.isEmpty()) {
                        sorted.add(withCoords.remove(0));
                        while (!withCoords.isEmpty()) {
                                Map<String, Object> last = sorted.get(sorted.size() - 1);
                                double lastLat = Double.parseDouble(last.get("latitude").toString());
                                double lastLng = Double.parseDouble(last.get("longitude").toString());

                                int nearestIdx = 0;
                                double nearestDist = Double.MAX_VALUE;
                                for (int i = 0; i < withCoords.size(); i++) {
                                        double lat = Double.parseDouble(withCoords.get(i).get("latitude").toString());
                                        double lng = Double.parseDouble(withCoords.get(i).get("longitude").toString());
                                        double dist = haversine(lastLat, lastLng, lat, lng);
                                        if (dist < nearestDist) {
                                                nearestDist = dist;
                                                nearestIdx = i;
                                        }
                                }
                                sorted.add(withCoords.remove(nearestIdx));
                        }
                }
                sorted.addAll(withoutCoords);
                return sorted;
        }

        private boolean hasValidCoords(Map<String, Object> p) {
                Object lat = p.get("latitude");
                Object lng = p.get("longitude");
                return lat != null && lng != null
                                && !lat.toString().isEmpty() && !lng.toString().isEmpty();
        }

        private double haversine(double lat1, double lng1, double lat2, double lng2) {
                double R = 6371;
                double dLat = Math.toRadians(lat2 - lat1);
                double dLng = Math.toRadians(lng2 - lng1);
                double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
                                + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
                                * Math.sin(dLng / 2) * Math.sin(dLng / 2);
                double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
                return R * c;
        }
}