partial update create kpi and progress bar

This commit is contained in:
2025-07-04 18:27:32 +07:00
parent 0ef03fe7cb
commit fa554446ca
19 changed files with 2150 additions and 45 deletions

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\KPI;
use App\Http\Controllers\Controller;
use App\Http\Requests\KPI\StoreKpiTargetRequest;
use App\Http\Requests\KPI\UpdateKpiTargetRequest;
use App\Models\KpiTarget;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
class TargetsController extends Controller
{
/**
* Display a listing of KPI targets
*/
public function index()
{
$targets = KpiTarget::with(['user', 'user.role'])
->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
]);
}
}

View File

@@ -49,7 +49,28 @@ class TransactionController extends Controller
->where('active', true) ->where('active', true)
->get(); ->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) public function workcategory($category_id)

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\KPI;
use Illuminate\Foundation\Http\FormRequest;
use Carbon\Carbon;
class StoreKpiTargetRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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)
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\KPI;
use Illuminate\Foundation\Http\FormRequest;
use Carbon\Carbon;
class UpdateKpiTargetRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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)
]);
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class KpiAchievement extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'kpi_target_id',
'target_value',
'actual_value',
'achievement_percentage',
'year',
'month',
'notes'
];
protected $casts = [
'achievement_percentage' => '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;
}
}

61
app/Models/KpiTarget.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Carbon\Carbon;
class KpiTarget extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'target_value',
'is_active',
'description'
];
protected $casts = [
'is_active' => '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;
}
}

View File

@@ -132,4 +132,64 @@ class User extends Authenticatable
return false; 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();
}
} }

332
app/Services/KpiService.php Normal file
View File

@@ -0,0 +1,332 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Transaction;
use App\Models\KpiTarget;
use App\Models\KpiAchievement;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class KpiService
{
/**
* Calculate KPI achievement for a user
*
* @param User $user
* @param int $year
* @param int $month
* @return KpiAchievement|null
*/
public function calculateKpiAchievement(User $user, $year = null, $month = null)
{
$year = $year ?? now()->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
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateKpiTargetsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('kpi_targets', function (Blueprint $table) {
$table->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');
}
}

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateKpiAchievementsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('kpi_achievements', function (Blueprint $table) {
$table->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');
}
}

View File

@@ -34,6 +34,10 @@ class MenuSeeder extends Seeder
[ [
'name' => 'Histori Stock', 'name' => 'Histori Stock',
'link' => 'stock-audit.index' 'link' => 'stock-audit.index'
],
[
'name' => 'Target',
'link' => 'kpi.targets.index'
] ]
]; ];

View File

