update ROLE

This commit is contained in:
kizadl 2026-04-25 22:45:49 +07:00
parent 45d641519c
commit e38215b1b4
16 changed files with 333 additions and 37 deletions

View File

@ -5,14 +5,40 @@
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\Classification; // Import model Classification
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
public function index()
{
$user = Auth::user();
// Mulai Query Dasar
$query = Classification::query();
// LOGIKA ROLE: Jika yang login adalah 'user', kunci hanya data miliknya
if ($user->role === 'user') {
$query->where('user_id', $user->id);
}
// Jika admin, biarkan query mengambil semua data (tanpa where)
// Ambil data Chart (sudah terfilter role)
$chartData = (clone $query)->select('result', DB::raw('count(*) as total'))
->groupBy('result')
->get();
// Ambil History Terbaru (sudah terfilter role)
$recentHistory = (clone $query)->latest()->limit(10)->get();
// Hitung Total Scan (sudah terfilter role)
$totalScan = (clone $query)->count();
return inertia('admin/Dashboard', [
'user' => $user,
'chartData' => $chartData,
'recentHistory' => $recentHistory,
'totalScan' => $totalScan,
]);
}
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\Classification;
use Illuminate\Http\Request;
class ClassificationController extends Controller
{
public function store(Request $request)
{
$request->validate([
'image' => 'required|image|max:2048',
'result' => 'required|string',
'confidence' => 'required',
]);
// Simpan gambar ke storage public
$path = $request->file('image')->store('classifications', 'public');
// Simpan ke Database
Classification::create([
'user_id' => auth()->id(), // null jika guest, id jika login
'image_path' => $path,
'result' => $request->result,
'confidence' => (float) filter_var($request->confidence, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
]);
return response()->json(['message' => 'Data tersimpan ke history']);
}
}

View File

@ -39,12 +39,18 @@ public function share(Request $request): array
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user(),
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'role' => $request->user()->role, // Memastikan role terkirim dengan jelas
// Jangan kirim password_hash atau data sensitif lainnya di sini
] : null,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'sidebarOpen' => !$request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
'success' => fn() => $request->session()->get('success'),
'error' => fn() => $request->session()->get('error'),
],
];
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoleMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Classification extends Model
{
//
protected $fillable = [
'user_id',
'image_path',
'result',
'confidence'
];
// Relasi ke User (Satu klasifikasi dimiliki satu User)
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -11,7 +11,7 @@
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
#[Fillable(['name', 'email', 'password'])]
#[Fillable(['name', 'email', 'password', 'role'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable
{

View File

@ -2,6 +2,7 @@
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\RoleMiddleware;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -9,13 +10,17 @@
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->alias([
'role' => RoleMiddleware::class,
]);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,

View File

@ -17,6 +17,9 @@ public function up(): void
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('role')->default('user');
$table->rememberToken();
$table->timestamps();
});

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('classifications', function (Blueprint $table) {
$table->id();
// user_id boleh kosong (untuk guest)
$table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
$table->string('image_path');
$table->string('result');
$table->float('confidence');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('classifications');
}
};

View File

@ -5,6 +5,7 @@
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
@ -16,8 +17,10 @@ public function run(): void
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'name' => 'Admin',
'email' => 'admin@example.com',
'password' => Hash::make('password'),
'role' => 'super_user',
]);
}
}

31
package-lock.json generated
View File

@ -9,6 +9,7 @@
"@inertiajs/vue3": "^3.0.0",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^12.8.2",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"laravel-vite-plugin": "^3.0.0",
@ -18,6 +19,7 @@
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
"vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0",
"ziggy-js": "^2.6.2"
@ -575,6 +577,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -2505,6 +2513,19 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@ -6625,6 +6646,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-eslint-parser": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",

View File

