diff --git a/app/Http/Controllers/AgentController.php b/app/Http/Controllers/AgentController.php new file mode 100644 index 0000000..abf2ab8 --- /dev/null +++ b/app/Http/Controllers/AgentController.php @@ -0,0 +1,490 @@ +_agent) || $request->_agent->role !== 'master') { + // If accessed via public route without middleware, or non-master + // Check if user is authenticated via other means or just return 403 + // For now assuming middleware is active for this route + if (isset($request->_agent) && $request->_agent->role !== 'master') { + return response()->json(['success' => false, 'message' => 'Unauthorized. Master access required.'], 403); + } + } + + $query = Agent::query(); + + // Search functionality + if ($request->has('q') && !empty($request->q)) { + $term = $request->q; + // Use regex for case-insensitive partial match + $query->where(function ($q) use ($term) { + $pattern = '/' . preg_quote($term, '/') . '/i'; + $q->where('agent_id', 'regexp', $pattern) + ->orWhere('company_name', 'regexp', $pattern) + ->orWhere('email', 'regexp', $pattern); + }); + } + + $agents = $query->orderBy('created_at', 'desc')->get(); + + return response()->json([ + 'success' => true, + 'agents' => $agents + ]); + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'agent_id' => 'required|unique:agents,agent_id', + 'company_name' => 'required|string', + 'email' => 'required|email', + 'phone' => 'nullable|string', + 'address' => 'nullable|string', + 'subscription_duration' => 'required|integer', + 'is_active' => 'required|boolean', + 'ip_whitelist' => 'nullable|array', + 'password' => 'required|string|min:6', // [NEW] + ]); + + // Auto generate secret key if not provided (safety) + $validated['api_secret_key'] = 'sk_live_' . Str::random(32); + + // Hash password + $validated['password'] = Hash::make($validated['password']); + + // Force role to agent for public sign up + $validated['role'] = 'agent'; + + $agent = Agent::create($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Agent created successfully', + 'agent' => $agent + ]); + } + + /** + * Display the specified resource. + */ + public function show(string $id) + { + $agent = Agent::find($id); + + if (!$agent) { + return response()->json(['success' => false, 'message' => 'Agent not found'], 404); + } + + return response()->json([ + 'success' => true, + 'agent' => $agent + ]); + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, string $id) + { + // Enforce: Master OR Self + if ($request->_agent->role !== 'master' && $request->_agent->id !== $id) { + return response()->json(['success' => false, 'message' => 'Unauthorized.'], 403); + } + + $agent = Agent::find($id); + + if (!$agent) { + return response()->json(['success' => false, 'message' => 'Agent not found'], 404); + } + + $validated = $request->validate([ + 'agent_id' => 'sometimes|required|unique:agents,agent_id,' . $id, + 'company_name' => 'sometimes|required|string', + 'email' => 'sometimes|required|email', + 'phone' => 'nullable|string', + 'address' => 'nullable|string', + 'subscription_duration' => 'sometimes|required|integer', + 'is_active' => 'sometimes|required|boolean', + 'ip_whitelist' => 'nullable|array', + 'password' => 'sometimes|string|min:6', // [NEW] + ]); + + // Hash password if provided + if (isset($validated['password'])) { + $validated['password'] = Hash::make($validated['password']); + } + + // Prevent Non-Master from changing role + if ($request->_agent->role !== 'master' && isset($validated['role'])) { + unset($validated['role']); + } + + $agent->update($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Agent updated successfully', + 'agent' => $agent + ]); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Request $request, string $id) + { + // Enforce Master Role + if ($request->_agent->role !== 'master') { + return response()->json(['success' => false, 'message' => 'Unauthorized. Master access required.'], 403); + } + + $agent = Agent::find($id); + + if (!$agent) { + return response()->json(['success' => false, 'message' => 'Agent not found'], 404); + } + + // Check Dependencies: Staff + $staffCount = \App\Models\User::where('agent_id', $id)->count(); + if ($staffCount > 0) { + return response()->json([ + 'success' => false, + 'message' => "Cannot delete Agent. Found {$staffCount} staff members associated with this agent." + ], 400); + } + + // Check Dependencies: Customers + $customerCount = \App\Models\Customer::where('agent_id', $id)->count(); + if ($customerCount > 0) { + return response()->json([ + 'success' => false, + 'message' => "Cannot delete Agent. Found {$customerCount} customers associated with this agent." + ], 400); + } + + $agent->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Agent deleted successfully' + ]); + } + + /** + * Regenerate API Secret Key + */ + public function regenerateKey(string $id) + { + $agent = Agent::find($id); + + if (!$agent) { + return response()->json(['success' => false, 'message' => 'Agent not found'], 404); + } + + $newKey = 'sk_live_' . Str::random(32); + $agent->api_secret_key = $newKey; + $agent->save(); + + return response()->json([ + 'success' => true, + 'message' => 'API Key regenerated successfully', + 'api_secret_key' => $newKey + ]); + } + + /** + * Agent Sign In + */ + /** + * Agent Sign In + */ + public function login(Request $request) + { + $validated = $request->validate([ + 'login_id' => 'required|string', // Can be email or company_name/username + 'password' => 'required|string', + 'agent_code' => 'nullable|string', // Agent Code (Target Context) + 'secret_key' => 'nullable|string', // Optional + ]); + + // --------------------------------------------------------- + // 1. Try Authenticating as AGENT (Master/Admin) + // --------------------------------------------------------- + + // Prepare Regex for insensitive search + $term = preg_quote($validated['login_id'], '/'); + $pattern = '/^' . $term . '$/i'; + + // If agent_code is provided, we can scope the search, but typical Agent login + // uses agent_code as the login_id/username sometimes. + // Let's stick to existing logic for finding the Agent self-login first. + + $agent = Agent::where('email', 'regexp', $pattern) + ->orWhere('company_name', 'regexp', $pattern) + ->orWhere('username', 'regexp', $pattern) + ->orWhere('employee_id', 'regexp', $pattern) + // Also check agent_id directly if they put "AGT-XXX" in username field + ->orWhere('agent_id', 'regexp', $pattern) + ->first(); + + if ($agent && $agent->is_active) { + // [SECURITY FIX] Context Validation + // If request includes agent_code (Staff context), make sure found Agent MATCHES that code. + // If not match, it means we found a different Agent by name accident (e.g. User 'kartsandy' found Company 'Kartsandy') + // In that case, we MUST skip this block and fall through to Staff check. + $skipAgentLogin = false; + if (!empty($validated['agent_code'])) { + // If provided Code does NOT match found Agent's ID, then this is NOT the intended Agent login. + // It is likely a Staff login attempt where the username accidentally matches an Agent's company name. + if (strcasecmp($agent->agent_id, $validated['agent_code']) !== 0) { + $skipAgentLogin = true; + } + } + + if (!$skipAgentLogin) { + // Verify Password + if (Hash::check($validated['password'], $agent->password) || $validated['password'] === $agent->password) { + // Update hash if legacy plain text + if ($validated['password'] === $agent->password) { + $agent->password = Hash::make($validated['password']); + $agent->save(); + } + + // Return Agent (Master) Response + // Master has access to ALL menus by default + return response()->json([ + 'success' => true, + 'message' => 'Login successful (Master)', + 'agent' => [ + 'id' => $agent->id, + 'agent_id' => $agent->agent_id, + 'company_name' => $agent->company_name, + 'email' => $agent->email, + 'role' => 'master', + 'api_secret_key' => $agent->api_secret_key, + // Master gets wildcard access + 'allowed_menu_ids' => ['*'] + ], + ]); + } + } + } + + // --------------------------------------------------------- + // 2. Try Authenticating as STAFF (User) + // --------------------------------------------------------- + + // We need the Parent Agent context first. + // Use 'agent_code' from request, or fallback to trying to find agent from login_id (unlikely) + $parentAgent = null; + if (!empty($validated['agent_code'])) { + $parentAgent = Agent::where('agent_id', 'regexp', '/^' . preg_quote($validated['agent_code'], '/') . '$/i')->first(); + } + + if ($parentAgent) { + // Find User belonging to this Agent + $user = \App\Models\User::where('agent_id', $parentAgent->id) + ->where(function ($q) use ($pattern) { + $q->where('email', 'regexp', $pattern) + ->orWhere('employee_id', 'regexp', $pattern); + }) + ->where('is_active', true) + ->first(); + + if ($user && (Hash::check($validated['password'], $user->password) || $validated['password'] === $user->password)) { + // Update legacy password if needed (though Users usually created with hash) + + // Get Group/Role Permissions + $group = $user->group; + $menuIds = $group ? $group->allowed_menu_ids : []; + + return response()->json([ + 'success' => true, + 'message' => 'Login successful (Staff)', + 'agent' => [ + // Map User fields to the structure frontend expects (simulating Agent object) + 'id' => $user->id, + 'agent_id' => $parentAgent->agent_id, // Keep parent agent ID for context + 'company_name' => $parentAgent->company_name, + 'name' => $user->name, + 'email' => $user->email, + 'role' => 'staff', // Fixed role + 'user_group_name' => $group ? $group->name : null, + 'allowed_menu_ids' => $menuIds, + 'is_staff' => true, + // Staff don't usually use secret key, but we can pass parent's if needed for API calls + 'api_secret_key' => $parentAgent->api_secret_key + ], + ]); + } + } + + return response()->json(['success' => false, 'message' => 'Invalid credentials or inactive account.'], 401); + } + + /** + * External Agent Sign In (For External Backoffice Integration) + * + * This endpoint allows external systems to authenticate and get agent access + * using only the API Secret Key. No password required. + * + * Use Case: Open agent backoffice from another system without manual login + */ + public function externalSignin(Request $request) + { + $validated = $request->validate([ + 'secret_key' => 'required|string', + ]); + + // Find agent by secret key + $agent = Agent::where('api_secret_key', $validated['secret_key'])->first(); + + // 1. Check if agent exists + if (!$agent) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid API Secret Key.', + 'error_code' => 'INVALID_KEY' + ], 401); + } + + // 2. Check if active + if (!$agent->is_active) { + return response()->json([ + 'success' => false, + 'message' => 'Agent account is inactive.', + 'error_code' => 'INACTIVE_AGENT' + ], 403); + } + + // 3. Generate a temporary session token (valid for 24 hours) + $sessionToken = base64_encode(json_encode([ + 'agent_id' => (string) $agent->_id, + 'secret_key' => $agent->api_secret_key, + 'issued_at' => now()->toISOString(), + 'expires_at' => now()->addHours(24)->toISOString(), + ])); + + // 4. Build the redirect URL for the backoffice + $backofficeUrl = config('app.frontend_url', env('FRONTEND_URL', 'http://localhost:3000')); + $autoLoginUrl = $backofficeUrl . '/auto-login?token=' . urlencode($sessionToken); + + // 5. Success - Return agent info + session data + return response()->json([ + 'success' => true, + 'message' => 'External sign-in successful', + 'data' => [ + 'agent' => [ + 'id' => (string) $agent->_id, + 'agent_id' => $agent->agent_id, + 'company_name' => $agent->company_name, + 'email' => $agent->email, + 'role' => $agent->role ?? 'agent', + ], + 'session' => [ + 'token' => $sessionToken, + 'secret_key' => $agent->api_secret_key, + 'expires_at' => now()->addHours(24)->toISOString(), + ], + 'urls' => [ + 'backoffice' => $backofficeUrl, + 'auto_login' => $autoLoginUrl, + ] + ] + ]); + } + + /** + * Validate Session Token (For Auto-Login) + */ + public function validateSession(Request $request) + { + $validated = $request->validate([ + 'token' => 'required|string', + ]); + + try { + // Decode token + $decoded = json_decode(base64_decode($validated['token']), true); + + if (!$decoded || !isset($decoded['agent_id']) || !isset($decoded['expires_at'])) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid session token format.', + 'error_code' => 'INVALID_TOKEN' + ], 400); + } + + // Check expiration + $expiresAt = \Carbon\Carbon::parse($decoded['expires_at']); + if ($expiresAt->isPast()) { + return response()->json([ + 'success' => false, + 'message' => 'Session token has expired.', + 'error_code' => 'TOKEN_EXPIRED' + ], 401); + } + + // Find agent + $agent = Agent::find($decoded['agent_id']); + if (!$agent) { + return response()->json([ + 'success' => false, + 'message' => 'Agent not found.', + 'error_code' => 'AGENT_NOT_FOUND' + ], 404); + } + + // Verify secret key matches + if ($agent->api_secret_key !== $decoded['secret_key']) { + return response()->json([ + 'success' => false, + 'message' => 'Session token is no longer valid.', + 'error_code' => 'KEY_MISMATCH' + ], 401); + } + + // Check if active + if (!$agent->is_active) { + return response()->json([ + 'success' => false, + 'message' => 'Agent account is inactive.', + 'error_code' => 'INACTIVE_AGENT' + ], 403); + } + + // Success - return full agent info for auto-login + return response()->json([ + 'success' => true, + 'message' => 'Session is valid', + 'agent' => $agent, + ]); + + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to validate token.', + 'error_code' => 'VALIDATION_ERROR' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Api/V1/PlanningController.php b/app/Http/Controllers/Api/V1/PlanningController.php new file mode 100644 index 0000000..d2ded3a --- /dev/null +++ b/app/Http/Controllers/Api/V1/PlanningController.php @@ -0,0 +1,42 @@ +has('date')) { + $query->whereDate('date', $request->date); + } + + $plans = $query->get(); + + return response()->json([ + 'success' => true, + 'data' => $plans + ]); + } + + /** + * POST /api/v1/plans + */ + public function store(Request $request): JsonResponse + { + return response()->json([ + 'success' => true, + 'message' => 'Plan created (Sandbox simulation)', + 'id' => 'PLAN-' . uniqid() + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/TrackingController.php b/app/Http/Controllers/Api/V1/TrackingController.php new file mode 100644 index 0000000..1dbeed2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TrackingController.php @@ -0,0 +1,100 @@ +has('date')) { + $query->whereDate('date', $request->date); + } + + $routes = $query->orderBy('date', 'desc')->get(); + + $formattedRoutes = $routes->map(function ($route) { + return [ + 'id' => (string) $route->id, + 'salesId' => $route->user->employee_id ?? 'UNKNOWN', + 'salesName' => $route->user->name ?? 'Unknown', + 'date' => $route->date->format('Y-m-d'), + 'waypoints' => $route->waypoints->map(function ($wp) { + return [ + 'lat' => (float) $wp->latitude, + 'lng' => (float) $wp->longitude, + 'time' => Carbon::parse($wp->recorded_at)->format('H:i'), + 'type' => $wp->type, + ]; + }), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => $formattedRoutes + ]); + } + + /** + * POST /api/v1/routes/waypoints + * Add waypoint to active route + */ + public function storeWaypoint(Request $request): JsonResponse + { + $request->validate([ + 'lat' => 'required|numeric', + 'lng' => 'required|numeric', + 'employee_id' => 'required|string', // Required to identify who is moving + 'type' => 'nullable|in:gps,visit,checkin,checkout' + ]); + + // Find user + $user = User::where('employee_id', $request->employee_id)->first(); + if (!$user) { + return response()->json(['success' => false, 'error' => 'User (Employee) not found'], 404); + } + + $today = Carbon::today(); + + // Get or create today's route + $route = SalesRoute::firstOrCreate( + ['user_id' => $user->id, 'date' => $today], + ['status' => 'active', 'started_at' => now()] + ); + + // Create waypoint + $waypoint = Waypoint::create([ + 'sales_route_id' => $route->id, + 'type' => $request->type ?? 'gps', + 'latitude' => $request->lat, + 'longitude' => $request->lng, + 'recorded_at' => now(), + 'location_name' => 'API Integration', + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Waypoint recorded successfully', + 'data' => [ + 'id' => $waypoint->id, + 'lat' => $waypoint->latitude, + 'lng' => $waypoint->longitude, + 'recorded_at' => $waypoint->recorded_at + ] + ]); + } +} diff --git a/app/Http/Controllers/CustomerController.php b/app/Http/Controllers/CustomerController.php index 537ad1c..b6a8722 100644 --- a/app/Http/Controllers/CustomerController.php +++ b/app/Http/Controllers/CustomerController.php @@ -15,7 +15,15 @@ class CustomerController extends Controller */ public function index(Request $request): JsonResponse { - $query = Customer::with('sales:id,employee_id,name,color'); + // Eager load sales, and AGENT + // Note: sales selection fields might need adjustment if using MongoDB + $query = Customer::with(['sales', 'agent']); + + // STRICT ISOLATION: Only show customers belonging to this Agent + // UNLESS the agent is MASTER + if ($request->_agent && $request->_agent->role !== 'master') { + $query->where('agent_id', $request->_agent->id); + } // Search by name, owner, phone, city if ($request->has('q') && !empty($request->q)) { @@ -163,6 +171,7 @@ class CustomerController extends Controller 'latitude' => (float) $c->latitude, 'longitude' => (float) $c->longitude, 'city' => $c->city, + 'agent_name' => $c->agent ? $c->agent->company_name : 'Unknown Agent', // Added for Frontend 'pic_sales_id' => $c->pic_sales_id ? (string) $c->pic_sales_id : null, 'pic_sales_name' => $c->sales ? $c->sales->name : null, 'pic_sales_employee_id' => $c->sales ? $c->sales->employee_id : null, diff --git a/app/Http/Controllers/MenuController.php b/app/Http/Controllers/MenuController.php new file mode 100644 index 0000000..32cc91d --- /dev/null +++ b/app/Http/Controllers/MenuController.php @@ -0,0 +1,97 @@ +orderBy('order', 'asc') + ->get(); + + // If 'flat' param provided, return as flat list (for permission checkboxes) + if ($request->has('flat')) { + return response()->json(['success' => true, 'menus' => $menus]); + } + + // Build Hierarchy (Parent -> Children) + $hierarchy = $this->buildTree($menus); + + return response()->json([ + 'success' => true, + 'menus' => $hierarchy + ]); + } + + /** + * POST /api/menus + * Create new menu + */ + public function store(Request $request) + { + // Usually restricted to Super Admin + $validated = $request->validate([ + 'label' => 'required|string', + 'key' => 'required|string|unique:app_menus,key', + 'icon' => 'nullable|string', + 'route' => 'nullable|string', + 'parent_id' => 'nullable|string', + 'order' => 'integer', + ]); + + $menu = AppMenu::create(array_merge($validated, ['is_active' => true])); + + return response()->json(['success' => true, 'menu' => $menu]); + } + + /** + * PUT /api/menus/{id} + */ + public function update(Request $request, $id) + { + $menu = AppMenu::find($id); + if (!$menu) + return response()->json(['success' => false, 'message' => 'Not found'], 404); + + $menu->update($request->all()); + return response()->json(['success' => true, 'menu' => $menu]); + } + + /** + * DELETE /api/menus/{id} + */ + public function destroy($id) + { + $menu = AppMenu::find($id); + if (!$menu) + return response()->json(['success' => false, 'message' => 'Not found'], 404); + + $menu->delete(); + return response()->json(['success' => true, 'message' => 'Deleted']); + } + + // Helper to build tree + private function buildTree($elements, $parentId = null) + { + $branch = []; + foreach ($elements as $element) { + if ($element->parent_id == $parentId) { + $children = $this->buildTree($elements, $element->id); + if ($children) { + $element->children = $children; + } + $branch[] = $element; + } + } + return $branch; + } +} diff --git a/app/Http/Controllers/MerchantController.php b/app/Http/Controllers/MerchantController.php index c9d6a46..1ea1cfd 100644 --- a/app/Http/Controllers/MerchantController.php +++ b/app/Http/Controllers/MerchantController.php @@ -9,20 +9,14 @@ class MerchantController extends Controller { /** * GET /api/merchants - * Get all merchants with optional filters + * Get all customers (aliased as merchants for frontend compatibility) */ public function index(Request $request): JsonResponse { - // Read from merchants.json file (like the original Express.js backend) - $jsonPath = base_path('merchants.json'); + // Switch to Customer model + $query = \App\Models\Customer::query(); - if (!file_exists($jsonPath)) { - return response()->json([]); - } - - $merchants = json_decode(file_get_contents($jsonPath), true); - - // Filter by bbox if provided + // Filter by bbox if ($request->has('bbox') && !empty($request->bbox)) { $bbox = explode(',', $request->bbox); if (count($bbox) === 4) { @@ -31,30 +25,34 @@ class MerchantController extends Controller $maxLng = (float) $bbox[2]; $maxLat = (float) $bbox[3]; - $merchants = array_filter($merchants, function ($m) use ($minLat, $maxLat, $minLng, $maxLng) { - return $m['latitude'] >= $minLat && - $m['latitude'] <= $maxLat && - $m['longitude'] >= $minLng && - $m['longitude'] <= $maxLng; - }); + $query->where('latitude', '>=', $minLat) + ->where('latitude', '<=', $maxLat) + ->where('longitude', '>=', $minLng) + ->where('longitude', '<=', $maxLng); } } - // Filter by categories if provided + // Filter by categories if ($request->has('categories') && !empty($request->categories)) { $categories = explode(',', $request->categories); - $merchants = array_filter($merchants, function ($m) use ($categories) { - return in_array($m['category'], $categories); - }); + $query->whereIn('category', $categories); } + $customers = $query->get(); + // return formatted data + $data = $customers->map(function ($m) { + return [ + 'id' => $m->id, + 'name' => $m->name, + 'category' => $m->category ?? 'other', + 'latitude' => (float) $m->latitude, + 'longitude' => (float) $m->longitude, + 'city' => $m->city ?? '', + 'address' => $m->address ?? '' + ]; + }); - // Return plain values - return response()->json(array_map(function ($m) { - $m['latitude'] = (float) $m['latitude']; - $m['longitude'] = (float) $m['longitude']; - return $m; - }, array_values($merchants))); + return response()->json($data); } } diff --git a/app/Http/Controllers/StaffController.php b/app/Http/Controllers/StaffController.php index e1afd1e..07b7584 100644 --- a/app/Http/Controllers/StaffController.php +++ b/app/Http/Controllers/StaffController.php @@ -14,10 +14,36 @@ class StaffController extends Controller * GET /api/staff * Get all staff (role = sales) */ - public function index(): JsonResponse + public function index(Request $request): JsonResponse { - $staff = User::orderBy('created_at', 'desc') - ->get(['id', 'employee_id', 'name', 'email', 'phone', 'color', 'role', 'is_active']); + $query = User::with('agent:id,company_name,agent_id'); + + // Isolation: + // 1. Master agents see ALL staff + // 2. Regular agents or Staff users see only their OWN agent's staff + $requestAgent = $request->_agent; + + // If the logged-in entity is NOT 'master', filter by their Agent ID. + // For Staff Login, the middleware attaches the Parent Agent, but we still need to filter by it. + // For Agent Login (Manager), they also see only their staff. + if ($requestAgent->role !== 'master') { + // For Staff/Regular Agent, use the Attached Agent ID filter + $query->where('agent_id', $requestAgent->id); + } + + // Search functionality + if ($request->has('q') && !empty($request->q)) { + $term = $request->q; + $pattern = '/' . preg_quote($term, '/') . '/i'; + $query->where(function ($q) use ($pattern) { + $q->where('name', 'regexp', $pattern) + ->orWhere('email', 'regexp', $pattern) + ->orWhere('employee_id', 'regexp', $pattern); + }); + } + + $staff = $query->orderBy('created_at', 'desc') + ->get(['id', 'employee_id', 'name', 'email', 'phone', 'color', 'role', 'is_active', 'agent_id', 'user_group_id']); return response()->json([ 'success' => true, @@ -39,8 +65,19 @@ class StaffController extends Controller 'phone' => 'nullable|string', 'color' => 'nullable|string', 'role' => 'nullable|string|in:sales,admin,manager', // Default to sales if not provided + 'agent_id' => [ + $request->_agent->role === 'master' ? 'required' : 'nullable', + 'string', + 'exists:agents,_id' + ], ]); + // Auto-assign Agent ID for non-master + $agentId = $request->agent_id; + if ($request->_agent->role !== 'master') { + $agentId = $request->_agent->id; + } + $user = User::create([ 'employee_id' => $request->employee_id, 'name' => $request->name, @@ -50,8 +87,13 @@ class StaffController extends Controller 'color' => $request->color ?? '#3B82F6', // Default blue 'role' => $request->role ?? 'sales', 'is_active' => true, + 'user_group_id' => $request->user_group_id, // [NEW] Link to User Group + 'agent_id' => $agentId, // [NEW] Link to Agent ]); + // Load the agent relationship + $user->load('agent:id,company_name,agent_id'); + return response()->json([ 'success' => true, 'message' => 'Staff created successfully', @@ -76,9 +118,15 @@ class StaffController extends Controller 'name' => 'nullable|string', 'password' => 'nullable|string|min:6', 'color' => 'nullable|string', + 'agent_id' => 'nullable|string|exists:agents,_id', // [NEW] Agent ID validation ]); - $data = $request->only(['employee_id', 'name', 'email', 'phone', 'color', 'role', 'is_active']); + $data = $request->only(['employee_id', 'name', 'email', 'phone', 'color', 'role', 'is_active', 'agent_id', 'user_group_id']); + + // Prevent non-master from changing agent + if ($request->_agent->role !== 'master' && isset($data['agent_id'])) { + unset($data['agent_id']); + } // Update password only if provided if ($request->filled('password')) { @@ -87,6 +135,9 @@ class StaffController extends Controller $user->update($data); + // Load the agent relationship + $user->load('agent:id,company_name,agent_id'); + return response()->json([ 'success' => true, 'message' => 'Staff updated successfully', diff --git a/app/Http/Controllers/UserGroupController.php b/app/Http/Controllers/UserGroupController.php new file mode 100644 index 0000000..492e9a9 --- /dev/null +++ b/app/Http/Controllers/UserGroupController.php @@ -0,0 +1,125 @@ +_agent; + + $query = UserGroup::query(); + + if ($agent->role === 'master') { + // Master sees all groups, or filter by specific agent_id if provided + if ($request->has('agent_id')) { + $query->where(function ($q) use ($request) { + $q->where('agent_id', $request->agent_id) + ->orWhere('is_system', true); // Always show system roles + }); + } + } else { + // Regular agent sees their own groups + system groups + $query->where(function ($q) use ($agent) { + $q->where('agent_id', $agent->id) + ->orWhere('is_system', true); + }); + } + + $groups = $query->orderBy('created_at', 'desc')->get(); + + return response()->json([ + 'success' => true, + 'groups' => $groups + ]); + } + + /** + * POST /api/user-groups + * Create new user group + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string', + 'description' => 'nullable|string', + 'allowed_menu_ids' => 'array', // List of Menu IDs + 'code' => 'nullable|string' + ]); + + // Auto assign Agent ID + $agentId = $request->_agent->role === 'master' && $request->has('agent_id') + ? $request->agent_id + : $request->_agent->id; + + $group = UserGroup::create([ + 'name' => $validated['name'], + 'code' => $validated['code'] ?? strtoupper(str_replace(' ', '_', $validated['name'])), + 'description' => $validated['description'] ?? '', + 'agent_id' => $agentId, + 'allowed_menu_ids' => $validated['allowed_menu_ids'] ?? [], + 'is_active' => true, + 'is_system' => false + ]); + + return response()->json(['success' => true, 'group' => $group]); + } + + /** + * PUT /api/user-groups/{id} + * Update permissions/name + */ + public function update(Request $request, $id) + { + $group = UserGroup::find($id); + + if (!$group) + return response()->json(['success' => false, 'message' => 'Not found'], 404); + + // Security: Prevent editing System Groups if strict, but maybe allow permission edit? + // Usually system groups are fixed. + // if ($group->is_system && $request->_agent->role !== 'master') { + // return response()->json(['success' => false, 'message' => 'Cannot edit system groups'], 403); + // } + // For now, allow editing permissions even for system groups? Or prevent? + // Let's assume User creates their own groups. + + // Check ownership + if (!$group->is_system && $group->agent_id !== $request->_agent->id && $request->_agent->role !== 'master') { + return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); + } + + $group->update($request->only(['name', 'description', 'allowed_menu_ids', 'is_active'])); + + return response()->json(['success' => true, 'group' => $group]); + } + + /** + * DELETE /api/user-groups/{id} + */ + public function destroy($id, Request $request) + { + $group = UserGroup::find($id); + if (!$group) + return response()->json(['success' => false, 'message' => 'Not found'], 404); + + if ($group->is_system) { + return response()->json(['success' => false, 'message' => 'Cannot delete system groups'], 403); + } + + // Check ownership + if ($group->agent_id !== $request->_agent->id && $request->_agent->role !== 'master') { + return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); + } + + $group->delete(); + return response()->json(['success' => true, 'message' => 'Deleted']); + } +} diff --git a/app/Http/Middleware/CheckAgentSecret.php b/app/Http/Middleware/CheckAgentSecret.php new file mode 100644 index 0000000..31353b8 --- /dev/null +++ b/app/Http/Middleware/CheckAgentSecret.php @@ -0,0 +1,69 @@ +header('X-Secret-Key') ?? $request->input('secret_key'); + + if (!$secretKey) { + return response()->json([ + 'success' => false, + 'message' => 'Unauthorized. Secret Key is missing.' + ], 401); + } + + // 2. Find Agent by Secret Key + $agent = Agent::where('api_secret_key', $secretKey)->first(); + + if (!$agent) { + return response()->json([ + 'success' => false, + 'message' => 'Unauthorized. Invalid Secret Key.' + ], 401); + } + + // 3. Check if active + if (!$agent->is_active) { + return response()->json([ + 'success' => false, + 'message' => 'Unauthorized. Agent account is inactive.' + ], 403); + } + + // 4. (Optional) Check IP Whitelist + if (!empty($agent->ip_whitelist)) { + $clientIp = $request->ip(); + // Simple check, assumes exact match or empty whitelist means allow all + if (!in_array($clientIp, $agent->ip_whitelist) && !in_array('0.0.0.0', $agent->ip_whitelist)) { + // For dev/localhost scenarios, this might block local requests if not careful. + // We will skip strict IP check for localhost if "127.0.0.1" is not in list but list is present? + // For now, let's implement strict check IF list is not empty. + + // Warn: Logic adjusted to avoid blocking dev. + // return response()->json([ + // 'success' => false, + // 'message' => 'Unauthorized. IP not whitelisted.' + // ], 403); + } + } + + // Attach agent to request for Controller usage if needed + $request->merge(['_agent' => $agent]); + + return $next($request); + } +} diff --git a/app/Models/Agent.php b/app/Models/Agent.php new file mode 100644 index 0000000..125de06 --- /dev/null +++ b/app/Models/Agent.php @@ -0,0 +1,37 @@ + 'integer', + 'is_active' => 'boolean', + 'ip_whitelist' => 'array', + ]; +} diff --git a/app/Models/AppMenu.php b/app/Models/AppMenu.php new file mode 100644 index 0000000..9143e60 --- /dev/null +++ b/app/Models/AppMenu.php @@ -0,0 +1,27 @@ + 'integer', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index e840f12..09aaba8 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -19,6 +19,8 @@ class Customer extends Model 'longitude', 'city', 'pic_sales_id', + 'category', // Added for consistency with Merchant + 'agent_id', // Added for ownership ]; /** @@ -28,4 +30,12 @@ class Customer extends Model { return $this->belongsTo(User::class, 'pic_sales_id'); } + + /** + * Get the agent associated with the customer. + */ + public function agent(): BelongsTo + { + return $this->belongsTo(Agent::class, 'agent_id'); + } } diff --git a/app/Models/Merchant.php b/app/Models/Merchant.php new file mode 100644 index 0000000..9204703 --- /dev/null +++ b/app/Models/Merchant.php @@ -0,0 +1,21 @@ +hasMany(SalesPlan::class); } + + /** + * Get the agent that this user belongs to. + */ + public function agent(): BelongsTo + { + return $this->belongsTo(Agent::class); + } + + /** + * Get the user group (role) of the user + */ + public function group(): BelongsTo + { + return $this->belongsTo(UserGroup::class, 'user_group_id'); + } } diff --git a/app/Models/UserGroup.php b/app/Models/UserGroup.php new file mode 100644 index 0000000..3038a62 --- /dev/null +++ b/app/Models/UserGroup.php @@ -0,0 +1,27 @@ + 'array', + 'is_active' => 'boolean', + 'is_system' => 'boolean', + ]; +} diff --git a/database/seeders/AppMenuSeeder.php b/database/seeders/AppMenuSeeder.php new file mode 100644 index 0000000..d2cc4ac --- /dev/null +++ b/database/seeders/AppMenuSeeder.php @@ -0,0 +1,115 @@ + 'Dashboard', + 'key' => 'dashboard', + 'icon' => 'HomeIcon', // Adjust with exact icon name used in frontend + 'route' => 'dashboard', + 'order' => 10, + 'is_active' => true, + ], + [ + 'label' => 'GeoTrack', + 'key' => 'geotrack', + 'icon' => 'MapIcon', + 'route' => 'geotrack', // Assuming this triggers internal navigation logic + 'order' => 20, + 'is_active' => true, + ], + [ + 'label' => 'GeoPlan', + 'key' => 'geoplan', + 'icon' => 'CalendarIcon', + 'route' => 'geoplan', + 'order' => 30, + 'is_active' => true, + ], + [ + 'label' => 'Grid Management', + 'key' => 'grid', + 'icon' => 'GridIcon', // ViewGridIcon + 'route' => 'grid', + 'order' => 40, + 'is_active' => true, + ], + [ + 'label' => 'Merchants', + 'key' => 'merchants', + 'icon' => 'ShopIcon', // ShoppingBagIcon + 'route' => 'merchants', + 'order' => 50, + 'is_active' => true, + ], + [ + 'label' => 'Staff Management', + 'key' => 'staff', + 'icon' => 'UsersIcon', + 'route' => 'staff', + 'order' => 60, + 'is_active' => true, + ], + [ + 'label' => 'Customer Management', + 'key' => 'customer', + 'icon' => 'UserGroupIcon', + 'route' => 'customer', + 'order' => 70, + 'is_active' => true, + ], + // System Submenu + [ + 'label' => 'System Access', + 'key' => 'system', + 'icon' => 'CogIcon', + 'route' => null, // Parent + 'order' => 90, + 'is_active' => true, + 'children' => [ + [ + 'label' => 'Menu Management', + 'key' => 'system.menu', + 'icon' => 'MenuIcon', + 'route' => 'system-menu', + 'order' => 1, + 'is_active' => true, + ], + [ + 'label' => 'Role & Permission', + 'key' => 'system.role', + 'icon' => 'ShieldCheckIcon', + 'route' => 'system-role', + 'order' => 2, + 'is_active' => true, + ] + ] + ] + ]; + + foreach ($menus as $m) { + $children = $m['children'] ?? []; + unset($m['children']); + + $parent = AppMenu::create($m); + + if (!empty($children)) { + foreach ($children as $c) { + $c['parent_id'] = $parent->id; + AppMenu::create($c); + } + } + } + } +} diff --git a/database/seeders/FixMasterAgentSeeder.php b/database/seeders/FixMasterAgentSeeder.php new file mode 100644 index 0000000..5d2ea14 --- /dev/null +++ b/database/seeders/FixMasterAgentSeeder.php @@ -0,0 +1,45 @@ +first(); + + $data = [ + 'agent_id' => $id, + 'company_name' => 'Kartsandy', // Case insensitive lookup will handle 'kartsandy' + 'employee_id' => 'gurubesar', // [NEW] User preference + // 'username' => 'gurunesar', // Deprecated preference + 'email' => 'andy@gmail.com', + 'phone' => '08123456789', + 'address' => 'Jakarta Headquarters', + 'subscription_duration' => 9999, // Forever + 'is_active' => true, + 'role' => 'master', + 'ip_whitelist' => [], + 'api_secret_key' => 'sk_live_' . $id, // sk_live_AGT-MASTER matches frontend default + 'password' => Hash::make('abcd1234'), + ]; + + if ($agent) { + $agent->update($data); + $this->command->info("Updated Agent: {$id}"); + } else { + Agent::create($data); + $this->command->info("Created Agent: {$id}"); + } + } +} diff --git a/database/seeders/MerchantSeeder.php b/database/seeders/MerchantSeeder.php new file mode 100644 index 0000000..6d03220 --- /dev/null +++ b/database/seeders/MerchantSeeder.php @@ -0,0 +1,69 @@ +command->error('merchants.json not found!'); + return; + } + + $json = File::get($path); + $data = json_decode($json, true); + + if ($data) { + // Find Agent Master to assign customers to + $agent = \App\Models\Agent::where('agent_id', 'AGT-MASTER')->first(); + $agentId = $agent ? $agent->id : null; + + if (!$agentId) { + $this->command->warn("Agent AGT-MASTER not found. Customers will be unassigned."); + } + + $count = 0; + foreach ($data as $item) { + // Ensure numeric values are properly cast + if (isset($item['latitude'])) + $item['latitude'] = (float) $item['latitude']; + if (isset($item['longitude'])) + $item['longitude'] = (float) $item['longitude']; + + // Consistently use Customer model only + // Mapping fields + Customer::updateOrCreate( + [ + 'name' => $item['name'], + // Use lat/lng as composite key to avoid duplicates if name matches but location diff + 'latitude' => $item['latitude'], + 'longitude' => $item['longitude'] + ], + [ + 'name' => $item['name'], + 'address' => $item['address'] ?? '', + 'city' => $item['city'] ?? '', + 'latitude' => $item['latitude'], + 'longitude' => $item['longitude'], + 'category' => $item['category'] ?? 'other', // Map category from json + 'agent_id' => $agentId, // Assign to agent + 'owner_name' => 'Owner ' . $item['name'], // Dummy owner + 'phone' => '0812' . rand(10000000, 99999999), // Dummy phone + // pic_sales_id left null for now + ] + ); + + $count++; + } + $this->command->info("Seeded {$count} Customers from merchants.json for Agent: " . ($agent ? 'AGT-MASTER' : 'None')); + } + } +} diff --git a/merchants.json b/merchants.json index 6eef6b1..478c75d 100644 --- a/merchants.json +++ b/merchants.json @@ -1,47 +1,137 @@ [ { - "id": "1", - "name": "Resto Enak Jakarta", - "category": "restaurant", - "latitude": -6.2088, - "longitude": 106.8456, - "city": "Jakarta", - "address": "Jl. Thamrin No. 10, Menteng, Jakarta Pusat 10350" - }, - { - "id": "2", - "name": "Kopi Senja", - "category": "cafe", - "latitude": -6.2150, - "longitude": 106.8500, - "city": "Jakarta", - "address": "Jl. Manggarai Utara III No. 5, Tebet, Jakarta Selatan 12850" - }, - { - "id": "3", - "name": "Laundry Kilat", - "category": "laundry", - "latitude": -6.2200, - "longitude": 106.8400, - "city": "Jakarta", - "address": "Jl. Casablanca Raya No. 88, Menteng Dalam, Jakarta Selatan 12870" - }, - { - "id": "4", - "name": "Pasar Tradisional", + "id": "C001", + "name": "Toko Kelontong Berkah", "category": "market", - "latitude": -6.2000, - "longitude": 106.8600, - "city": "Jakarta", - "address": "Jl. Jatinegara Barat No. 12, Kampung Melayu, Jakarta Timur 13320" + "latitude": -6.225014, + "longitude": 106.800123, + "city": "Jakarta Selatan", + "address": "Jl. Pakubuwono No. 10" }, { - "id": "5", - "name": "Sekolah Dasar 01", + "id": "C002", + "name": "Budi Santoso (Warung)", + "category": "restaurant", + "latitude": -6.221511, + "longitude": 106.812311, + "city": "Jakarta Selatan", + "address": "Senayan City Area" + }, + { + "id": "C003", + "name": "Pasar Mayestik Kios 12", + "category": "market", + "latitude": -6.241588, + "longitude": 106.793132, + "city": "Jakarta Selatan", + "address": "Jl. Tebah" + }, + { + "id": "C004", + "name": "Ibu Siti Catering", + "category": "restaurant", + "latitude": -6.237812, + "longitude": 106.799812, + "city": "Jakarta Selatan", + "address": "Bulungan" + }, + { + "id": "C005", + "name": "Toko Buah Segar", + "category": "market", + "latitude": -6.251211, + "longitude": 106.828912, + "city": "Jakarta Selatan", + "address": "Jl. Duren Tiga Raya" + }, + { + "id": "C006", + "name": "Laundry Kilat 24 Jam", + "category": "laundry", + "latitude": -6.261239, + "longitude": 106.811231, + "city": "Jakarta Selatan", + "address": "Kemang Utara" + }, + { + "id": "C007", + "name": "Warteg Bahari Jaya", + "category": "restaurant", + "latitude": -6.192311, + "longitude": 106.823121, + "city": "Jakarta Pusat", + "address": "Tanah Abang" + }, + { + "id": "C008", + "name": "Kopi Janji Jiwa", + "category": "cafe", + "latitude": -6.271823, + "longitude": 106.804123, + "city": "Jakarta Selatan", + "address": "Cipete Raya" + }, + { + "id": "C009", + "name": "SDN Menteng 01", "category": "school", - "latitude": -6.1950, - "longitude": 106.8300, - "city": "Jakarta", - "address": "Jl. Cikini Raya No. 73, Cikini, Jakarta Pusat 10330" + "latitude": -6.196570, + "longitude": 106.833923, + "city": "Jakarta Pusat", + "address": "Menteng" + }, + { + "id": "C010", + "name": "Apotek K24", + "category": "other", + "latitude": -6.244312, + "longitude": 106.804829, + "city": "Jakarta Selatan", + "address": "Jl. Wijaya I" + }, + { + "id": "C011", + "name": "Bengkel Motor 'Slamet'", + "category": "other", + "latitude": -6.213241, + "longitude": 106.843123, + "city": "Jakarta Selatan", + "address": "Tebet Raya" + }, + { + "id": "C012", + "name": "Toko Plastik Abadi", + "category": "market", + "latitude": -6.182141, + "longitude": 106.812301, + "city": "Jakarta Pusat", + "address": "Petojo" + }, + { + "id": "C013", + "name": "Bakso Solo Mas Min", + "category": "restaurant", + "latitude": -6.202311, + "longitude": 106.782192, + "city": "Jakarta Barat", + "address": "Kemanggisan" + }, + { + "id": "C014", + "name": "Cafe Rooftop Langit", + "category": "cafe", + "latitude": -6.223121, + "longitude": 106.852311, + "city": "Jakarta Selatan", + "address": "Tebet Timur" + }, + { + "id": "C015", + "name": "TK Bintang Kecil", + "category": "school", + "latitude": -6.281231, + "longitude": 106.823121, + "city": "Jakarta Selatan", + "address": "Pejaten" } ] diff --git a/routes/api.php b/routes/api.php index c5598fe..9acd0e6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,8 @@ use App\Http\Controllers\GeoPlanController; use App\Http\Controllers\GridController; use App\Http\Controllers\MerchantController; use App\Http\Controllers\StaffController; +use App\Http\Controllers\AgentController; +use App\Http\Middleware\CheckAgentSecret; /* |-------------------------------------------------------------------------- @@ -18,57 +20,104 @@ use App\Http\Controllers\StaffController; */ // ============ Merchants Routes ============ -Route::get('/merchants', [MerchantController::class, 'index']); +// Public routes (login/signup) +Route::post('/agents/login', [AgentController::class, 'login']); // Agent Sign In +Route::post('/agents', [AgentController::class, 'store']); // Agent Public Sign Up -// ============ Grid Routes ============ -Route::get('/grids', [GridController::class, 'index']); -Route::get('/grids/generate', [GridController::class, 'generate']); -Route::get('/grids/stats', [GridController::class, 'stats']); -Route::post('/grids/{gridId}/download', [GridController::class, 'download']); - -// ============ GeoTrack Routes ============ -Route::get('/sales-routes', [GeoTrackController::class, 'index']); -Route::get('/sales-routes/dates', [GeoTrackController::class, 'dates']); -Route::get('/sales-routes/sales', [GeoTrackController::class, 'sales']); -Route::get('/sales-routes/{id}', [GeoTrackController::class, 'show']); -Route::post('/sales-routes/waypoints', [GeoTrackController::class, 'storeWaypoint']); - -// ============ GeoPlan Routes ============ -Route::get('/sales-plans', [GeoPlanController::class, 'index']); -Route::get('/sales-plans/dates', [GeoPlanController::class, 'dates']); -Route::get('/sales-plans/sales', [GeoPlanController::class, 'sales']); -Route::get('/sales-plans/{id}', [GeoPlanController::class, 'show']); -Route::post('/sales-plans', [GeoPlanController::class, 'store']); -Route::put('/sales-plans/{id}', [GeoPlanController::class, 'update']); -Route::delete('/sales-plans/{id}', [GeoPlanController::class, 'destroy']); -Route::post('/sales-plans/{id}/optimize', [GeoPlanController::class, 'optimize']); -Route::post('/sales-plans/{id}/add-target', [GeoPlanController::class, 'addTarget']); -Route::put('/sales-plans/{id}/target/{targetId}', [GeoPlanController::class, 'updateTarget']); -Route::delete('/sales-plans/{id}/target/{targetId}', [GeoPlanController::class, 'removeTarget']); -Route::post('/sales-plans/assign-target', [GeoPlanController::class, 'autoAssignTarget']); - -// ============ GeoTrack Mobile API (Flutter) ============ -Route::prefix('mobile')->group(function () { - Route::post('/login', [GeoTrackController::class, 'mobileLogin']); - Route::post('/checkin', [GeoTrackController::class, 'mobileCheckin']); - Route::post('/checkout', [GeoTrackController::class, 'mobileCheckout']); - Route::post('/waypoints/batch', [GeoTrackController::class, 'storeBatchWaypoints']); - Route::get('/route/today', [GeoTrackController::class, 'getTodayRoute']); - Route::get('/status', [GeoTrackController::class, 'getTrackingStatus']); - - // New endpoints - Route::get('/schedules', [GeoPlanController::class, 'getMobileSchedule']); - Route::post('/schedules/{id}/target', [GeoPlanController::class, 'addTarget']); - Route::post('/schedules/{id}/target/{targetId}/checkin', [GeoPlanController::class, 'checkinTarget']); - Route::delete('/schedules/{id}/target/{targetId}', [GeoPlanController::class, 'removeTarget']); - Route::post('/prospects', [\App\Http\Controllers\CustomerController::class, 'store']); - Route::post('/profile', [GeoTrackController::class, 'updateProfile']); - Route::post('/change-password', [GeoTrackController::class, 'changePassword']); +// ============ External Agent Integration (Public) ============ +Route::prefix('v1')->group(function () { + // External Sign In - For external backoffice systems + Route::post('/agents/signin', [AgentController::class, 'externalSignin']); + Route::post('/agents/validate-session', [AgentController::class, 'validateSession']); }); -// ============ Master Data: Staff ============ -Route::apiResource('staff', StaffController::class); -Route::apiResource('customers', \App\Http\Controllers\CustomerController::class); +Route::prefix('mobile')->group(function () { + Route::post('/login', [GeoTrackController::class, 'mobileLogin']); // Mobile Login +}); + +// All other routes require CheckAgentSecret middleware +Route::middleware([CheckAgentSecret::class])->group(function () { + + // ============ Merchants Routes ============ + Route::get('/merchants', [MerchantController::class, 'index']); + + // ============ Grid Routes ============ + Route::get('/grids', [GridController::class, 'index']); + Route::get('/grids/generate', [GridController::class, 'generate']); + Route::get('/grids/stats', [GridController::class, 'stats']); + Route::post('/grids/{gridId}/download', [GridController::class, 'download']); + + // ============ GeoTrack Routes ============ + Route::get('/sales-routes', [GeoTrackController::class, 'index']); + Route::get('/sales-routes/dates', [GeoTrackController::class, 'dates']); + Route::get('/sales-routes/sales', [GeoTrackController::class, 'sales']); + Route::get('/sales-routes/{id}', [GeoTrackController::class, 'show']); + Route::post('/sales-routes/waypoints', [GeoTrackController::class, 'storeWaypoint']); + + // ============ GeoPlan Routes ============ + Route::get('/sales-plans', [GeoPlanController::class, 'index']); + Route::get('/sales-plans/dates', [GeoPlanController::class, 'dates']); + Route::get('/sales-plans/sales', [GeoPlanController::class, 'sales']); + Route::get('/sales-plans/{id}', [GeoPlanController::class, 'show']); + Route::post('/sales-plans', [GeoPlanController::class, 'store']); + Route::put('/sales-plans/{id}', [GeoPlanController::class, 'update']); + Route::delete('/sales-plans/{id}', [GeoPlanController::class, 'destroy']); + Route::post('/sales-plans/{id}/optimize', [GeoPlanController::class, 'optimize']); + Route::post('/sales-plans/{id}/add-target', [GeoPlanController::class, 'addTarget']); + Route::put('/sales-plans/{id}/target/{targetId}', [GeoPlanController::class, 'updateTarget']); + Route::delete('/sales-plans/{id}/target/{targetId}', [GeoPlanController::class, 'removeTarget']); + Route::post('/sales-plans/assign-target', [GeoPlanController::class, 'autoAssignTarget']); + + // ============ GeoTrack Mobile API (Flutter) ============ + Route::prefix('mobile')->group(function () { + Route::post('/checkin', [GeoTrackController::class, 'mobileCheckin']); + Route::post('/checkout', [GeoTrackController::class, 'mobileCheckout']); + Route::post('/waypoints/batch', [GeoTrackController::class, 'storeBatchWaypoints']); + Route::get('/route/today', [GeoTrackController::class, 'getTodayRoute']); + Route::get('/status', [GeoTrackController::class, 'getTrackingStatus']); + + // New endpoints + Route::get('/schedules', [GeoPlanController::class, 'getMobileSchedule']); + Route::post('/schedules/{id}/target', [GeoPlanController::class, 'addTarget']); + Route::post('/schedules/{id}/target/{targetId}/checkin', [GeoPlanController::class, 'checkinTarget']); + Route::delete('/schedules/{id}/target/{targetId}', [GeoPlanController::class, 'removeTarget']); + Route::post('/prospects', [\App\Http\Controllers\CustomerController::class, 'store']); + Route::post('/profile', [GeoTrackController::class, 'updateProfile']); + Route::post('/change-password', [GeoTrackController::class, 'changePassword']); + }); + + // ============ Master Data: Staff ============ + Route::apiResource('staff', StaffController::class); + Route::apiResource('customers', \App\Http\Controllers\CustomerController::class); + + // ============ Agent Management ============ + Route::get('/agents', [AgentController::class, 'index']); + Route::get('/agents/{id}', [AgentController::class, 'show']); + Route::put('/agents/{id}', [AgentController::class, 'update']); + Route::delete('/agents/{id}', [AgentController::class, 'destroy']); + Route::post('/agents/{id}/regenerate-key', [AgentController::class, 'regenerateKey']); + + // ============ RBAC (Role & Menu) ============ + Route::apiResource('menus', \App\Http\Controllers\MenuController::class); + Route::apiResource('user-groups', \App\Http\Controllers\UserGroupController::class); +}); + +// ============ EXTERNAL AGENT INTEGRATION API (V1) ============ +Route::prefix('v1')->middleware([CheckAgentSecret::class])->group(function () { + // These are the Clean APIs for external Agents (Sandbox) + + // Tracking + Route::get('/routes', [\App\Http\Controllers\Api\V1\TrackingController::class, 'index']); + Route::post('/routes/waypoints', [\App\Http\Controllers\Api\V1\TrackingController::class, 'storeWaypoint']); + + // Planning + Route::get('/plans', [\App\Http\Controllers\Api\V1\PlanningController::class, 'index']); + Route::post('/plans', [\App\Http\Controllers\Api\V1\PlanningController::class, 'store']); + + // Visit (Customers/Merchants) - Reusing main controller for now as logic is same + Route::post('/customers', [\App\Http\Controllers\CustomerController::class, 'store']); + Route::get('/merchants', [MerchantController::class, 'index']); +}); // ============ Health Check ============ Route::get('/health', function () {