@@ -1,63 +1,67 @@
"use strict"; "use strict";
function moneyFormat(n, currency) { function moneyFormat(n, currency) {
n = (n != null) ? n : 0; n = n != null ? n : 0;
var v = parseFloat(n).toFixed(0); var v = parseFloat(n).toFixed(0);
return currency + " " + v.replace(/./g, function (c, i, a) { return (
return i > 0 && c !== "," && (a.length - i) % 3 === 0 ? "." + c : c; 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) { var successToastr = function (msg, title) {
toastr.options = { toastr.options = {
"closeButton": true, closeButton: true,
"debug": false, debug: false,
"newestOnTop": false, newestOnTop: false,
"progressBar": false, progressBar: false,
"positionClass": "toast-top-right", positionClass: "toast-top-right",
"preventDuplicates": true, preventDuplicates: true,
"onclick": null, onclick: null,
"showDuration": "500", showDuration: "500",
"hideDuration": "500", hideDuration: "500",
"timeOut": "3000", timeOut: "3000",
"extendedTimeOut": "3000", extendedTimeOut: "3000",
"showEasing": "swing", showEasing: "swing",
"hideEasing": "swing", hideEasing: "swing",
"showMethod": "fadeIn", showMethod: "fadeIn",
"hideMethod": "fadeOut" hideMethod: "fadeOut",
}; };
var $toast = toastr["success"](msg, title); var $toast = toastr["success"](msg, title);
if (typeof $toast === 'undefined') { if (typeof $toast === "undefined") {
return; return;
} }
} };
var errorToastr = function (msg, title) { var errorToastr = function (msg, title) {
toastr.options = { toastr.options = {
"closeButton": true, closeButton: true,
"debug": false, debug: false,
"newestOnTop": false, newestOnTop: false,
"progressBar": false, progressBar: false,
"positionClass": "toast-top-right", positionClass: "toast-top-right",
"preventDuplicates": true, preventDuplicates: true,
"onclick": null, onclick: null,
"showDuration": "500", showDuration: "500",
"hideDuration": "500", hideDuration: "500",
"timeOut": "3000", timeOut: "3000",
"extendedTimeOut": "3000", extendedTimeOut: "3000",
"showEasing": "swing", showEasing: "swing",
"hideEasing": "swing", hideEasing: "swing",
"showMethod": "fadeIn", showMethod: "fadeIn",
"hideMethod": "fadeOut" hideMethod: "fadeOut",
}; };
var $toast = toastr["error"](msg, title); var $toast = toastr["error"](msg, title);
if (typeof $toast === 'undefined') { if (typeof $toast === "undefined") {
return; return;
} }
} };
return { return {
initSuccess: function (msg, title) { initSuccess: function (msg, title) {
@@ -65,11 +69,29 @@ var KTToastr = function () {
}, },
initError: function (msg, title) { initError: function (msg, title) {
errorToastr(msg, title); errorToastr(msg, title);
} },
}; };
}(); })();
jQuery(document).ready(function () { jQuery(document).ready(function () {
var li = $('.kt-menu__item--active'); var li = $(".kt-menu__item--active");
li.closest('li.kt-menu__item--submenu').addClass('kt-menu__item--open'); 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",
});
}
});

View File

@@ -0,0 +1,252 @@
@extends('layouts.backapp')
@section('title', 'Tambah Target KPI')
@section('styles')
<style>
.select2-container .select2-selection {
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.select2-container.select2-container--focus .select2-selection {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(197, 214, 233, 0.25);
}
.select2-container .select2-selection--single .select2-selection__rendered {
padding-left: 0;
line-height: 1.5;
}
.select2-container .select2-selection--single .select2-selection__arrow {
height: 100%;
}
.select2-results__option--highlighted[aria-selected] {
background-color: #007bff;
color: white;
}
/* Limit Select2 dropdown height */
.select2-results__options {
max-height: 200px;
overflow-y: auto;
}
/* Style for Select2 results */
.select2-results__option {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.select2-results__option:last-child {
border-bottom: none;
}
/* Improve Select2 search box */
.select2-search--dropdown .select2-search__field {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
</style>
@endsection
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Tambah Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
<form id="kpi-form" method="POST" action="{{ route('kpi.targets.store') }}">
@csrf
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="user_id" class="form-control-label">Mekanik <span class="text-danger">*</span></label>
<select name="user_id" id="user_id" class="form-control select2" required>
<option value="">Pilih Mekanik</option>
@foreach($mechanics as $mechanic)
<option value="{{ $mechanic->id }}" {{ old('user_id') == $mechanic->id ? 'selected' : '' }}>
{{ $mechanic->name }} ({{ $mechanic->dealer->name ?? 'N/A' }})
</option>
@endforeach
</select>
@if($mechanics->isEmpty())
<div class="alert alert-warning mt-2">
<i class="fas fa-exclamation-triangle"></i>
Tidak ada mekanik yang ditemukan. Pastikan ada user dengan role "mechanic" di sistem.
</div>
@else
<small class="form-text text-muted">
Ditemukan {{ $mechanics->count() }} mekanik.
@if($mechanics->count() >= 50)
Menampilkan 50 mekanik pertama. Gunakan pencarian untuk menemukan mekanik tertentu.
@endif
</small>
@endif
@error('user_id')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="target_value" class="form-control-label">Target Nilai <span class="text-danger">*</span></label>
<input type="number" name="target_value" id="target_value" class="form-control"
value="{{ old('target_value') }}" min="1" required>
@error('target_value')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<label for="description" class="form-control-label">Deskripsi</label>
<textarea name="description" id="description" class="form-control" rows="3"
placeholder="Deskripsi target (opsional)">{{ old('description') }}</textarea>
@error('description')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" name="is_active" id="is_active" class="custom-control-input"
value="1" {{ old('is_active', true) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">
Target Aktif
</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Simpan Target
</button>
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">Kembali</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
@push('javascripts')
<script>
$(document).ready(function() {
// Initialize Select2 with fallback
try {
// Initialize Select2 for mechanics with search limit
$('#user_id').select2({
theme: 'bootstrap4',
width: '100%',
placeholder: 'Pilih Mekanik',
allowClear: true,
minimumInputLength: 1,
maximumInputLength: 50,
maximumResultsForSearch: 10,
language: {
inputTooShort: function() {
return "Masukkan minimal 1 karakter untuk mencari";
},
inputTooLong: function() {
return "Maksimal 50 karakter";
},
noResults: function() {
return "Tidak ada hasil ditemukan";
},
searching: function() {
return "Mencari...";
}
}
});
} catch (error) {
console.log('Select2 not available, using regular select');
// Fallback: ensure regular select works
$('.select2').removeClass('select2').addClass('form-control');
}
// Form validation
$('#kpi-form').on('submit', function(e) {
var isValid = true;
var errors = [];
// Clear previous errors
$('.text-danger').remove();
// Validate required fields
if (!$('#user_id').val()) {
errors.push('Mekanik harus dipilih');
isValid = false;
}
if (!$('#target_value').val() || $('#target_value').val() < 1) {
errors.push('Target nilai harus diisi dan minimal 1');
isValid = false;
}
if (!isValid) {
e.preventDefault();
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'error',
title: 'Validasi Gagal',
html: errors.join('<br>'),
confirmButtonText: 'OK'
});
} else {
alert('Validasi Gagal:\n' + errors.join('\n'));
}
}
});
});
</script>
@endpush

View File

@@ -0,0 +1,263 @@
@extends('layouts.backapp')
@section('title', 'Edit Target KPI')
@section('styles')
<style>
.select2-container .select2-selection {
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #495057;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.select2-container.select2-container--focus .select2-selection {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.select2-container .select2-selection--single .select2-selection__rendered {
padding-left: 0;
line-height: 1.5;
}
.select2-container .select2-selection--single .select2-selection__arrow {
height: 100%;
}
.select2-results__option--highlighted[aria-selected] {
background-color: #007bff;
color: white;
}
/* Ensure Select2 is visible */
.select2-container {
z-index: 9999;
}
.select2-dropdown {
z-index: 9999;
}
/* Fix Select2 width */
.select2-container--default .select2-selection--single {
height: calc(1.5em + 0.75rem + 2px);
padding: 0.375rem 0.75rem;
}
/* Limit Select2 dropdown height */
.select2-results__options {
max-height: 200px;
overflow-y: auto;
}
/* Style for Select2 results */
.select2-results__option {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.select2-results__option:last-child {
border-bottom: none;
}
/* Improve Select2 search box */
.select2-search--dropdown .select2-search__field {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
</style>
@endsection
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Edit Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
<form id="kpi-form" method="POST" action="{{ route('kpi.targets.update', $target->id) }}">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="user_id" class="form-control-label">Mekanik <span class="text-danger">*</span></label>
<select name="user_id" id="user_id" class="form-control select2" required>
<option value="">Pilih Mekanik</option>
@foreach($mechanics as $mechanic)
@php
$isSelected = old('user_id', $target->user_id) == $mechanic->id;
@endphp
<option value="{{ $mechanic->id }}"
{{ $isSelected ? 'selected' : '' }}>
{{ $mechanic->name }}
</option>
@endforeach
</select>
@error('user_id')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="target_value" class="form-control-label">Target Nilai <span class="text-danger">*</span></label>
<input type="number" name="target_value" id="target_value" class="form-control"
value="{{ old('target_value', $target->target_value) }}" min="1" required>
@error('target_value')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<label for="description" class="form-control-label">Deskripsi</label>
<textarea name="description" id="description" class="form-control" rows="3"
placeholder="Deskripsi target (opsional)">{{ old('description', $target->description) }}</textarea>
@error('description')
<span class="text-danger">{{ $message }}</span>
@enderror
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="checkbox" name="is_active" id="is_active" class="custom-control-input"
value="1" {{ old('is_active', $target->is_active) ? 'checked' : '' }}>
<label class="custom-control-label" for="is_active">
Target Aktif
</label>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Update Target
</button>
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">Kembali</a>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
@push('javascripts')
<script>
$(document).ready(function() {
// Initialize Select2 with fallback and delay
setTimeout(function() {
try {
// Initialize Select2 for mechanics with search limit
$('#user_id').select2({
theme: 'bootstrap4',
width: '100%',
placeholder: 'Pilih Mekanik',
allowClear: true,
minimumInputLength: 1,
maximumInputLength: 50,
maximumResultsForSearch: 10,
language: {
inputTooShort: function() {
return "Masukkan minimal 1 karakter untuk mencari";
},
inputTooLong: function() {
return "Maksimal 50 karakter";
},
noResults: function() {
return "Tidak ada hasil ditemukan";
},
searching: function() {
return "Mencari...";
}
}
});
} catch (error) {
console.log('Select2 not available, using regular select');
// Fallback: ensure regular select works
$('.select2').removeClass('select2').addClass('form-control');
}
}, 100);
// Form validation
$('#kpi-form').on('submit', function(e) {
var isValid = true;
var errors = [];
// Clear previous errors
$('.text-danger').remove();
// Validate required fields
if (!$('#user_id').val()) {
errors.push('Mekanik harus dipilih');
isValid = false;
}
if (!$('#target_value').val() || $('#target_value').val() < 1) {
errors.push('Target nilai harus diisi dan minimal 1');
isValid = false;
}
if (!isValid) {
e.preventDefault();
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'error',
title: 'Validasi Gagal',
html: errors.join('<br>'),
confirmButtonText: 'OK'
});
} else {
alert('Validasi Gagal:\n' + errors.join('\n'));
}
}
});
});
</script>
@endpush

View File

@@ -0,0 +1,212 @@
@extends('layouts.backapp')
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Manajemen Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.create') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Tambah Target
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<div class="table-responsive">
<table class="table table-striped table-bordered" id="kpiTargetsTable">
<thead>
<tr>
<th>No</th>
<th>Mekanik</th>
<th>Target</th>
<th>Status</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
@forelse($targets as $target)
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $target->user->name }}</td>
<td>{{ number_format($target->target_value) }}</td>
<td>
@if($target->is_active)
<span class="badge badge-success">Aktif</span>
@else
<span class="badge badge-secondary">Nonaktif</span>
@endif
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ route('kpi.targets.show', $target->id) }}"
class="btn btn-sm btn-info" title="Detail">
<i class="fas fa-eye"></i>
</a>
<a href="{{ route('kpi.targets.edit', $target->id) }}"
class="btn btn-sm btn-warning" title="Edit">
<i class="fas fa-edit"></i>
</a>
<button type="button"
class="btn btn-sm btn-{{ $target->is_active ? 'warning' : 'success' }}"
onclick="toggleStatus({{ $target->id }})"
title="{{ $target->is_active ? 'Nonaktifkan' : 'Aktifkan' }}">
<i class="fas fa-{{ $target->is_active ? 'pause' : 'play' }}"></i>
</button>
<form action="{{ route('kpi.targets.destroy', $target->id) }}"
method="POST"
style="display: inline;"
onsubmit="return confirm('Yakin ingin menghapus target ini?')">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-danger" title="Hapus">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center">Tidak ada data target KPI</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($targets->hasPages())
<div class="d-flex justify-content-center">
{{ $targets->links() }}
</div>
@endif
</div>
</div>
</div>
</div>
<!-- Filter Modal -->
<div class="modal fade" id="filterModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Filter Target KPI</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="{{ route('kpi.targets.index') }}" method="GET">
<div class="modal-body">
<div class="form-group">
<label>Mekanik</label>
<select name="user_id" class="form-control">
<option value="">Semua Mekanik</option>
@foreach($mechanics as $mechanic)
<option value="{{ $mechanic->id }}"
{{ request('user_id') == $mechanic->id ? 'selected' : '' }}>
{{ $mechanic->name }}
</option>
@endforeach
</select>
</div>
<div class="form-group">
<label>Status</label>
<select name="is_active" class="form-control">
<option value="">Semua Status</option>
<option value="1" {{ request('is_active') == '1' ? 'selected' : '' }}>Aktif</option>
<option value="0" {{ request('is_active') == '0' ? 'selected' : '' }}>Nonaktif</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-primary">Filter</button>
</div>
</form>
</div>
</div>
</div>
@endsection
@push('javascripts')
<script>
$(document).ready(function() {
// Initialize DataTable
$('#kpiTargetsTable').DataTable({
"pageLength": 25,
"order": [[0, "asc"]],
"language": {
"url": "//cdn.datatables.net/plug-ins/1.10.24/i18n/Indonesian.json"
}
});
// Auto hide alerts after 5 seconds
setTimeout(function() {
$('.alert').fadeOut('slow');
}, 5000);
});
function toggleStatus(targetId) {
if (confirm('Yakin ingin mengubah status target ini?')) {
$.ajax({
url: '{{ route("kpi.targets.toggle-status", ":id") }}'.replace(':id', targetId),
type: 'POST',
data: {
_token: '{{ csrf_token() }}'
},
success: function(response) {
if (response.success) {
Swal.fire({
icon: 'success',
title: 'Berhasil',
text: response.message,
timer: 2000,
showConfirmButton: false
}).then(function() {
location.reload();
});
} else {
Swal.fire({
icon: 'error',
title: 'Error',
text: response.message
});
}
},
error: function() {
Swal.fire({
icon: 'error',
title: 'Error',
text: 'Terjadi kesalahan saat mengubah status'
});
}
});
}
}
</script>
@endpush

