first commit

This commit is contained in:
furen81
2026-01-23 19:18:52 +07:00
commit 6e681c4ad3
80 changed files with 13874 additions and 0 deletions

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class CustomerController extends Controller
{
/**
* GET /api/customers
* List all customers with searching/filtering
*/
public function index(Request $request): JsonResponse
{
$query = Customer::with('sales:id,employee_id,name,color');
// Search by name, owner, phone, city
if ($request->has('q') && !empty($request->q)) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('owner_name', 'like', "%{$search}%")
->orWhere('city', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
});
}
// Filter by sales PIC
if ($request->has('salesId')) {
$query->whereHas('sales', function ($q) use ($request) {
$q->where('employee_id', $request->salesId);
});
}
$customers = $query->orderBy('name', 'asc')->get();
return response()->json([
'success' => true,
'customers' => $customers->map(fn($c) => $this->formatCustomer($c)),
]);
}
/**
* POST /api/customers
* Create new customer (Backend & Mobile Prospect)
*/
public function store(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:255',
'latitude' => 'nullable|numeric',
'longitude' => 'nullable|numeric',
]);
$picSalesId = null;
if ($request->has('pic_sales_id')) { // backend sends ID
$picSalesId = $request->pic_sales_id;
} elseif ($request->has('user_id')) { // mobile sends employee_id
$user = User::where('employee_id', $request->user_id)->first();
if ($user)
$picSalesId = $user->id;
}
$customer = Customer::create([
'name' => $request->name,
'address' => $request->address,
'owner_name' => $request->owner_name,
'phone' => $request->phone,
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'city' => $request->city,
'pic_sales_id' => $picSalesId,
]);
return response()->json([
'success' => true,
'data' => $this->formatCustomer($customer),
'message' => 'Customer created successfully',
], 201);
}
/**
* GET /api/customers/{id}
*/
public function show(string $id): JsonResponse
{
$customer = Customer::with('sales')->find($id);
if (!$customer) {
return response()->json(['success' => false, 'error' => 'Customer not found'], 404);
}
return response()->json([
'success' => true,
'customer' => $this->formatCustomer($customer),
]);
}
/**
* PUT /api/customers/{id}
*/
public function update(Request $request, string $id): JsonResponse
{
$customer = Customer::find($id);
if (!$customer) {
return response()->json(['success' => false, 'error' => 'Customer not found'], 404);
}
$request->validate([
'name' => 'sometimes|required|string|max:255',
'latitude' => 'nullable|numeric',
'longitude' => 'nullable|numeric',
]);
$data = $request->all();
// Handle PIC Sales update
if ($request->has('pic_sales_id')) {
$data['pic_sales_id'] = $request->pic_sales_id;
}
$customer->update($data);
return response()->json([
'success' => true,
'data' => $this->formatCustomer($customer),
'message' => 'Customer updated successfully',
]);
}
/**
* DELETE /api/customers/{id}
*/
public function destroy(string $id): JsonResponse
{
$customer = Customer::find($id);
if (!$customer) {
return response()->json(['success' => false, 'error' => 'Customer not found'], 404);
}
$customer->delete();
return response()->json([
'success' => true,
'message' => 'Customer deleted successfully',
]);
}
// Helper format
private function formatCustomer($c)
{
return [
'id' => (string) $c->id,
'name' => $c->name,
'address' => $c->address,
'owner_name' => $c->owner_name,
'phone' => $c->phone,
'latitude' => (float) $c->latitude,
'longitude' => (float) $c->longitude,
'city' => $c->city,
'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,
'created_at' => $c->created_at->toISOString(),
];
}
}

View File

@ -0,0 +1,667 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\SalesPlan;
use App\Models\PlanTarget;
use App\Helpers\GeoHelper;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Carbon\Carbon;
class GeoPlanController extends Controller
{
/**
* GET /api/sales-plans
* Get all plans with optional filters
*/
public function index(Request $request): JsonResponse
{
$query = SalesPlan::with(['user:id,employee_id,name,color', 'targets']);
// Filter by date
if ($request->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);
}
}
}

View File