@ -37,6 +37,7 @@
"@inertiajs/vue3": "^3.0.0",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^12.8.2",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"laravel-vite-plugin": "^3.0.0",
@ -46,6 +47,7 @@
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
"vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0",
"ziggy-js": "^2.6.2"

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { route } from 'ziggy-js';
import { BookOpen, FolderGit2, LayoutGrid, Users } from 'lucide-vue-next';
import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue';
import {
Sidebar,
SidebarContent,
@ -17,19 +19,35 @@ import {
} from '@/components/ui/sidebar';
import type { NavItem } from '@/types';
const mainNavItems: NavItem[] = [
const page = usePage();
// Ambil data user dari auth yang dikirim via HandleInertiaRequests
const user = computed(() => page.props.auth.user);
// 1. Definisikan semua menu, tambahkan properti 'role' untuk menu yang dibatasi
const allNavItems: NavItem[] = [
{
title: 'Dashboard',
href: route('admin.dashboard'),
icon: LayoutGrid,
},
{
title: 'Users',
title: 'Users Management',
href: route('admin.users.index'),
icon: Users,
role: 'admin', // Hanya untuk admin
},
];
// 2. Filter menu berdasarkan role user yang sedang login
const filteredNavItems = computed(() => {
return allNavItems.filter(item => {
// Jika menu tidak punya batasan role, tampilkan untuk semua
if (!item.role) return true;
// Jika ada batasan role, cek apakah role user cocok (admin === admin)
return item.role === user.value?.role;
});
});
const footerNavItems: NavItem[] = [
{
title: 'Repository',
@ -59,7 +77,7 @@ const footerNavItems: NavItem[] = [
</SidebarHeader>
<SidebarContent>
<NavMain :items="mainNavItems" />
<NavMain :items="filteredNavItems" />
</SidebarContent>
<SidebarFooter>
@ -68,4 +86,4 @@ const footerNavItems: NavItem[] = [
</SidebarFooter>
</Sidebar>
<slot />
</template>
</template>

View File

@ -2,10 +2,16 @@
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import Notification from './Notification.vue';
import type { BreadcrumbItem } from '@/types';
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const { breadcrumbs = [] } = defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>();
// Mengambil data user untuk keperluan pengecekan role jika dibutuhkan di level ini
const page = usePage();
const user = computed(() => page.props.auth.user);
</script>
<template>
@ -13,4 +19,4 @@ const { breadcrumbs = [] } = defineProps<{
<slot />
<Notification />
</AppLayout>
</template>
</template>

View File

@ -1,10 +1,15 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Card } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { User } from '@/types';
import { computed } from 'vue';
// Import untuk Chart
import { Pie } from 'vue-chartjs';
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend);
defineOptions({
layout: {
@ -17,29 +22,108 @@ defineOptions({
},
});
const { user } = defineProps<{ user: User }>();
// Terima Props dari Controller
const props = defineProps<{
user: User,
chartData: Array<{ result: string, total: number }>,
recentHistory: Array<any>,
totalScan: number
}>();
// Konfigurasi Warna & Data untuk Pie Chart
const chartConfig = computed(() => ({
labels: props.chartData.map(item => item.result),
datasets: [{
backgroundColor: ['#eab308', '#22c55e', '#3b82f6'], // Honey (Yellow), Natural (Green), Washed (Blue)
data: props.chartData.map(item => item.total),
borderWidth: 1
}]
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
}
}
};
</script>
<template>
<Head title="Dashboard" />
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
>
<!-- card greetings -->
<Card class="flex-1 border-border bg-card">
<div class="flex flex-col">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-medium text-foreground">
Selamat Datang {{ user.name }}
</h3>
<p class="text-sm text-muted-foreground">
ini adalah dashboard admin
</p>
</div>
</div>
</div>
<div class="flex h-full flex-1 flex-col gap-4 p-4 overflow-y-auto">
<Card class="border-border bg-card">
<CardHeader class="pb-3">
<CardTitle class="text-lg font-semibold text-foreground">
Selamat Datang, {{ user.name }}!
</CardTitle>
<CardDescription>
Anda masuk sebagai <span class="font-bold uppercase">{{ user.role }}</span>. Berikut ringkasan data klasifikasi kopi.
</CardDescription>
</CardHeader>
</Card>
<div class="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Scan</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ totalScan }}</div>
<p class="text-xs text-muted-foreground">Akumulasi seluruh pemindaian</p>
</CardContent>
</Card>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card class="col-span-1 lg:col-span-3">
<CardHeader>
<CardTitle>Distribusi Pasca Panen</CardTitle>
<CardDescription>Persentase hasil klasifikasi</CardDescription>
</CardHeader>
<CardContent class="h-[300px]">
<Pie v-if="chartData.length > 0" :data="chartConfig" :options="chartOptions" />
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
Belum ada data untuk ditampilkan
</div>
</CardContent>
</Card>
<Card class="col-span-1 lg:col-span-4">
<CardHeader>
<CardTitle>Riwayat Terakhir</CardTitle>
<CardDescription>10 pemindaian terbaru</CardDescription>
</CardHeader>
<CardContent>
<div class="relative w-full overflow-auto">
<table class="w-full caption-bottom text-sm">
<thead class="[&_tr]:border-b">
<tr class="border-b transition-colors hover:bg-muted/50">
<th class="h-12 px-4 text-left align-middle font-medium">Tanggal</th>
<th class="h-12 px-4 text-left align-middle font-medium">Hasil</th>
<th class="h-12 px-4 text-left align-middle font-medium">Akurasi</th>
</tr>
</thead>
<tbody class="[&_tr:last-child]:border-0">
<tr v-for="item in recentHistory" :key="item.id" class="border-b transition-colors hover:bg-muted/50">
<td class="p-4 align-middle">
{{ new Date(item.created_at).toLocaleDateString('id-ID') }}
</td>
<td class="p-4 align-middle font-medium uppercase">{{ item.result }}</td>
<td class="p-4 align-middle">{{ item.confidence }}%</td>
</tr>
<tr v-if="recentHistory.length === 0">
<td colspan="3" class="p-4 text-center text-muted-foreground">Tidak ada data.</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
</template>

View File

@ -4,6 +4,7 @@
use Laravel\Fortify\Features;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Public\ClassificationController; // Import Controller
Route::redirect('dashboard', 'admin/dashboard')->name('dashboard');
@ -13,9 +14,14 @@
]);
})->name('home');
Route::post('/classifications', [ClassificationController::class, 'store'])->name('public.classifications.store');
Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('users', UserController::class)->names('users');
// Route tambahan untuk Admin jika ingin melihat history klasifikasi secara global
// Route::get('history', [DashboardController::class, 'history'])->name('history');
});