View File

@@ -0,0 +1,193 @@
@extends('layouts.backapp')
@section('content')
<div class="kt-content kt-grid__item kt-grid__item--fluid kt-grid kt-grid--hor" id="kt_content">
<div class="kt-container kt-container--fluid kt-grid__item kt-grid__item--fluid">
<div class="kt-portlet kt-portlet--mobile">
<div class="kt-portlet__head kt-portlet__head--lg">
<div class="kt-portlet__head-label">
<h3 class="kt-portlet__head-title">
Detail Target KPI
</h3>
</div>
<div class="kt-portlet__head-toolbar">
<div class="kt-portlet__head-actions">
<a href="{{ route('kpi.targets.edit', $target->id) }}" class="btn btn-warning">
<i class="fas fa-edit"></i> Edit
</a>
<a href="{{ route('kpi.targets.index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Kembali
</a>
</div>
</div>
</div>
<div class="kt-portlet__body">
<div class="row">
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td width="150"><strong>Mekanik</strong></td>
<td>: {{ $target->user->name }}</td>
</tr>
<tr>
<td><strong>Email</strong></td>
<td>: {{ $target->user->email }}</td>
</tr>
<tr>
<td><strong>Dealer</strong></td>
<td>: {{ $target->user->dealer->name ?? 'N/A' }}</td>
</tr>
<tr>
<td><strong>Target Nilai</strong></td>
<td>: {{ number_format($target->target_value) }} Pekerjaan</td>
</tr>
<tr>
<td><strong>Status</strong></td>
<td>:
@if($target->is_active)
<span class="badge badge-success">Aktif</span>
@else
<span class="badge badge-secondary">Nonaktif</span>
@endif
</td>
</tr>
</table>
</div>
<div class="col-md-6">
<table class="table table-borderless">
<tr>
<td width="150"><strong>Jenis Target</strong></td>
<td>: <span class="badge badge-info">Target Permanen</span></td>
</tr>
<tr>
<td><strong>Berlaku Sejak</strong></td>
<td>: {{ $target->created_at->format('d/m/Y') }}</td>
</tr>
<tr>
<td><strong>Dibuat</strong></td>
<td>: {{ $target->created_at->format('d/m/Y H:i') }}</td>
</tr>
<tr>
<td><strong>Terakhir Update</strong></td>
<td>: {{ $target->updated_at->format('d/m/Y H:i') }}</td>
</tr>
<tr>
<td><strong>Total Pencapaian</strong></td>
<td>: {{ $target->achievements->count() }} Bulan</td>
</tr>
</table>
</div>
</div>
@if($target->description)
<div class="row mt-3">
<div class="col-12">
<h6><strong>Deskripsi:</strong></h6>
<p class="text-muted">{{ $target->description }}</p>
</div>
</div>
@endif
<!-- Achievement History -->
<div class="row mt-4">
<div class="col-12">
<h5><i class="fas fa-chart-line"></i> Riwayat Pencapaian Bulanan</h5>
@if($target->achievements->count() > 0)
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Periode</th>
<th>Target</th>
<th>Aktual</th>
<th>Pencapaian</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($target->achievements->sortByDesc('year')->sortByDesc('month') as $achievement)
<tr>
<td>{{ $achievement->getPeriodDisplayName() }}</td>
<td>{{ number_format($achievement->target_value) }}</td>
<td>{{ number_format($achievement->actual_value) }}</td>
<td>{{ number_format($achievement->achievement_percentage, 1) }}%</td>
<td>
<span class="badge badge-{{ $achievement->status_color }}">
@switch($achievement->status)
@case('exceeded')
Melebihi Target
@break
@case('good')
Baik
@break
@case('fair')
Cukup
@break
@case('poor')
Kurang
@break
@default
Tidak Diketahui
@endswitch
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="alert alert-info">
<i class="fas fa-info-circle"></i> Belum ada data pencapaian untuk target ini.
</div>
@endif
</div>
</div>
<!-- Summary Statistics -->
@if($target->achievements->count() > 0)
<div class="row mt-4">
<div class="col-12">
<h5><i class="fas fa-chart-bar"></i> Statistik Pencapaian</h5>
<div class="row">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body text-center">
<h4>{{ $target->achievements->count() }}</h4>
<small>Total Pencapaian</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body text-center">
<h4>{{ $target->achievements->where('achievement_percentage', '>=', 100)->count() }}</h4>
<small>Target Tercapai</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body text-center">
<h4>{{ number_format($target->achievements->avg('achievement_percentage'), 1) }}%</h4>
<small>Rata-rata Pencapaian</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-white">
<div class="card-body text-center">
<h4>{{ number_format($target->achievements->max('achievement_percentage'), 1) }}%</h4>
<small>Pencapaian Tertinggi</small>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
</div>
</div>
</div>
</div>
@endsection

