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 +

+
+ +
+
+
+ @csrf + +
+
+
+ + + @if($mechanics->isEmpty()) +
+ + Tidak ada mekanik yang ditemukan. Pastikan ada user dengan role "mechanic" di sistem. +
+ @else + + Ditemukan {{ $mechanics->count() }} mekanik. + @if($mechanics->count() >= 50) + Menampilkan 50 mekanik pertama. Gunakan pencarian untuk menemukan mekanik tertentu. + @endif + + @endif + @error('user_id') + {{ $message }} + @enderror +
+
+ +
+
+ + + @error('target_value') + {{ $message }} + @enderror +
+
+
+ + + +
+
+
+ + + @error('description') + {{ $message }} + @enderror +
+
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+ + Kembali +
+
+
+
+
+
+
+
+@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 +

+
+ +
+
+ +
+ @csrf + @method('PUT') + +
+
+
+ + + + @error('user_id') + {{ $message }} + @enderror +
+
+ +
+
+ + + @error('target_value') + {{ $message }} + @enderror +
+
+
+ + + +
+
+
+ + + @error('description') + {{ $message }} + @enderror +
+
+
+ +
+
+
+
+ is_active) ? 'checked' : '' }}> + +
+
+
+
+ +
+
+
+ + Kembali +
+
+
+
+
+
+
+
+@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')) + + @endif + + @if(session('error')) + + @endif + +
+ + + + + + + + + + + + @forelse($targets as $target) + + + + + + + + @empty + + + + @endforelse + +
NoMekanikTargetStatusAksi
{{ $loop->iteration }}{{ $target->user->name }}{{ number_format($target->target_value) }} + @if($target->is_active) + Aktif + @else + Nonaktif + @endif + +
+ + + + + + + +
+ @csrf + @method('DELETE') + +
+
+
Tidak ada data target KPI
+
+ + @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) +
+ + + + + + + + + + + + @foreach($target->achievements->sortByDesc('year')->sortByDesc('month') as $achievement) + + + + + + + + @endforeach + +
PeriodeTargetAktualPencapaianStatus
{{ $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 + +
+
+ @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 + +
+
+ + KPI +
+
+ + @can('view', $menus['kpi.targets.index']) +
  • + + + Target + +
  • + @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();