has('date')) { $date = Carbon::parse($request->date, config('app.timezone')); $query->whereBetween('date', [ $date->copy()->startOfDay(), $date->copy()->endOfDay() ]); } // Filter by salesId if ($request->has('salesId')) { $user = User::where('employee_id', $request->salesId)->first(['id']); if ($user) { $query->where('user_id', $user->id); } else { // User not found, return empty result $query->where('user_id', 'non_existent_id'); } } // Filter by status if ($request->has('status')) { $query->where('status', $request->status); } $plans = $query->orderBy('date', 'desc')->get(); $formattedPlans = $plans->map(function ($plan) { return [ 'id' => (string) $plan->id, 'salesId' => $plan->user->employee_id, 'salesName' => $plan->user->name, 'salesColor' => $plan->user->color, 'date' => $plan->date->format('Y-m-d'), 'status' => $plan->status, 'createdAt' => $plan->created_at->toISOString(), 'targets' => $plan->targets->map(fn($t) => [ 'id' => (string) $t->id, 'lat' => (float) $t->latitude, 'lng' => (float) $t->longitude, 'name' => $t->name, 'address' => $t->address ?? '', 'order' => $t->order, 'isCompleted' => $t->is_completed ?? false, 'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null, ]), 'optimizedRoute' => $plan->optimized_route, 'totalDistance' => $plan->total_distance_km, ]; }); return response()->json([ 'success' => true, 'plans' => $formattedPlans, ]); } /** * GET /api/sales-plans/dates * Get available dates */ public function dates(): JsonResponse { // MongoDB Aggregation to get unique dates $rawDates = SalesPlan::raw(function ($collection) { return $collection->aggregate([ [ '$project' => [ 'dateOnly' => ['$dateToString' => ['format' => '%Y-%m-%d', 'date' => '$date']] ] ], [ '$group' => [ '_id' => '$dateOnly' ] ], [ '$sort' => ['_id' => 1] // Ascending for Planner usually, but let's check original. Original was ASC. ] ]); }); $dates = collect($rawDates)->map(function ($item) { return $item['_id']; })->values(); return response()->json([ 'success' => true, 'dates' => $dates, ]); } /** * GET /api/sales-plans/sales * Get sales list */ public function sales(): JsonResponse { $users = User::where('is_active', true) ->where('role', 'sales') ->get(['id', 'employee_id', 'name', 'color']); return response()->json([ 'success' => true, 'salesList' => $users->map(fn($u) => [ 'id' => $u->employee_id, 'name' => $u->name, 'color' => $u->color, ]), ]); } /** * GET /api/sales-plans/{id} * Get single plan */ public function show(string $id): JsonResponse { $plan = SalesPlan::with(['user:id,employee_id,name,color', 'targets'])->find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } return response()->json([ 'success' => true, 'plan' => [ 'id' => (string) $plan->id, 'salesId' => $plan->user->employee_id, 'salesName' => $plan->user->name, 'salesColor' => $plan->user->color, 'date' => $plan->date->format('Y-m-d'), 'status' => $plan->status, 'targets' => $plan->targets->map(fn($t) => [ 'id' => (string) $t->id, 'lat' => (float) $t->latitude, 'lng' => (float) $t->longitude, 'name' => $t->name, 'address' => $t->address ?? '', 'order' => $t->order, 'isCompleted' => $t->is_completed ?? false, 'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null, ]), 'optimizedRoute' => $plan->optimized_route, 'totalDistance' => $plan->total_distance_km, ], ]); } /** * POST /api/sales-plans * Create new plan */ public function store(Request $request): JsonResponse { $request->validate([ 'salesId' => 'required|string', 'date' => 'required|date', 'targets' => 'array', ]); $user = User::where('employee_id', $request->salesId)->first(); if (!$user) { return response()->json(['success' => false, 'error' => 'Invalid salesId'], 400); } $plan = SalesPlan::create([ 'user_id' => $user->id, 'date' => $request->date, 'status' => 'pending', ]); // Create targets $targets = collect($request->targets ?? [])->map(function ($t, $idx) use ($plan) { return PlanTarget::create([ 'sales_plan_id' => $plan->id, 'order' => $idx + 1, 'latitude' => $t['lat'], 'longitude' => $t['lng'], 'name' => $t['name'], 'address' => $t['address'] ?? '', ]); }); return response()->json([ 'success' => true, 'plan' => [ 'id' => (string) $plan->id, 'salesId' => $user->employee_id, 'salesName' => $user->name, 'salesColor' => $user->color, 'date' => $plan->date->format('Y-m-d'), 'status' => $plan->status, 'targets' => $targets->map(fn($t) => [ 'id' => (string) $t->id, 'lat' => (float) $t->latitude, 'lng' => (float) $t->longitude, 'name' => $t->name, 'address' => $t->address, 'order' => $t->order, 'isCompleted' => $t->is_completed ?? false, 'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null, ]), 'optimizedRoute' => null, ], ]); } /** * PUT /api/sales-plans/{id} * Update plan */ public function update(Request $request, string $id): JsonResponse { $plan = SalesPlan::with('user')->find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } if ($request->has('targets')) { // Delete existing targets $plan->targets()->delete(); // Create new targets foreach ($request->targets as $idx => $t) { PlanTarget::create([ 'sales_plan_id' => $plan->id, 'order' => $idx + 1, 'latitude' => $t['lat'], 'longitude' => $t['lng'], 'name' => $t['name'], 'address' => $t['address'] ?? '', ]); } $plan->status = 'pending'; $plan->optimized_route = null; } if ($request->has('date')) { $plan->date = $request->date; } if ($request->has('salesId')) { $user = User::where('employee_id', $request->salesId)->first(); if ($user) { $plan->user_id = $user->id; } } $plan->save(); $plan->load(['user', 'targets']); return response()->json([ 'success' => true, 'plan' => $this->formatPlan($plan), ]); } /** * DELETE /api/sales-plans/{id} * Delete plan */ public function destroy(string $id): JsonResponse { $plan = SalesPlan::find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } $plan->targets()->delete(); $plan->delete(); return response()->json([ 'success' => true, 'message' => 'Plan deleted', ]); } /** * POST /api/sales-plans/{id}/optimize * Optimize route using Nearest Neighbor algorithm */ public function optimize(string $id): JsonResponse { $plan = SalesPlan::with(['user', 'targets'])->find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } $targets = $plan->targets->toArray(); if (count($targets) < 2) { return response()->json(['success' => false, 'error' => 'Need at least 2 targets to optimize'], 400); } // Store original order $originalOrder = array_keys($targets); // Run optimization $result = GeoHelper::optimizeRouteNearestNeighbor($targets); $optimizedOrder = $result['order']; $totalDistance = $result['totalDistance']; // Reorder targets foreach ($optimizedOrder as $newOrder => $originalIdx) { PlanTarget::where('id', $targets[$originalIdx]['id']) ->update(['order' => $newOrder + 1]); } // Update plan $plan->status = 'optimized'; $plan->optimized_at = now(); $plan->optimized_route = $optimizedOrder; $plan->total_distance_km = $totalDistance; $plan->save(); $plan->load('targets'); return response()->json([ 'success' => true, 'plan' => $this->formatPlan($plan), 'optimization' => [ 'originalOrder' => $originalOrder, 'optimizedOrder' => $optimizedOrder, 'totalDistanceKm' => $totalDistance, ], ]); } /** * POST /api/sales-plans/{id}/add-target * Add target to existing plan */ public function addTarget(Request $request, string $id): JsonResponse { $plan = SalesPlan::with('user')->find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } $request->validate([ 'lat' => 'required|numeric', 'lng' => 'required|numeric', 'name' => 'required|string', ]); $maxOrder = $plan->targets()->max('order') ?? 0; $target = PlanTarget::create([ 'sales_plan_id' => $plan->id, 'order' => $maxOrder + 1, 'latitude' => $request->lat, 'longitude' => $request->lng, 'name' => $request->name, 'address' => $request->address ?? '', 'source' => 'mobile_manual', // Differentiate from admin plan ]); $plan->status = 'pending'; $plan->optimized_route = null; $plan->save(); $plan->load('targets'); return response()->json([ 'success' => true, 'plan' => $this->formatPlan($plan), 'newTarget' => [ 'id' => (string) $target->id, 'lat' => (float) $target->latitude, 'lng' => (float) $target->longitude, 'name' => $target->name, 'address' => $target->address, 'order' => $target->order, ], ]); } /** * PUT /api/sales-plans/{id}/target/{targetId} * Update existing target */ public function updateTarget(Request $request, string $id, string $targetId): JsonResponse { $plan = SalesPlan::find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } $target = PlanTarget::where('id', $targetId) ->where('sales_plan_id', $plan->id) ->first(); if (!$target) { return response()->json(['success' => false, 'error' => 'Target not found'], 404); } $request->validate([ 'lat' => 'nullable|numeric', 'lng' => 'nullable|numeric', 'name' => 'nullable|string', ]); if ($request->has('name')) $target->name = $request->name; if ($request->has('address')) $target->address = $request->address; if ($request->has('lat')) $target->latitude = $request->lat; if ($request->has('lng')) $target->longitude = $request->lng; $target->save(); // Mark plan as pending optimization $plan->status = 'pending'; $plan->optimized_route = null; $plan->save(); $plan->load(['user', 'targets']); // Reload user relation too just in case formatPlan needs it return response()->json([ 'success' => true, 'plan' => $this->formatPlan($plan), 'target' => [ 'id' => (string) $target->id, 'lat' => (float) $target->latitude, 'lng' => (float) $target->longitude, 'name' => $target->name, 'address' => $target->address, 'order' => $target->order, 'isCompleted' => $target->is_completed ?? false, 'completedAt' => $target->completed_at ? $target->completed_at->toISOString() : null, ], ]); } /** * DELETE /api/sales-plans/{id}/target/{targetId} * Remove target from plan */ public function removeTarget(string $id, string $targetId): JsonResponse { $plan = SalesPlan::with('user')->find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } $target = PlanTarget::where('id', $targetId) ->where('sales_plan_id', $plan->id) ->first(); if (!$target) { return response()->json(['success' => false, 'error' => 'Target not found'], 404); } $target->delete(); // Reorder remaining targets $plan->targets()->orderBy('order')->get()->each(function ($t, $idx) { $t->update(['order' => $idx + 1]); }); $plan->status = 'pending'; $plan->optimized_route = null; $plan->save(); $plan->load('targets'); return response()->json([ 'success' => true, 'plan' => $this->formatPlan($plan), ]); } /** * POST /api/mobile/schedules/{id}/target/{targetId}/checkin * Check-in to target (mark as completed) */ public function checkinTarget(string $id, string $targetId): JsonResponse { $plan = SalesPlan::with('user')->find($id); if (!$plan) { return response()->json(['success' => false, 'error' => 'Plan not found'], 404); } $target = PlanTarget::where('id', $targetId) ->where('sales_plan_id', $plan->id) ->first(); if (!$target) { return response()->json(['success' => false, 'error' => 'Target not found'], 404); } $target->is_completed = true; $target->completed_at = now(); $target->save(); // Refresh plan $plan->load('targets'); return response()->json([ 'success' => true, 'plan' => $this->formatPlan($plan), ]); } /** * POST /api/sales-plans/assign-target * Auto assign target to user's plan for today (find or create) */ public function autoAssignTarget(Request $request): JsonResponse { $request->validate([ 'staffId' => 'required|string', 'placeData' => 'required|array', 'notes' => 'nullable|string', ]); $user = User::where('employee_id', $request->staffId)->first(); // Fallback: check by mongo ID if not found by employee_id, or just assume input is right ID type if (!$user) { $user = User::find($request->staffId); } if (!$user) { return response()->json(['success' => false, 'error' => 'Staff/User not found'], 404); } // Find or create plan for today in user's timezone (or app default) $today = now()->startOfDay(); $plan = SalesPlan::where('user_id', $user->id) ->whereDate('date', $today) ->first(); if (!$plan) { $plan = SalesPlan::create([ 'user_id' => $user->id, 'date' => $today, 'status' => 'pending', ]); } $place = $request->placeData; // Calculate order $maxOrder = $plan->targets()->max('order') ?? 0; $target = PlanTarget::create([ 'sales_plan_id' => $plan->id, 'order' => $maxOrder + 1, 'latitude' => $place['lat'], 'longitude' => $place['lng'], 'name' => $place['name'], 'address' => $place['address'] ?? '', 'notes' => $request->notes, 'source' => 'web_assign', ]); return response()->json([ 'success' => true, 'message' => 'Target assigned successfully', 'planId' => $plan->id ]); } /** * Format plan for response */ private function formatPlan(SalesPlan $plan): array { return [ 'id' => (string) $plan->id, 'salesId' => $plan->user->employee_id, 'salesName' => $plan->user->name, 'salesColor' => $plan->user->color, 'date' => $plan->date->format('Y-m-d'), 'status' => $plan->status, 'targets' => $plan->targets->map(fn($t) => [ 'id' => (string) $t->id, 'lat' => (float) $t->latitude, 'lng' => (float) $t->longitude, 'name' => $t->name, 'address' => $t->address ?? '', 'order' => $t->order, 'isCompleted' => $t->is_completed ?? false, 'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null, ]), 'optimizedRoute' => $plan->optimized_route, 'totalDistance' => $plan->total_distance_km, ]; } /** * GET /api/mobile/schedules * Get plan for specific user (for mobile app) */ public function getMobileSchedule(Request $request): JsonResponse { $request->validate([ 'user_id' => 'required|string', // Employee ID 'date' => 'nullable|date', ]); // Find user $user = User::where('employee_id', $request->user_id)->first(); if (!$user) { return response()->json(['success' => false, 'error' => 'User not found'], 404); } // Get the date string for comparison (YYYY-MM-DD format) $dateStr = $request->date ?? now()->toDateString(); // Create date range for the entire day in local timezone (Asia/Jakarta) // This handles plans created with different timezone offsets $startOfDay = \Carbon\Carbon::parse($dateStr, config('app.timezone'))->startOfDay(); $endOfDay = \Carbon\Carbon::parse($dateStr, config('app.timezone'))->endOfDay(); try { // First, try to find an existing plan for this user on this date $plan = SalesPlan::where('user_id', $user->id) ->whereBetween('date', [$startOfDay, $endOfDay]) ->first(); // If no plan exists, create one if (!$plan) { $plan = SalesPlan::create([ 'user_id' => $user->id, 'date' => $startOfDay, // Use local timezone start of day 'status' => 'pending', 'total_distance_km' => 0, ]); } $plan->load('targets'); return response()->json([ 'success' => true, 'plan' => $this->formatPlan($plan), ]); } catch (\Throwable $e) { return response()->json([ 'success' => false, 'error' => 'Server Error: ' . $e->getMessage(), ], 500); } } }