View File

@@ -222,6 +222,22 @@
</a> </a>
</li> </li>
@endcan @endcan
<div class="kt-menu__section" style="padding: 15px 20px; margin-top: 10px; margin-bottom: 5px;">
<div style="display: flex; align-items: center; color: #a7abc3; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
<i class="fa fa-user-cog" style="margin-right: 8px; font-size: 14px;"></i>
<span>KPI</span>
</div>
</div>
@can('view', $menus['kpi.targets.index'])
<li class="kt-menu__item" aria-haspopup="true">
<a href="{{ route('kpi.targets.index') }}" class="kt-menu__link">
<i class="fa fa-user-cog" style="display: flex; align-items: center; margin-right: 10px;"></i>
<span class="kt-menu__link-text">Target</span>
</a>
</li>
@endcan
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -320,6 +320,8 @@ use Illuminate\Support\Facades\Auth;
border-color: #28a745; border-color: #28a745;
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25); box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
} }
</style> </style>
@endsection @endsection
@@ -359,6 +361,58 @@ use Illuminate\Support\Facades\Auth;
<b>Dealer {{ $mechanic->dealer_name }}</b><br><br> <b>Dealer {{ $mechanic->dealer_name }}</b><br><br>
<a href="#">Total {{ $count_transaction_dealers }} Pekerjaan terkirim pada dealer</a><br> <a href="#">Total {{ $count_transaction_dealers }} Pekerjaan terkirim pada dealer</a><br>
<a href="#">Anda telah posting {{ $count_transaction_users }} pekerjaan</a> <a href="#">Anda telah posting {{ $count_transaction_users }} pekerjaan</a>
<!-- KPI Information -->
<div class="mt-3">
@if($kpiData['has_target'])
<div class="row">
<div class="col-6">
<small class="text-muted">Target {{ $kpiData['period'] }}</small><br>
<strong class="text-primary">{{ number_format($kpiData['target']) }} Pekerjaan</strong>
</div>
<div class="col-6">
<small class="text-muted">Pencapaian</small><br>
<strong class="text-{{ $kpiData['status_color'] }}">{{ number_format($kpiData['actual']) }} Pekerjaan</strong>
</div>
</div>
<div class="mt-2">
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Progress</small>
<small class="text-{{ $kpiData['status_color'] }} font-weight-bold">{{ $kpiData['percentage'] }}%</small>
</div>
<div class="progress" style="height: 8px;">
@php
$progressWidth = min(100, $kpiData['percentage']);
@endphp
<div class="progress-bar bg-{{ $kpiData['status_color'] }}"
role="progressbar"
style="width: {{ $progressWidth }}%"
aria-valuenow="{{ $kpiData['percentage'] }}"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
<div class="mt-1">
@if($kpiData['status'] == 'exceeded')
<small class="text-success"><i class="fa fa-check-circle"></i> Target tercapai!</small>
@elseif($kpiData['status'] == 'good')
<small class="text-info"><i class="fa fa-arrow-up"></i> Performa baik</small>
@elseif($kpiData['status'] == 'fair')
<small class="text-warning"><i class="fa fa-exclamation-triangle"></i> Perlu peningkatan</small>
@elseif($kpiData['status'] == 'poor')
<small class="text-danger"><i class="fa fa-times-circle"></i> Perlu perbaikan</small>
@else
<small class="text-secondary"><i class="fa fa-clock"></i> Belum ada data</small>
@endif
</div>
</div>
@else
<div class="text-center py-2">
<i class="fa fa-info-circle text-muted"></i>
<small class="text-muted">Belum ada target KPI untuk {{ $kpiData['period'] }}</small>
</div>
@endif
</div>
</div> </div>
<div class="col-4"> <div class="col-4">
<div class="text-center mt-2"> <div class="text-center mt-2">
@@ -1665,6 +1719,8 @@ use Illuminate\Support\Facades\Auth;
$(this).val('0.00'); $(this).val('0.00');
} }
}); });
}); });
// Handle when input loses focus - set default if empty // Handle when input loses focus - set default if empty

View File

@@ -13,6 +13,7 @@ use App\Http\Controllers\WarehouseManagement\ProductsController;
use App\Http\Controllers\WorkController; use App\Http\Controllers\WorkController;
use App\Http\Controllers\WarehouseManagement\MutationsController; use App\Http\Controllers\WarehouseManagement\MutationsController;
use App\Http\Controllers\WarehouseManagement\StockAuditController; use App\Http\Controllers\WarehouseManagement\StockAuditController;
use App\Http\Controllers\KPI\TargetsController;
use App\Models\Menu; use App\Models\Menu;
use App\Models\Privilege; use App\Models\Privilege;
use App\Models\Role; use App\Models\Role;
@@ -282,6 +283,14 @@ Route::group(['middleware' => 'auth'], function() {
Route::get('{stockLog}/detail', 'getDetail')->name('detail'); 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(); Auth::routes();