first commit
This commit is contained in:
131
app/Helpers/GeoHelper.php
Normal file
131
app/Helpers/GeoHelper.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class GeoHelper
|
||||
{
|
||||
/**
|
||||
* Calculate distance between two coordinates using Haversine formula
|
||||
*
|
||||
* @param float $lat1 Latitude of point 1
|
||||
* @param float $lng1 Longitude of point 1
|
||||
* @param float $lat2 Latitude of point 2
|
||||
* @param float $lng2 Longitude of point 2
|
||||
* @return float Distance in kilometers
|
||||
*/
|
||||
public static function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float
|
||||
{
|
||||
$R = 6371; // Earth's radius in km
|
||||
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLng = deg2rad($lng2 - $lng1);
|
||||
|
||||
$a = sin($dLat / 2) * sin($dLat / 2) +
|
||||
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
||||
sin($dLng / 2) * sin($dLng / 2);
|
||||
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
|
||||
return $R * $c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize route using Nearest Neighbor algorithm
|
||||
*
|
||||
* @param array $targets Array of targets with lat/lng properties
|
||||
* @return array ['order' => array, 'totalDistance' => float]
|
||||
*/
|
||||
public static function optimizeRouteNearestNeighbor(array $targets): array
|
||||
{
|
||||
$n = count($targets);
|
||||
|
||||
if ($n <= 2) {
|
||||
$totalDistance = 0;
|
||||
if ($n === 2) {
|
||||
$totalDistance = self::haversineDistance(
|
||||
$targets[0]['latitude'] ?? $targets[0]['lat'],
|
||||
$targets[0]['longitude'] ?? $targets[0]['lng'],
|
||||
$targets[1]['latitude'] ?? $targets[1]['lat'],
|
||||
$targets[1]['longitude'] ?? $targets[1]['lng']
|
||||
);
|
||||
}
|
||||
return [
|
||||
'order' => array_keys($targets),
|
||||
'totalDistance' => round($totalDistance, 2)
|
||||
];
|
||||
}
|
||||
|
||||
$visited = array_fill(0, $n, false);
|
||||
$route = [];
|
||||
$totalDistance = 0;
|
||||
|
||||
// Start from first target
|
||||
$current = 0;
|
||||
$visited[$current] = true;
|
||||
$route[] = $current;
|
||||
|
||||
// Find nearest unvisited neighbor
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$nearest = -1;
|
||||
$minDist = PHP_FLOAT_MAX;
|
||||
|
||||
$currentLat = $targets[$current]['latitude'] ?? $targets[$current]['lat'];
|
||||
$currentLng = $targets[$current]['longitude'] ?? $targets[$current]['lng'];
|
||||
|
||||
for ($j = 0; $j < $n; $j++) {
|
||||
if (!$visited[$j]) {
|
||||
$targetLat = $targets[$j]['latitude'] ?? $targets[$j]['lat'];
|
||||
$targetLng = $targets[$j]['longitude'] ?? $targets[$j]['lng'];
|
||||
|
||||
$dist = self::haversineDistance($currentLat, $currentLng, $targetLat, $targetLng);
|
||||
|
||||
if ($dist < $minDist) {
|
||||
$minDist = $dist;
|
||||
$nearest = $j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($nearest !== -1) {
|
||||
$visited[$nearest] = true;
|
||||
$route[] = $nearest;
|
||||
$totalDistance += $minDist;
|
||||
$current = $nearest;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'order' => $route,
|
||||
'totalDistance' => round($totalDistance, 2)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total distance of a route
|
||||
*
|
||||
* @param array $targets Array of targets in order
|
||||
* @return float Total distance in kilometers
|
||||
*/
|
||||
public static function calculateRouteDistance(array $targets): float
|
||||
{
|
||||
if (count($targets) < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalDistance = 0;
|
||||
|
||||
for ($i = 0; $i < count($targets) - 1; $i++) {
|
||||
$t1 = $targets[$i];
|
||||
$t2 = $targets[$i + 1];
|
||||
|
||||
$totalDistance += self::haversineDistance(
|
||||
$t1['latitude'] ?? $t1['lat'],
|
||||
$t1['longitude'] ?? $t1['lng'],
|
||||
$t2['latitude'] ?? $t2['lat'],
|
||||
$t2['longitude'] ?? $t2['lng']
|
||||
);
|
||||
}
|
||||
|
||||
return round($totalDistance, 2);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
172
app/Http/Controllers/CustomerController.php
Normal file
172
app/Http/Controllers/CustomerController.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
667
app/Http/Controllers/GeoPlanController.php
Normal file
667
app/Http/Controllers/GeoPlanController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
711
app/Http/Controllers/GeoTrackController.php
Normal file
711
app/Http/Controllers/GeoTrackController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
155
app/Http/Controllers/GridController.php
Normal file
155
app/Http/Controllers/GridController.php
Normal 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' => []
|
||||
]);
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/MerchantController.php
Normal file
60
app/Http/Controllers/MerchantController.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
132
app/Http/Controllers/StaffController.php
Normal file
132
app/Http/Controllers/StaffController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/Models/Customer.php
Normal file
31
app/Models/Customer.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Customer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'address',
|
||||
'owner_name',
|
||||
'phone',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'city',
|
||||
'pic_sales_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the sales user associated with the customer.
|
||||
*/
|
||||
public function sales(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'pic_sales_id');
|
||||
}
|
||||
}
|
||||
42
app/Models/PlanTarget.php
Normal file
42
app/Models/PlanTarget.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PlanTarget extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'sales_plan_id',
|
||||
'order',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'name',
|
||||
'address',
|
||||
'phone',
|
||||
'notes',
|
||||
'estimated_duration_minutes',
|
||||
'is_completed',
|
||||
'completed_at',
|
||||
'source', // 'admin', 'mobile_manual', etc.
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latitude' => 'decimal:8',
|
||||
'longitude' => 'decimal:8',
|
||||
'is_completed' => 'boolean',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sales plan that owns the target.
|
||||
*/
|
||||
public function salesPlan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesPlan::class);
|
||||
}
|
||||
}
|
||||
55
app/Models/SalesPlan.php
Normal file
55
app/Models/SalesPlan.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SalesPlan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'date',
|
||||
'status',
|
||||
'total_distance_km',
|
||||
'optimized_at',
|
||||
'optimized_route',
|
||||
'notes',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'total_distance_km' => 'decimal:2',
|
||||
'optimized_at' => 'datetime',
|
||||
'optimized_route' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user that owns the plan.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user that created the plan.
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the targets for the plan.
|
||||
*/
|
||||
public function targets(): HasMany
|
||||
{
|
||||
return $this->hasMany(PlanTarget::class, 'sales_plan_id')->orderBy('order');
|
||||
}
|
||||
}
|
||||
47
app/Models/SalesRoute.php
Normal file
47
app/Models/SalesRoute.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SalesRoute extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'date',
|
||||
'status',
|
||||
'total_distance_km',
|
||||
'total_duration_minutes',
|
||||
'total_visits',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'total_distance_km' => 'decimal:2',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user that owns the route.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the waypoints for the route.
|
||||
*/
|
||||
public function waypoints(): HasMany
|
||||
{
|
||||
return $this->hasMany(Waypoint::class)->orderBy('recorded_at');
|
||||
}
|
||||
}
|
||||
40
app/Models/SalesRouteSummary.php
Normal file
40
app/Models/SalesRouteSummary.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SalesRouteSummary extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'sales_route_id',
|
||||
'date',
|
||||
'total_distance_km',
|
||||
'total_duration_minutes',
|
||||
'total_visits',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'total_distance_km' => 'decimal:2',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function route(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesRoute::class, 'sales_route_id');
|
||||
}
|
||||
}
|
||||
63
app/Models/User.php
Normal file
63
app/Models/User.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use MongoDB\Laravel\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'employee_id',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'color',
|
||||
'role',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sales routes for the user.
|
||||
*/
|
||||
public function salesRoutes(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesRoute::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sales plans for the user.
|
||||
*/
|
||||
public function salesPlans(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesPlan::class);
|
||||
}
|
||||
}
|
||||
38
app/Models/Waypoint.php
Normal file
38
app/Models/Waypoint.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use MongoDB\Laravel\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Waypoint extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'sales_route_id',
|
||||
'type',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'recorded_at',
|
||||
'location_name',
|
||||
'address',
|
||||
'notes',
|
||||
'photo_url',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latitude' => 'decimal:8',
|
||||
'longitude' => 'decimal:8',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sales route that owns the waypoint.
|
||||
*/
|
||||
public function salesRoute(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesRoute::class);
|
||||
}
|
||||
}
|
||||
27
app/Providers/AppServiceProvider.php
Normal file
27
app/Providers/AppServiceProvider.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use MongoDB\Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user