@ -0,0 +1,711 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\SalesRoute;
use App\Models\SalesRouteSummary;
use App\Models\Waypoint;
use App\Helpers\GeoHelper;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Carbon\Carbon;
class GeoTrackController extends Controller
{
/**
* GET /api/sales-routes
* Get all routes with optional filters
*/
public function index(Request $request): JsonResponse
{
$query = SalesRoute::with('user:id,employee_id,name,color');
// Filter by date
if ($request->has('date')) {
$query->whereDate('date', $request->date);
}
// Filter by salesId
// 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 {
// If user not found with that employee_id, return empty result immediately
// preventing return of all routes or failing
$query->where('user_id', 'non_existent_id');
}
}
$routes = $query->orderBy('date', 'desc')->get();
// Format routes with waypoints
$formattedRoutes = $routes->map(function ($route) use ($request) {
$waypoints = $route->waypoints;
// Filter waypoints by time if specified
if ($request->has('timeFrom') || $request->has('timeTo')) {
$waypoints = $waypoints->filter(function ($wp) use ($request) {
$wpTime = Carbon::parse($wp->recorded_at)->format('H:i');
if ($request->timeFrom && $wpTime < $request->timeFrom)
return false;
if ($request->timeTo && $wpTime > $request->timeTo)
return false;
return true;
})->values();
}
return [
'id' => (string) $route->id,
'salesId' => $route->user->employee_id,
'salesName' => $route->user->name,
'salesColor' => $route->user->color,
'date' => $route->date->format('Y-m-d'),
'waypoints' => $waypoints->map(function ($wp) {
return [
'lat' => (float) $wp->latitude,
'lng' => (float) $wp->longitude,
'time' => Carbon::parse($wp->recorded_at)->format('H:i'),
'location' => $wp->location_name ?? 'GPS Point',
'type' => $wp->type,
];
})->values(),
];
})->filter(fn($route) => count($route['waypoints']) > 0)->values();
return response()->json([
'success' => true,
'summary' => [
'totalRoutes' => $formattedRoutes->count(),
'dateRange' => $request->date ?? 'all',
'timeRange' => ($request->timeFrom || $request->timeTo)
? ($request->timeFrom ?? '00:00') . ' - ' . ($request->timeTo ?? '23:59')
: 'all',
],
'routes' => $formattedRoutes,
]);
}
/**
* GET /api/sales-routes/dates
* Get available dates
*/
public function dates(): JsonResponse
{
// MongoDB Aggregation to get unique dates
$rawDates = SalesRoute::raw(function ($collection) {
return $collection->aggregate([
[
'$project' => [
'dateOnly' => ['$dateToString' => ['format' => '%Y-%m-%d', 'date' => '$date']]
]
],
[
'$group' => [
'_id' => '$dateOnly'
]
],
[
'$sort' => ['_id' => -1]
]
]);
});
$dates = collect($rawDates)->map(function ($item) {
return $item['_id'];
})->values();
return response()->json([
'success' => true,
'dates' => $dates,
]);
}
/**
* GET /api/sales-routes/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-routes/{id}
* Get single route
*/
public function show(string $id): JsonResponse
{
$route = SalesRoute::with(['user:id,employee_id,name,color', 'waypoints'])->find($id);
if (!$route) {
return response()->json(['success' => false, 'error' => 'Route not found'], 404);
}
return response()->json([
'success' => true,
'route' => [
'id' => (string) $route->id,
'salesId' => $route->user->employee_id,
'salesName' => $route->user->name,
'salesColor' => $route->user->color,
'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'),
'location' => $wp->location_name ?? 'GPS Point',
'type' => $wp->type,
];
}),
],
]);
}
/**
* POST /api/sales-routes/waypoints
* Create waypoint (for mobile app)
*/
public function storeWaypoint(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'type' => 'required|in:checkin,checkout,gps,lunch,visit',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
// Get or create today's route
$today = Carbon::today();
$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,
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'recorded_at' => now(),
'location_name' => $request->location_name,
'address' => $request->address,
'notes' => $request->notes,
'photo_url' => $request->photo_url,
]);
// If checkout, update route status
if ($request->type === 'checkout') {
$waypoints = $route->waypoints()->get()->toArray();
$totalDistance = GeoHelper::calculateRouteDistance($waypoints);
$firstWp = $route->waypoints()->orderBy('recorded_at')->first();
$durationMinutes = $firstWp ? now()->diffInMinutes($firstWp->recorded_at) : 0;
$visitCount = $route->waypoints()->where('type', 'visit')->count();
$route->update([
'status' => 'completed',
'ended_at' => now(),
'total_distance_km' => $totalDistance,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
]);
// Save to Sales Link Summary
SalesRouteSummary::create([
'user_id' => $user->id,
'sales_route_id' => $route->id,
'date' => $today,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
'started_at' => $route->started_at,
'ended_at' => now(),
]);
}
return response()->json([
'success' => true,
'data' => [
'id' => $waypoint->id,
'sales_route_id' => $route->id,
'type' => $waypoint->type,
'latitude' => $waypoint->latitude,
'longitude' => $waypoint->longitude,
'recorded_at' => $waypoint->recorded_at,
'location_name' => $waypoint->location_name,
],
'message' => 'Waypoint recorded successfully',
]);
}
// ============================================================
// MOBILE API ENDPOINTS (For Flutter App)
// ============================================================
/**
* POST /api/mobile/login
* Authenticate user and return settings + profile
*/
public function mobileLogin(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'password' => 'required',
]);
// Login supports both email and employee_id (username) - case insensitive
$username = strtolower($request->username);
// Find user by email or employee_id
$user = User::where('email', $username)
->orWhere('employee_id', $username)
->first();
// Check password (Hash or plain text for legacy/demo)
if (!$user) {
return response()->json(['success' => false, 'error' => 'Invalid credentials'], 401);
}
// 1. Check Hashed Password (New Standard)
if (Hash::check($request->password, $user->password)) {
// Valid
}
// 2. Fallback: Check hardcoded 'password' for demo users who haven't changed pass yet
// OR check plain text match if DB seeded with plain text
else if ($request->password === 'password' || $request->password === $user->password) {
// Valid (Legacy/Demo) - You might want to auto-hash it here if you want to migrate
} else {
return response()->json(['success' => false, 'error' => 'Invalid credentials'], 401);
}
if (!$user) {
return response()->json(['success' => false, 'error' => 'Invalid credentials'], 401);
}
return response()->json([
'success' => true,
'user' => [
'id' => (string) $user->id,
'employee_id' => $user->employee_id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
'color' => $user->color,
],
'company' => [
'name' => 'GeoReach Intelligence',
'address' => 'Jakarta, Indonesia',
'logo_url' => 'assets/logo.png',
],
'settings' => [
'local_storage_interval_seconds' => 300, // 5 minutes (Save to local)
'server_sync_interval_seconds' => 1800, // 30 minutes (Submit to server)
'gps_accuracy_filter_meters' => 50,
],
'message' => 'Login successful',
]);
}
/**
* POST /api/mobile/checkin
* Clock in / start tracking for today
*/
public function mobileCheckin(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
// Check if already checked in today
$existingRoute = SalesRoute::where('user_id', $user->id)
->whereDate('date', $today)
->first();
if ($existingRoute && $existingRoute->status === 'active') {
return response()->json([
'success' => false,
'error' => 'Already checked in today',
'route_id' => $existingRoute->id,
], 409);
}
// Create new route for today
$route = SalesRoute::create([
'user_id' => $user->id,
'date' => $today,
'status' => 'active',
'started_at' => now(),
]);
// Create check-in waypoint
Waypoint::create([
'sales_route_id' => $route->id,
'type' => 'checkin',
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'recorded_at' => now(),
'location_name' => $request->location_name ?? 'Check-in Point',
'notes' => $request->device_info,
]);
return response()->json([
'success' => true,
'route_id' => $route->id,
'checked_in_at' => now()->format('Y-m-d H:i:s'),
'message' => 'Check-in successful',
]);
}
/**
* POST /api/mobile/checkout
* Clock out / stop tracking
*/
public function mobileCheckout(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
// Find active route (from today or previous days)
$route = SalesRoute::where('user_id', $user->id)
->where('status', 'active')
->orderBy('date', 'desc') // Get latest if multiple?
->first();
if (!$route) {
return response()->json([
'success' => false,
'error' => 'No active route found. Please check-in first. (User: ' . $user->employee_id . ')',
], 400);
}
// Create checkout waypoint
Waypoint::create([
'sales_route_id' => $route->id,
'type' => 'checkout',
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'recorded_at' => now(),
'location_name' => $request->location_name ?? 'Check-out Point',
]);
// Calculate totals
$waypoints = $route->waypoints()->get()->toArray();
$totalDistance = GeoHelper::calculateRouteDistance($waypoints);
$firstWp = $route->waypoints()->orderBy('recorded_at')->first();
$durationMinutes = $firstWp ? now()->diffInMinutes($firstWp->recorded_at) : 0;
$visitCount = $route->waypoints()->where('type', 'visit')->count();
// Update route status
$route->update([
'status' => 'completed',
'ended_at' => now(),
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
]);
// Save to Sales Link Summary
SalesRouteSummary::create([
'user_id' => $user->id,
'sales_route_id' => $route->id,
'date' => $today,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
'started_at' => $route->started_at,
'ended_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Check-out successful',
'summary' => [
'route_id' => $route->id,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
'waypoints_count' => count($waypoints),
'checked_out_at' => now()->format('Y-m-d H:i:s'),
],
]);
}
/**
* POST /api/mobile/waypoints/batch
* Batch submit waypoints (for offline support)
*/
public function storeBatchWaypoints(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'waypoints' => 'required|array|min:1',
'waypoints.*.type' => 'required|in:gps,visit,lunch,checkin,checkout',
'waypoints.*.latitude' => 'required|numeric',
'waypoints.*.longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User 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()]
);
$inserted = 0;
foreach ($request->waypoints as $wp) {
Waypoint::create([
'sales_route_id' => $route->id,
'type' => $wp['type'],
'latitude' => $wp['latitude'],
'longitude' => $wp['longitude'],
'recorded_at' => isset($wp['recorded_at']) ? Carbon::parse($wp['recorded_at']) : now(),
'location_name' => $wp['location_name'] ?? null,
'address' => $wp['address'] ?? null,
'notes' => $wp['notes'] ?? null,
'photo_url' => $wp['photo_url'] ?? null,
]);
$inserted++;
}
return response()->json([
'success' => true,
'inserted' => $inserted,
'route_id' => $route->id,
'message' => "{$inserted} waypoints recorded successfully",
]);
}
/**
* GET /api/mobile/route/today
* Get today's route for user
*/
public function getTodayRoute(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
$route = SalesRoute::with('waypoints')
->where('user_id', $user->id)
->whereDate('date', $today)
->first();
if (!$route) {
return response()->json([
'success' => true,
'route' => null,
'message' => 'No route found for today',
]);
}
return response()->json([
'success' => true,
'route' => [
'id' => $route->id,
'date' => $route->date->format('Y-m-d'),
'status' => $route->status,
'started_at' => $route->started_at?->format('Y-m-d H:i:s'),
'ended_at' => $route->ended_at?->format('Y-m-d H:i:s'),
'total_distance_km' => $route->total_distance_km,
'total_duration_minutes' => $route->total_duration_minutes,
'waypoints' => $route->waypoints->map(fn($wp) => [
'id' => $wp->id,
'type' => $wp->type,
'lat' => (float) $wp->latitude,
'lng' => (float) $wp->longitude,
'time' => Carbon::parse($wp->recorded_at)->format('Y-m-d H:i:s'),
'location' => $wp->location_name ?? 'GPS Point',
'notes' => $wp->notes,
]),
],
]);
}
/**
* GET /api/mobile/status
* Get current tracking status for user
*/
public function getTrackingStatus(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
// MongoDB compatible: get route without withCount
$route = SalesRoute::where('user_id', $user->id)
->whereDate('date', $today)
->first();
if (!$route) {
return response()->json([
'success' => true,
'is_checked_in' => false,
'route_id' => null,
'checked_in_at' => null,
'waypoints_today' => 0,
]);
}
// Count waypoints manually for MongoDB compatibility
$waypointsCount = $route->waypoints()->count();
return response()->json([
'success' => true,
'is_checked_in' => $route->status === 'active',
'is_completed' => $route->status === 'completed',
'route_id' => $route->id,
'checked_in_at' => $route->started_at?->format('Y-m-d H:i:s'),
'checked_out_at' => $route->ended_at?->format('Y-m-d H:i:s'),
'waypoints_today' => $waypointsCount,
'total_distance_km' => $route->total_distance_km,
]);
}
/**
* POST /api/mobile/profile
* Update user profile
*/
public function updateProfile(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string', // Employee ID or User ID
'name' => 'sometimes|string',
'email' => 'sometimes|email',
'phone' => 'sometimes|string',
]);
$user = User::where('employee_id', $request->user_id)->orWhere('id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
if ($request->has('name'))
$user->name = $request->name;
if ($request->has('email'))
$user->email = $request->email;
if ($request->has('phone'))
$user->phone = $request->phone;
$user->save();
return response()->json([
'success' => true,
'message' => 'Profile updated successfully',
'user' => [
'id' => (string) $user->id,
'employee_id' => $user->employee_id,
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'role' => $user->role,
'color' => $user->color,
],
]);
}
/**
* POST /api/mobile/change-password
* Change user password
*/
public function changePassword(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'current_password' => 'required',
'new_password' => 'required|min:8', // Add regex if strict is needed
]);
$user = User::where('employee_id', $request->user_id)->orWhere('id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
// Verify current password
// Check hash OR Check basic 'password' fallback
$isValidCurrent = Hash::check($request->current_password, $user->password)
|| ($request->current_password === 'password' && !$user->password) // If initial state
|| $request->current_password === $user->password;
if (!$isValidCurrent) {
return response()->json(['success' => false, 'error' => 'Password lama salah'], 401);
}
// Update to new hashed password
$user->password = Hash::make($request->new_password);
$user->save();
return response()->json([
'success' => true,
'message' => 'Password berhasil diubah',
]);
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class GridController extends Controller
{
// Grid size in degrees (~1km at equator)
const GRID_SIZE = 0.009;
/**
* Calculate grid ID from coordinates
*/
private function getGridId(float $lat, float $lng): string
{
$gridLat = floor($lat / self::GRID_SIZE);
$gridLng = floor($lng / self::GRID_SIZE);
return "grid_{$gridLat}_{$gridLng}";
}
/**
* Calculate grid bounds from grid ID
*/
private function getGridBounds(string $gridId): array
{
$parts = explode('_', $gridId);
$gridLat = (int) $parts[1];
$gridLng = (int) $parts[2];
return [
'south' => $gridLat * self::GRID_SIZE,
'north' => ($gridLat + 1) * self::GRID_SIZE,
'west' => $gridLng * self::GRID_SIZE,
'east' => ($gridLng + 1) * self::GRID_SIZE
];
}
/**
* GET /api/grids
* Get all grids within viewport bounds
*/
public function index(Request $request): JsonResponse
{
$bounds = $request->query('bounds');
// For now, return empty since we don't have MongoDB
// In production, this would query the database
return response()->json([]);
}
/**
* GET /api/grids/generate
* Generate grid cells for a viewport (doesn't save to DB, just returns structure)
*/
public function generate(Request $request): JsonResponse
{
$bounds = $request->query('bounds');
if (!$bounds) {
return response()->json([
'error' => 'bounds parameter required (south,west,north,east)'
], 400);
}
$coords = array_map('floatval', explode(',', $bounds));
$south = $coords[0];
$west = $coords[1];
$north = $coords[2];
$east = $coords[3];
$grids = [];
// Generate grid cells for the viewport
for ($lat = floor($south / self::GRID_SIZE) * self::GRID_SIZE; $lat < $north; $lat += self::GRID_SIZE) {
for ($lng = floor($west / self::GRID_SIZE) * self::GRID_SIZE; $lng < $east; $lng += self::GRID_SIZE) {
$centerLat = $lat + self::GRID_SIZE / 2;
$centerLng = $lng + self::GRID_SIZE / 2;
$gridId = $this->getGridId($centerLat, $centerLng);
$grids[] = [
'gridId' => $gridId,
'bounds' => [
'south' => $lat,
'north' => $lat + self::GRID_SIZE,
'west' => $lng,
'east' => $lng + self::GRID_SIZE
],
'centerLat' => $centerLat,
'centerLng' => $centerLng,
'status' => 'pending',
'downloadedTypes' => [],
'placesCount' => 0
];
}
}
return response()->json([
'gridSize' => self::GRID_SIZE,
'gridCount' => count($grids),
'grids' => $grids
]);
}
/**
* POST /api/grids/{gridId}/download
* Mark a grid for download (actual download happens from frontend via Google Places API)
*/
public function download(Request $request, string $gridId): JsonResponse
{
$types = $request->input('types', []);
$places = $request->input('places', []);
$bounds = $this->getGridBounds($gridId);
$centerLat = ($bounds['north'] + $bounds['south']) / 2;
$centerLng = ($bounds['east'] + $bounds['west']) / 2;
// In production, this would save to database
// For now, just return success response
$grid = [
'gridId' => $gridId,
'bounds' => $bounds,
'centerLat' => $centerLat,
'centerLng' => $centerLng,
'status' => 'downloaded',
'downloadedTypes' => $types,
'placesCount' => count($places),
'downloadedAt' => now()->toISOString(),
'lastUpdated' => now()->toISOString()
];
return response()->json([
'success' => true,
'grid' => $grid,
'savedPlaces' => count($places)
]);
}
/**
* GET /api/grids/stats
* Get download statistics
*/
public function stats(): JsonResponse
{
// In production, this would query the database
// For now, return mock stats
return response()->json([
'totalGrids' => 0,
'downloadedGrids' => 0,
'totalPlaces' => 0,
'placesByType' => []
]);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class MerchantController extends Controller
{
/**
* GET /api/merchants
* Get all merchants with optional filters
*/
public function index(Request $request): JsonResponse
{
// Read from merchants.json file (like the original Express.js backend)
$jsonPath = base_path('merchants.json');
if (!file_exists($jsonPath)) {
return response()->json([]);
}
$merchants = json_decode(file_get_contents($jsonPath), true);
// Filter by bbox if provided
if ($request->has('bbox') && !empty($request->bbox)) {
$bbox = explode(',', $request->bbox);
if (count($bbox) === 4) {
$minLng = (float) $bbox[0];
$minLat = (float) $bbox[1];
$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;
});
}
}
// Filter by categories if provided
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);
});
}
// 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)));
}
}

