diff --git a/app/Http/Controllers/KPI/TargetsController.php b/app/Http/Controllers/KPI/TargetsController.php
new file mode 100644
index 0000000..40b0533
--- /dev/null
+++ b/app/Http/Controllers/KPI/TargetsController.php
@@ -0,0 +1,237 @@
+orderBy('created_at', 'desc')
+ ->paginate(15);
+
+ // Get mechanics using role_id 3 (mechanic) with dealer relationship
+ $mechanics = User::with('dealer')
+ ->where('role_id', 3)
+ ->orderBy('name', 'asc')
+ ->limit(50)
+ ->get();
+
+ // If no mechanics found, get all users as fallback
+ if ($mechanics->isEmpty()) {
+ $mechanics = User::with('dealer')
+ ->orderBy('name', 'asc')
+ ->limit(50)
+ ->get();
+ }
+
+ return view('kpi.targets.index', compact('targets', 'mechanics'));
+ }
+
+ /**
+ * Show the form for creating a new KPI target
+ */
+ public function create()
+ {
+ // Get mechanics using role_id 3 (mechanic) with dealer relationship
+ $mechanics = User::with('dealer')
+ ->where('role_id', 3)
+ ->orderBy('name', 'asc')
+ ->limit(50)
+ ->get();
+
+ // Debug: Log the mechanics found
+ Log::info('Mechanics found for KPI target creation:', [
+ 'count' => $mechanics->count(),
+ 'mechanics' => $mechanics->pluck('name', 'id')->toArray()
+ ]);
+
+ // If no mechanics found, get all users as fallback
+ if ($mechanics->isEmpty()) {
+ $mechanics = User::with('dealer')
+ ->orderBy('name', 'asc')
+ ->limit(50)
+ ->get();
+
+ Log::warning('No mechanics found, using all users as fallback', [
+ 'count' => $mechanics->count()
+ ]);
+ }
+
+ return view('kpi.targets.create', compact('mechanics'));
+ }
+
+ /**
+ * Store a newly created KPI target
+ */
+ public function store(StoreKpiTargetRequest $request)
+ {
+ try {
+ // Log the validated data
+ Log::info('Creating KPI target with data:', $request->validated());
+
+ // Check if user already has an active target and deactivate it
+ $existingTarget = KpiTarget::where('user_id', $request->user_id)
+ ->where('is_active', true)
+ ->first();
+
+ if ($existingTarget) {
+ Log::info('Deactivating existing active KPI target', [
+ 'user_id' => $request->user_id,
+ 'existing_target_id' => $existingTarget->id
+ ]);
+
+ // Deactivate the existing target
+ $existingTarget->update(['is_active' => false]);
+ }
+
+ $target = KpiTarget::create($request->validated());
+
+ Log::info('KPI target created successfully', [
+ 'target_id' => $target->id,
+ 'user_id' => $target->user_id
+ ]);
+
+ return redirect()->route('kpi.targets.index')
+ ->with('success', 'Target KPI berhasil ditambahkan');
+ } catch (\Exception $e) {
+ Log::error('Failed to create KPI target', [
+ 'error' => $e->getMessage(),
+ 'data' => $request->validated()
+ ]);
+
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'Gagal menambahkan target KPI: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Display the specified KPI target
+ */
+ public function show(KpiTarget $target)
+ {
+ $target->load(['user.dealer', 'achievements']);
+
+ return view('kpi.targets.show', compact('target'));
+ }
+
+ /**
+ * Show the form for editing the specified KPI target
+ */
+ public function edit(KpiTarget $target)
+ {
+ // Debug: Check if target is loaded correctly
+ if (!$target) {
+ abort(404, 'Target KPI tidak ditemukan');
+ }
+
+ // Load target with user relationship
+ $target->load('user');
+
+ // Get mechanics using role_id 3 (mechanic) with dealer relationship
+ $mechanics = User::with('dealer')
+ ->where('role_id', 3)
+ ->orderBy('name', 'asc')
+ ->limit(50)
+ ->get();
+
+ // If no mechanics found, get all users as fallback
+ if ($mechanics->isEmpty()) {
+ $mechanics = User::with('dealer')
+ ->orderBy('name', 'asc')
+ ->limit(50)
+ ->get();
+ }
+
+ // Ensure data types are correct for comparison
+ $target->user_id = (int)$target->user_id;
+
+ return view('kpi.targets.edit', compact('target', 'mechanics'));
+ }
+
+ /**
+ * Update the specified KPI target
+ */
+ public function update(UpdateKpiTargetRequest $request, KpiTarget $target)
+ {
+ try {
+ $target->update($request->validated());
+
+ return redirect()->route('kpi.targets.index')
+ ->with('success', 'Target KPI berhasil diperbarui');
+ } catch (\Exception $e) {
+ return redirect()->back()
+ ->withInput()
+ ->with('error', 'Gagal memperbarui target KPI: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Remove the specified KPI target
+ */
+ public function destroy(KpiTarget $target)
+ {
+ try {
+ $target->delete();
+
+ return redirect()->route('kpi.targets.index')
+ ->with('success', 'Target KPI berhasil dihapus');
+ } catch (\Exception $e) {
+ return redirect()->back()
+ ->with('error', 'Gagal menghapus target KPI: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Toggle active status of KPI target
+ */
+ public function toggleStatus(KpiTarget $target)
+ {
+ try {
+ $target->update(['is_active' => !$target->is_active]);
+
+ $status = $target->is_active ? 'diaktifkan' : 'dinonaktifkan';
+
+ return response()->json([
+ 'success' => true,
+ 'message' => "Target KPI berhasil {$status}",
+ 'is_active' => $target->is_active
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'Gagal mengubah status target KPI'
+ ], 500);
+ }
+ }
+
+ /**
+ * Get KPI targets for specific user
+ */
+ public function getUserTargets(User $user)
+ {
+ $targets = $user->kpiTargets()
+ ->with('achievements')
+ ->orderBy('year', 'desc')
+ ->orderBy('month', 'desc')
+ ->get();
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $targets
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php
index 14ebfe2..6635ff5 100755
--- a/app/Http/Controllers/TransactionController.php
+++ b/app/Http/Controllers/TransactionController.php
@@ -49,7 +49,28 @@ class TransactionController extends Controller
->where('active', true)
->get();
- return view('transaction.index', compact('now', 'wash_work', 'work_works', 'user_sas', 'count_transaction_users', 'count_transaction_dealers', 'mechanic', 'products'));
+ // Get KPI data for current user using KPI service
+ $kpiService = app(\App\Services\KpiService::class);
+
+ // Auto-calculate current month KPI achievement to ensure data is up-to-date
+ $kpiService->calculateKpiAchievement(Auth::user());
+
+ $kpiSummary = $kpiService->getKpiSummary(Auth::user());
+
+ // Get current month period name
+ $currentMonthName = now()->translatedFormat('F Y');
+
+ $kpiData = [
+ 'target' => $kpiSummary['current_target'] ? $kpiSummary['current_target']->target_value : 0,
+ 'actual' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->actual_value : 0,
+ 'percentage' => $kpiSummary['current_percentage'],
+ 'status' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status : 'pending',
+ 'status_color' => $kpiSummary['current_achievement'] ? $kpiSummary['current_achievement']->status_color : 'secondary',
+ 'period' => $currentMonthName,
+ 'has_target' => $kpiSummary['current_target'] ? true : false
+ ];
+
+ return view('transaction.index', compact('now', 'wash_work', 'work_works', 'user_sas', 'count_transaction_users', 'count_transaction_dealers', 'mechanic', 'products', 'kpiData'));
}
public function workcategory($category_id)
diff --git a/app/Http/Requests/KPI/StoreKpiTargetRequest.php b/app/Http/Requests/KPI/StoreKpiTargetRequest.php
new file mode 100644
index 0000000..c07afc2
--- /dev/null
+++ b/app/Http/Requests/KPI/StoreKpiTargetRequest.php
@@ -0,0 +1,59 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'user_id' => 'required|exists:users,id',
+ 'target_value' => 'required|integer|min:1',
+ 'description' => 'nullable|string|max:1000',
+ 'is_active' => 'boolean'
+ ];
+ }
+
+ /**
+ * Get custom messages for validator errors.
+ */
+ public function messages(): array
+ {
+ return [
+ 'user_id.required' => 'Mekanik harus dipilih',
+ 'user_id.exists' => 'Mekanik yang dipilih tidak valid',
+ 'target_value.required' => 'Target nilai harus diisi',
+ 'target_value.integer' => 'Target nilai harus berupa angka',
+ 'target_value.min' => 'Target nilai minimal 1',
+ 'description.max' => 'Deskripsi maksimal 1000 karakter',
+ ];
+ }
+
+ /**
+ * Prepare the data for validation.
+ */
+ protected function prepareForValidation(): void
+ {
+ $this->merge([
+ 'is_active' => $this->boolean('is_active', true)
+ ]);
+ }
+
+
+}
diff --git a/app/Http/Requests/KPI/UpdateKpiTargetRequest.php b/app/Http/Requests/KPI/UpdateKpiTargetRequest.php
new file mode 100644
index 0000000..1bc7308
--- /dev/null
+++ b/app/Http/Requests/KPI/UpdateKpiTargetRequest.php
@@ -0,0 +1,59 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'user_id' => 'required|exists:users,id',
+ 'target_value' => 'required|integer|min:1',
+ 'description' => 'nullable|string|max:1000',
+ 'is_active' => 'boolean'
+ ];
+ }
+
+ /**
+ * Get custom messages for validator errors.
+ */
+ public function messages(): array
+ {
+ return [
+ 'user_id.required' => 'Mekanik harus dipilih',
+ 'user_id.exists' => 'Mekanik yang dipilih tidak valid',
+ 'target_value.required' => 'Target nilai harus diisi',
+ 'target_value.integer' => 'Target nilai harus berupa angka',
+ 'target_value.min' => 'Target nilai minimal 1',
+ 'description.max' => 'Deskripsi maksimal 1000 karakter',
+ ];
+ }
+
+ /**
+ * Prepare the data for validation.
+ */
+ protected function prepareForValidation(): void
+ {
+ $this->merge([
+ 'is_active' => $this->boolean('is_active', true)
+ ]);
+ }
+
+
+}
diff --git a/app/Models/KpiAchievement.php b/app/Models/KpiAchievement.php
new file mode 100644
index 0000000..1409b2e
--- /dev/null
+++ b/app/Models/KpiAchievement.php
@@ -0,0 +1,168 @@
+ 'decimal:2',
+ 'year' => 'integer',
+ 'month' => 'integer'
+ ];
+
+ protected $attributes = [
+ 'actual_value' => 0,
+ 'achievement_percentage' => 0
+ ];
+
+ /**
+ * Get the user that owns the achievement
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ /**
+ * Get the KPI target for this achievement
+ */
+ public function kpiTarget(): BelongsTo
+ {
+ return $this->belongsTo(KpiTarget::class);
+ }
+
+ /**
+ * Scope to get achievements for specific year and month
+ */
+ public function scopeForPeriod($query, $year, $month)
+ {
+ return $query->where('year', $year)->where('month', $month);
+ }
+
+ /**
+ * Scope to get achievements for current month
+ */
+ public function scopeCurrentMonth($query)
+ {
+ return $query->where('year', now()->year)->where('month', now()->month);
+ }
+
+ /**
+ * Scope to get achievements within year range
+ */
+ public function scopeWithinYearRange($query, $startYear, $endYear)
+ {
+ return $query->whereBetween('year', [$startYear, $endYear]);
+ }
+
+ /**
+ * Scope to get achievements for specific user
+ */
+ public function scopeForUser($query, $userId)
+ {
+ return $query->where('user_id', $userId);
+ }
+
+ /**
+ * Get achievement status
+ */
+ public function getStatusAttribute(): string
+ {
+ if ($this->achievement_percentage >= 100) {
+ return 'exceeded';
+ } elseif ($this->achievement_percentage >= 80) {
+ return 'good';
+ } elseif ($this->achievement_percentage >= 60) {
+ return 'fair';
+ } else {
+ return 'poor';
+ }
+ }
+
+ /**
+ * Get status color for display
+ */
+ public function getStatusColorAttribute(): string
+ {
+ return match($this->status) {
+ 'exceeded' => 'success',
+ 'good' => 'info',
+ 'fair' => 'warning',
+ 'poor' => 'danger',
+ default => 'secondary'
+ };
+ }
+
+ /**
+ * Get period display name (e.g., "Januari 2024")
+ */
+ public function getPeriodDisplayName(): string
+ {
+ $monthNames = [
+ 1 => 'Januari', 2 => 'Februari', 3 => 'Maret', 4 => 'April',
+ 5 => 'Mei', 6 => 'Juni', 7 => 'Juli', 8 => 'Agustus',
+ 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Desember'
+ ];
+
+ return $monthNames[$this->month] . ' ' . $this->year;
+ }
+
+ /**
+ * Get period start date
+ */
+ public function getPeriodStartDate(): Carbon
+ {
+ return Carbon::createFromDate($this->year, $this->month, 1);
+ }
+
+ /**
+ * Get period end date
+ */
+ public function getPeriodEndDate(): Carbon
+ {
+ return Carbon::createFromDate($this->year, $this->month, 1)->endOfMonth();
+ }
+
+ /**
+ * Get target value (from stored value or from relation)
+ */
+ public function getTargetValueAttribute(): int
+ {
+ // Return stored target value if available, otherwise get from relation
+ return $this->target_value ?? $this->kpiTarget?->target_value ?? 0;
+ }
+
+ /**
+ * Get current target value from relation (for comparison)
+ */
+ public function getCurrentTargetValueAttribute(): int
+ {
+ return $this->kpiTarget?->target_value ?? 0;
+ }
+
+ /**
+ * Check if stored target value differs from current target value
+ */
+ public function hasTargetValueChanged(): bool
+ {
+ return $this->target_value !== $this->current_target_value;
+ }
+}
\ No newline at end of file
diff --git a/app/Models/KpiTarget.php b/app/Models/KpiTarget.php
new file mode 100644
index 0000000..210af8b
--- /dev/null
+++ b/app/Models/KpiTarget.php
@@ -0,0 +1,61 @@
+ 'boolean'
+ ];
+
+ protected $attributes = [
+ 'is_active' => true
+ ];
+
+ /**
+ * Get the user that owns the KPI target
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ /**
+ * Get the achievements for this target
+ */
+ public function achievements(): HasMany
+ {
+ return $this->hasMany(KpiAchievement::class);
+ }
+
+ /**
+ * Scope to get active targets
+ */
+ public function scopeActive($query)
+ {
+ return $query->where('is_active', true);
+ }
+
+ /**
+ * Check if target is currently active
+ */
+ public function isCurrentlyActive(): bool
+ {
+ return $this->is_active;
+ }
+}
\ No newline at end of file
diff --git a/app/Models/User.php b/app/Models/User.php
index 7745295..1b9b941 100755
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -132,4 +132,64 @@ class User extends Authenticatable
return false;
}
}
+
+ /**
+ * Get all KPI targets for the User
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function kpiTargets()
+ {
+ return $this->hasMany(KpiTarget::class);
+ }
+
+ /**
+ * Get all KPI achievements for the User
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function kpiAchievements()
+ {
+ return $this->hasMany(KpiAchievement::class);
+ }
+
+ /**
+ * Check if user is mechanic
+ *
+ * @return bool
+ */
+ public function isMechanic()
+ {
+ return $this->hasRole('mechanic');
+ }
+
+ /**
+ * Get current KPI target (no longer filtered by year/month)
+ *
+ * @return KpiTarget|null
+ */
+ public function getCurrentKpiTarget()
+ {
+ return $this->kpiTargets()
+ ->where('is_active', true)
+ ->first();
+ }
+
+ /**
+ * Get KPI achievement for specific year and month
+ *
+ * @param int $year
+ * @param int $month
+ * @return KpiAchievement|null
+ */
+ public function getKpiAchievement($year = null, $month = null)
+ {
+ $year = $year ?? now()->year;
+ $month = $month ?? now()->month;
+
+ return $this->kpiAchievements()
+ ->where('year', $year)
+ ->where('month', $month)
+ ->first();
+ }
}
diff --git a/app/Services/KpiService.php b/app/Services/KpiService.php
new file mode 100644
index 0000000..c841fa2
--- /dev/null
+++ b/app/Services/KpiService.php
@@ -0,0 +1,332 @@
+year;
+ $month = $month ?? now()->month;
+
+ // Get current KPI target (no longer filtered by year/month)
+ $kpiTarget = $user->kpiTargets()
+ ->where('is_active', true)
+ ->first();
+
+ if (!$kpiTarget) {
+ Log::info("No KPI target found for user {$user->id}");
+ return null;
+ }
+
+ // Calculate actual value based on month
+ $actualValue = $this->getActualWorkCount($user, $year, $month);
+
+ // Calculate percentage
+ $achievementPercentage = $kpiTarget->target_value > 0
+ ? ($actualValue / $kpiTarget->target_value) * 100
+ : 0;
+
+ // Save or update achievement with target value stored directly
+ return KpiAchievement::updateOrCreate(
+ [
+ 'user_id' => $user->id,
+ 'year' => $year,
+ 'month' => $month
+ ],
+ [
+ 'kpi_target_id' => $kpiTarget->id,
+ 'target_value' => $kpiTarget->target_value, // Store target value directly for historical tracking
+ 'actual_value' => $actualValue,
+ 'achievement_percentage' => $achievementPercentage
+ ]
+ );
+ }
+
+ /**
+ * Get actual work count for a user in specific month
+ *
+ * @param User $user
+ * @param int $year
+ * @param int $month
+ * @return int
+ */
+ private function getActualWorkCount(User $user, $year, $month)
+ {
+ return Transaction::where('user_id', $user->id)
+ ->where('status', 'completed')
+ ->whereYear('date', $year)
+ ->whereMonth('date', $month)
+ ->sum('qty');
+ }
+
+ /**
+ * Generate KPI report for a user
+ *
+ * @param User $user
+ * @param int|null $year
+ * @param int|null $month
+ * @return array
+ */
+ public function generateKpiReport(User $user, $year = null, $month = null)
+ {
+ $year = $year ?? now()->year;
+ $month = $month ?? now()->month;
+
+ $achievements = $user->kpiAchievements()
+ ->where('year', $year)
+ ->where('month', $month)
+ ->orderBy('month')
+ ->get();
+
+ $target = $user->kpiTargets()
+ ->where('is_active', true)
+ ->first();
+
+ return [
+ 'user' => $user,
+ 'target' => $target,
+ 'achievements' => $achievements,
+ 'summary' => $this->calculateSummary($achievements),
+ 'period' => [
+ 'year' => $year,
+ 'month' => $month,
+ 'period_name' => $this->getMonthName($month) . ' ' . $year
+ ]
+ ];
+ }
+
+ /**
+ * Calculate summary statistics for achievements
+ *
+ * @param \Illuminate\Database\Eloquent\Collection $achievements
+ * @return array
+ */
+ private function calculateSummary($achievements)
+ {
+ if ($achievements->isEmpty()) {
+ return [
+ 'total_target' => 0,
+ 'total_actual' => 0,
+ 'average_achievement' => 0,
+ 'best_period' => null,
+ 'worst_period' => null,
+ 'total_periods' => 0,
+ 'achievement_rate' => 0
+ ];
+ }
+
+ $totalTarget = $achievements->sum('target_value');
+ $totalActual = $achievements->sum('actual_value');
+ $averageAchievement = $achievements->avg('achievement_percentage');
+ $totalPeriods = $achievements->count();
+ $achievementRate = $totalPeriods > 0 ? ($achievements->where('achievement_percentage', '>=', 100)->count() / $totalPeriods) * 100 : 0;
+
+ $bestPeriod = $achievements->sortByDesc('achievement_percentage')->first();
+ $worstPeriod = $achievements->sortBy('achievement_percentage')->first();
+
+ return [
+ 'total_target' => $totalTarget,
+ 'total_actual' => $totalActual,
+ 'average_achievement' => round($averageAchievement, 2),
+ 'best_period' => $bestPeriod,
+ 'worst_period' => $worstPeriod,
+ 'total_periods' => $totalPeriods,
+ 'achievement_rate' => round($achievementRate, 2)
+ ];
+ }
+
+ /**
+ * Get KPI statistics for all mechanics
+ *
+ * @param int|null $year
+ * @param int|null $month
+ * @return array
+ */
+ public function getMechanicsKpiStats($year = null, $month = null)
+ {
+ $year = $year ?? now()->year;
+ $month = $month ?? now()->month;
+
+ $mechanics = User::whereHas('role', function($query) {
+ $query->where('name', 'mechanic');
+ })->get();
+
+ $stats = [];
+ foreach ($mechanics as $mechanic) {
+ $report = $this->generateKpiReport($mechanic, $year, $month);
+ $stats[] = [
+ 'user' => $mechanic,
+ 'summary' => $report['summary'],
+ 'target' => $report['target']
+ ];
+ }
+
+ return $stats;
+ }
+
+ /**
+ * Auto-calculate KPI achievements for all mechanics
+ *
+ * @param int|null $year
+ * @param int|null $month
+ * @return array
+ */
+ public function autoCalculateAllMechanics($year = null, $month = null)
+ {
+ $year = $year ?? now()->year;
+ $month = $month ?? now()->month;
+
+ $mechanics = User::whereHas('role', function($query) {
+ $query->where('name', 'mechanic');
+ })->get();
+
+ $results = [];
+ foreach ($mechanics as $mechanic) {
+ try {
+ $achievement = $this->calculateKpiAchievement($mechanic, $year, $month);
+ $results[] = [
+ 'user_id' => $mechanic->id,
+ 'user_name' => $mechanic->name,
+ 'success' => true,
+ 'achievement' => $achievement
+ ];
+ } catch (\Exception $e) {
+ Log::error("Failed to calculate KPI for user {$mechanic->id}: " . $e->getMessage());
+ $results[] = [
+ 'user_id' => $mechanic->id,
+ 'user_name' => $mechanic->name,
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ];
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get KPI trend data for chart
+ *
+ * @param User $user
+ * @param int $months
+ * @return array
+ */
+ public function getKpiTrendData(User $user, $months = 12)
+ {
+ $endDate = now();
+ $startDate = $endDate->copy()->subMonths($months);
+
+ $achievements = $user->kpiAchievements()
+ ->where(function($query) use ($startDate, $endDate) {
+ $query->where(function($q) use ($startDate, $endDate) {
+ $q->where('year', '>', $startDate->year)
+ ->orWhere(function($subQ) use ($startDate, $endDate) {
+ $subQ->where('year', $startDate->year)
+ ->where('month', '>=', $startDate->month);
+ });
+ })
+ ->where(function($q) use ($endDate) {
+ $q->where('year', '<', $endDate->year)
+ ->orWhere(function($subQ) use ($endDate) {
+ $subQ->where('year', $endDate->year)
+ ->where('month', '<=', $endDate->month);
+ });
+ });
+ })
+ ->orderBy('year')
+ ->orderBy('month')
+ ->get();
+
+ $trendData = [];
+ foreach ($achievements as $achievement) {
+ $trendData[] = [
+ 'period' => $achievement->getPeriodDisplayName(),
+ 'target' => $achievement->target_value,
+ 'actual' => $achievement->actual_value,
+ 'percentage' => $achievement->achievement_percentage,
+ 'status' => $achievement->status
+ ];
+ }
+
+ return $trendData;
+ }
+
+ /**
+ * Get month name in Indonesian
+ *
+ * @param int $month
+ * @return string
+ */
+ private function getMonthName($month)
+ {
+ $monthNames = [
+ 1 => 'Januari', 2 => 'Februari', 3 => 'Maret', 4 => 'April',
+ 5 => 'Mei', 6 => 'Juni', 7 => 'Juli', 8 => 'Agustus',
+ 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Desember'
+ ];
+
+ return $monthNames[$month] ?? 'Unknown';
+ }
+
+ /**
+ * Get KPI summary for dashboard
+ *
+ * @param User $user
+ * @return array
+ */
+ public function getKpiSummary(User $user)
+ {
+ $currentYear = now()->year;
+ $currentMonth = now()->month;
+
+ // Get current month achievement
+ $currentAchievement = $user->kpiAchievements()
+ ->where('year', $currentYear)
+ ->where('month', $currentMonth)
+ ->first();
+
+ // Get current month target (no longer filtered by year/month)
+ $currentTarget = $user->kpiTargets()
+ ->where('is_active', true)
+ ->first();
+
+ // Get last 6 months achievements
+ $recentAchievements = $user->kpiAchievements()
+ ->where(function($query) use ($currentYear, $currentMonth) {
+ $query->where('year', '>', $currentYear - 1)
+ ->orWhere(function($q) use ($currentYear, $currentMonth) {
+ $q->where('year', $currentYear)
+ ->where('month', '>=', max(1, $currentMonth - 5));
+ });
+ })
+ ->orderBy('year', 'desc')
+ ->orderBy('month', 'desc')
+ ->limit(6)
+ ->get();
+
+ return [
+ 'current_achievement' => $currentAchievement,
+ 'current_target' => $currentTarget,
+ 'recent_achievements' => $recentAchievements,
+ 'current_percentage' => $currentAchievement ? $currentAchievement->achievement_percentage : 0,
+ 'is_on_track' => $currentAchievement ? $currentAchievement->achievement_percentage >= 100 : false
+ ];
+ }
+}
\ No newline at end of file
diff --git a/database/migrations/2025_07_04_105900_create_kpi_targets_table.php b/database/migrations/2025_07_04_105900_create_kpi_targets_table.php
new file mode 100644
index 0000000..fe39063
--- /dev/null
+++ b/database/migrations/2025_07_04_105900_create_kpi_targets_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->foreignId('user_id')->constrained()->onDelete('cascade');
+ $table->integer('target_value');
+ $table->boolean('is_active')->default(true);
+ $table->text('description')->nullable();
+ $table->timestamps();
+
+ // Unique constraint untuk mencegah duplikasi target aktif per user (satu target aktif per user)
+ $table->unique(['user_id', 'is_active'], 'kpi_targets_user_active_unique');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('kpi_targets');
+ }
+}
diff --git a/database/migrations/2025_07_04_110119_create_kpi_achievements_table.php b/database/migrations/2025_07_04_110119_create_kpi_achievements_table.php
new file mode 100644
index 0000000..425f0c3
--- /dev/null
+++ b/database/migrations/2025_07_04_110119_create_kpi_achievements_table.php
@@ -0,0 +1,43 @@
+id();
+ $table->foreignId('user_id')->constrained()->onDelete('cascade');
+ $table->foreignId('kpi_target_id')->constrained()->onDelete('cascade');
+ $table->integer('target_value'); // Menyimpan target value secara langsung untuk historical tracking
+ $table->integer('actual_value')->default(0);
+ $table->decimal('achievement_percentage', 5, 2)->default(0);
+ $table->integer('year');
+ $table->integer('month');
+ $table->text('notes')->nullable();
+ $table->timestamps();
+
+ // Unique constraint untuk mencegah duplikasi achievement per user per bulan
+ // Note: Tidak menggunakan kpi_target_id karena target sekarang permanen per user
+ $table->unique(['user_id', 'year', 'month']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('kpi_achievements');
+ }
+}
diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php
index 0f524b4..500b73b 100755
--- a/database/seeders/MenuSeeder.php
+++ b/database/seeders/MenuSeeder.php
@@ -34,6 +34,10 @@ class MenuSeeder extends Seeder
[
'name' => 'Histori Stock',
'link' => 'stock-audit.index'
+ ],
+ [
+ 'name' => 'Target',
+ 'link' => 'kpi.targets.index'
]
];
diff --git a/public/js/init.js b/public/js/init.js
index 1af1f76..9129148 100755
--- a/public/js/init.js
+++ b/public/js/init.js
@@ -1,63 +1,67 @@
"use strict";
function moneyFormat(n, currency) {
- n = (n != null) ? n : 0;
+ n = n != null ? n : 0;
var v = parseFloat(n).toFixed(0);
- return currency + " " + v.replace(/./g, function (c, i, a) {
- return i > 0 && c !== "," && (a.length - i) % 3 === 0 ? "." + c : c;
- });
+ return (
+ currency +
+ " " +
+ v.replace(/./g, function (c, i, a) {
+ return i > 0 && c !== "," && (a.length - i) % 3 === 0 ? "." + c : c;
+ })
+ );
}
-var KTToastr = function () {
+var KTToastr = (function () {
var successToastr = function (msg, title) {
toastr.options = {
- "closeButton": true,
- "debug": false,
- "newestOnTop": false,
- "progressBar": false,
- "positionClass": "toast-top-right",
- "preventDuplicates": true,
- "onclick": null,
- "showDuration": "500",
- "hideDuration": "500",
- "timeOut": "3000",
- "extendedTimeOut": "3000",
- "showEasing": "swing",
- "hideEasing": "swing",
- "showMethod": "fadeIn",
- "hideMethod": "fadeOut"
+ closeButton: true,
+ debug: false,
+ newestOnTop: false,
+ progressBar: false,
+ positionClass: "toast-top-right",
+ preventDuplicates: true,
+ onclick: null,
+ showDuration: "500",
+ hideDuration: "500",
+ timeOut: "3000",
+ extendedTimeOut: "3000",
+ showEasing: "swing",
+ hideEasing: "swing",
+ showMethod: "fadeIn",
+ hideMethod: "fadeOut",
};
var $toast = toastr["success"](msg, title);
- if (typeof $toast === 'undefined') {
+ if (typeof $toast === "undefined") {
return;
}
- }
+ };
var errorToastr = function (msg, title) {
toastr.options = {
- "closeButton": true,
- "debug": false,
- "newestOnTop": false,
- "progressBar": false,
- "positionClass": "toast-top-right",
- "preventDuplicates": true,
- "onclick": null,
- "showDuration": "500",
- "hideDuration": "500",
- "timeOut": "3000",
- "extendedTimeOut": "3000",
- "showEasing": "swing",
- "hideEasing": "swing",
- "showMethod": "fadeIn",
- "hideMethod": "fadeOut"
+ closeButton: true,
+ debug: false,
+ newestOnTop: false,
+ progressBar: false,
+ positionClass: "toast-top-right",
+ preventDuplicates: true,
+ onclick: null,
+ showDuration: "500",
+ hideDuration: "500",
+ timeOut: "3000",
+ extendedTimeOut: "3000",
+ showEasing: "swing",
+ hideEasing: "swing",
+ showMethod: "fadeIn",
+ hideMethod: "fadeOut",
};
var $toast = toastr["error"](msg, title);
- if (typeof $toast === 'undefined') {
+ if (typeof $toast === "undefined") {
return;
}
- }
+ };
return {
initSuccess: function (msg, title) {
@@ -65,11 +69,29 @@ var KTToastr = function () {
},
initError: function (msg, title) {
errorToastr(msg, title);
- }
+ },
};
-}();
+})();
jQuery(document).ready(function () {
- var li = $('.kt-menu__item--active');
- li.closest('li.kt-menu__item--submenu').addClass('kt-menu__item--open');
-});
\ No newline at end of file
+ var li = $(".kt-menu__item--active");
+ li.closest("li.kt-menu__item--submenu").addClass("kt-menu__item--open");
+
+ // Initialize Select2 globally
+ if (typeof $.fn.select2 !== "undefined") {
+ $(".select2").select2({
+ theme: "bootstrap4",
+ width: "100%",
+ });
+ }
+
+ // Initialize DatePicker globally
+ if (typeof $.fn.datepicker !== "undefined") {
+ $(".datepicker").datepicker({
+ format: "yyyy-mm-dd",
+ autoclose: true,
+ todayHighlight: true,
+ orientation: "bottom auto",
+ });
+ }
+});
diff --git a/resources/views/kpi/targets/create.blade.php b/resources/views/kpi/targets/create.blade.php
new file mode 100644
index 0000000..ce38732
--- /dev/null
+++ b/resources/views/kpi/targets/create.blade.php
@@ -0,0 +1,252 @@
+@extends('layouts.backapp')
+
+@section('title', 'Tambah Target KPI')
+
+@section('styles')
+
+@endsection
+
+@section('content')
+
+
+
+
+
+
+ Tambah Target KPI
+
+
+
+
+
+
+
+
+@endsection
+
+@push('javascripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/kpi/targets/edit.blade.php b/resources/views/kpi/targets/edit.blade.php
new file mode 100644
index 0000000..16bea89
--- /dev/null
+++ b/resources/views/kpi/targets/edit.blade.php
@@ -0,0 +1,263 @@
+@extends('layouts.backapp')
+
+@section('title', 'Edit Target KPI')
+
+@section('styles')
+
+@endsection
+
+@section('content')
+
+
+
+
+
+
+ Edit Target KPI
+
+
+
+
+
+
+
+
+
+
+
+@endsection
+
+@push('javascripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/kpi/targets/index.blade.php b/resources/views/kpi/targets/index.blade.php
new file mode 100644
index 0000000..84c5bbb
--- /dev/null
+++ b/resources/views/kpi/targets/index.blade.php
@@ -0,0 +1,212 @@
+@extends('layouts.backapp')
+
+@section('content')
+
+
+
+
+
+
+ Manajemen Target KPI
+
+
+
+
+
+ @if(session('success'))
+
+ {{ session('success') }}
+
+
+ @endif
+
+ @if(session('error'))
+
+ {{ session('error') }}
+
+
+ @endif
+
+
+
+
+
+ | No |
+ Mekanik |
+ Target |
+ Status |
+ Aksi |
+
+
+
+ @forelse($targets as $target)
+
+ | {{ $loop->iteration }} |
+ {{ $target->user->name }} |
+ {{ number_format($target->target_value) }} |
+
+ @if($target->is_active)
+ Aktif
+ @else
+ Nonaktif
+ @endif
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ @empty
+
+ | Tidak ada data target KPI |
+
+ @endforelse
+
+
+
+
+ @if($targets->hasPages())
+
+ {{ $targets->links() }}
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+@endsection
+
+@push('javascripts')
+
+@endpush
\ No newline at end of file
diff --git a/resources/views/kpi/targets/show.blade.php b/resources/views/kpi/targets/show.blade.php
new file mode 100644
index 0000000..723049c
--- /dev/null
+++ b/resources/views/kpi/targets/show.blade.php
@@ -0,0 +1,193 @@
+@extends('layouts.backapp')
+
+@section('content')
+
+
+
+
+
+
+ Detail Target KPI
+
+
+
+
+
+
+
+
+
+ | Mekanik |
+ : {{ $target->user->name }} |
+
+
+ | Email |
+ : {{ $target->user->email }} |
+
+
+ | Dealer |
+ : {{ $target->user->dealer->name ?? 'N/A' }} |
+
+
+ | Target Nilai |
+ : {{ number_format($target->target_value) }} Pekerjaan |
+
+
+ | Status |
+ :
+ @if($target->is_active)
+ Aktif
+ @else
+ Nonaktif
+ @endif
+ |
+
+
+
+
+
+
+ | Jenis Target |
+ : Target Permanen |
+
+
+ | Berlaku Sejak |
+ : {{ $target->created_at->format('d/m/Y') }} |
+
+
+ | Dibuat |
+ : {{ $target->created_at->format('d/m/Y H:i') }} |
+
+
+ | Terakhir Update |
+ : {{ $target->updated_at->format('d/m/Y H:i') }} |
+
+
+ | Total Pencapaian |
+ : {{ $target->achievements->count() }} Bulan |
+
+
+
+
+
+ @if($target->description)
+
+
+
Deskripsi:
+
{{ $target->description }}
+
+
+ @endif
+
+
+
+
+
Riwayat Pencapaian Bulanan
+ @if($target->achievements->count() > 0)
+
+
+
+
+ | Periode |
+ Target |
+ Aktual |
+ Pencapaian |
+ Status |
+
+
+
+ @foreach($target->achievements->sortByDesc('year')->sortByDesc('month') as $achievement)
+
+ | {{ $achievement->getPeriodDisplayName() }} |
+ {{ number_format($achievement->target_value) }} |
+ {{ number_format($achievement->actual_value) }} |
+ {{ number_format($achievement->achievement_percentage, 1) }}% |
+
+
+ @switch($achievement->status)
+ @case('exceeded')
+ Melebihi Target
+ @break
+ @case('good')
+ Baik
+ @break
+ @case('fair')
+ Cukup
+ @break
+ @case('poor')
+ Kurang
+ @break
+ @default
+ Tidak Diketahui
+ @endswitch
+
+ |
+
+ @endforeach
+
+
+
+ @else
+
+ Belum ada data pencapaian untuk target ini.
+
+ @endif
+
+
+
+
+ @if($target->achievements->count() > 0)
+
+
+
Statistik Pencapaian
+
+
+
+
+
{{ $target->achievements->count() }}
+ Total Pencapaian
+
+
+
+
+
+
+
{{ $target->achievements->where('achievement_percentage', '>=', 100)->count() }}
+ Target Tercapai
+
+
+
+
+
+
+
{{ number_format($target->achievements->avg('achievement_percentage'), 1) }}%
+ Rata-rata Pencapaian
+
+
+
+
+
+
+
{{ number_format($target->achievements->max('achievement_percentage'), 1) }}%
+ Pencapaian Tertinggi
+
+
+
+
+
+
+ @endif
+
+
+
+
+@endsection
\ No newline at end of file
diff --git a/resources/views/layouts/partials/sidebarMenu.blade.php b/resources/views/layouts/partials/sidebarMenu.blade.php
index 57fd2ad..bc86027 100755
--- a/resources/views/layouts/partials/sidebarMenu.blade.php
+++ b/resources/views/layouts/partials/sidebarMenu.blade.php
@@ -222,6 +222,22 @@
@endcan
+
+
+
+ @can('view', $menus['kpi.targets.index'])
+
+ @endcan
diff --git a/resources/views/transaction/index.blade.php b/resources/views/transaction/index.blade.php
index 3fc6922..98366b3 100755
--- a/resources/views/transaction/index.blade.php
+++ b/resources/views/transaction/index.blade.php
@@ -320,6 +320,8 @@ use Illuminate\Support\Facades\Auth;
border-color: #28a745;
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
}
+
+
@endsection
@@ -359,6 +361,58 @@ use Illuminate\Support\Facades\Auth;
Dealer {{ $mechanic->dealer_name }}
Total {{ $count_transaction_dealers }} Pekerjaan terkirim pada dealer
Anda telah posting {{ $count_transaction_users }} pekerjaan
+
+
+
+ @if($kpiData['has_target'])
+
+
+ Target {{ $kpiData['period'] }}
+ {{ number_format($kpiData['target']) }} Pekerjaan
+
+
+ Pencapaian
+ {{ number_format($kpiData['actual']) }} Pekerjaan
+
+
+
+
+ Progress
+ {{ $kpiData['percentage'] }}%
+
+
+ @php
+ $progressWidth = min(100, $kpiData['percentage']);
+ @endphp
+
+
+
+
+ @if($kpiData['status'] == 'exceeded')
+ Target tercapai!
+ @elseif($kpiData['status'] == 'good')
+ Performa baik
+ @elseif($kpiData['status'] == 'fair')
+ Perlu peningkatan
+ @elseif($kpiData['status'] == 'poor')
+ Perlu perbaikan
+ @else
+ Belum ada data
+ @endif
+
+
+ @else
+
+
+ Belum ada target KPI untuk {{ $kpiData['period'] }}
+
+ @endif
+
@@ -1665,6 +1719,8 @@ use Illuminate\Support\Facades\Auth;
$(this).val('0.00');
}
});
+
+
});
// Handle when input loses focus - set default if empty
diff --git a/routes/web.php b/routes/web.php
index 42b87c2..644e390 100755
--- a/routes/web.php
+++ b/routes/web.php
@@ -13,6 +13,7 @@ use App\Http\Controllers\WarehouseManagement\ProductsController;
use App\Http\Controllers\WorkController;
use App\Http\Controllers\WarehouseManagement\MutationsController;
use App\Http\Controllers\WarehouseManagement\StockAuditController;
+use App\Http\Controllers\KPI\TargetsController;
use App\Models\Menu;
use App\Models\Privilege;
use App\Models\Role;
@@ -282,6 +283,14 @@ Route::group(['middleware' => 'auth'], function() {
Route::get('{stockLog}/detail', 'getDetail')->name('detail');
});
});
+
+ // KPI Routes for Admins
+ Route::prefix('kpi')->middleware(['adminRole'])->group(function () {
+ // Target Management
+ Route::resource('targets', TargetsController::class, ['as' => 'kpi']);
+ Route::post('/targets/{target}/toggle-status', [TargetsController::class, 'toggleStatus'])->name('kpi.targets.toggle-status');
+ Route::get('/targets/user/{user}', [TargetsController::class, 'getUserTargets'])->name('kpi.targets.user');
+ });
});
Auth::routes();