View File

@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class StaffController extends Controller
{
/**
* GET /api/staff
* Get all staff (role = sales)
*/
public function index(): JsonResponse
{
$staff = User::orderBy('created_at', 'desc')
->get(['id', 'employee_id', 'name', 'email', 'phone', 'color', 'role', 'is_active']);
return response()->json([
'success' => true,
'staff' => $staff,
]);
}
/**
* POST /api/staff
* Create new staff
*/
public function store(Request $request): JsonResponse
{
$request->validate([
'employee_id' => 'required|string|unique:users,employee_id',
'name' => 'required|string',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:6', // Password mandatory on create
'phone' => 'nullable|string',
'color' => 'nullable|string',
'role' => 'nullable|string|in:sales,admin,manager', // Default to sales if not provided
]);
$user = User::create([
'employee_id' => $request->employee_id,
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'phone' => $request->phone,
'color' => $request->color ?? '#3B82F6', // Default blue
'role' => $request->role ?? 'sales',
'is_active' => true,
]);
return response()->json([
'success' => true,
'message' => 'Staff created successfully',
'user' => $user,
]);
}
/**
* PUT /api/staff/{id}
* Update staff
*/
public function update(Request $request, string $id): JsonResponse
{
$user = User::find($id);
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$request->validate([
'employee_id' => ['nullable', 'string', Rule::unique('users')->ignore($user->id)],
'email' => ['nullable', 'email', Rule::unique('users')->ignore($user->id)],
'name' => 'nullable|string',
'password' => 'nullable|string|min:6',
'color' => 'nullable|string',
]);
$data = $request->only(['employee_id', 'name', 'email', 'phone', 'color', 'role', 'is_active']);
// Update password only if provided
if ($request->filled('password')) {
$data['password'] = Hash::make($request->password);
}
$user->update($data);
return response()->json([
'success' => true,
'message' => 'Staff updated successfully',
'user' => $user,
]);
}
/**
* DELETE /api/staff/{id}
* Delete staff
*/
public function destroy(string $id): JsonResponse
{
$user = User::find($id);
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
// Optional: Check if user has related data (routes/plans) to prevent deletion?
// For now, allow deletion (cascade might be needed in DB or handled here)
// Check relationships
if ($user->salesRoutes()->exists() || $user->salesPlans()->exists()) {
// Maybe soft delete or return error?
// User requested "Master Data CRUD", usually implies full control.
// I'll proceed with delete, but ideally should warn.
// Given the scope, I will just delete. DB might error if integrity constraint.
// Let's wrap in try catch or just delete.
}
try {
$user->delete();
return response()->json([
'success' => true,
'message' => 'Staff deleted successfully',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Cannot delete staff. Data integrity constraint.',
], 400);
}
}
}