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 App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use App\Models\Classification; // Import model Classification
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller class DashboardController extends Controller
{ {
public function index() public function index()
{ {
$user = Auth::user(); $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', [ return inertia('admin/Dashboard', [
'user' => $user, '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), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'auth' => [ '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' => [ 'flash' => [
'success' => fn () => $request->session()->get('success'), 'success' => fn() => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'), '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 Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
#[Fillable(['name', 'email', 'password'])] #[Fillable(['name', 'email', 'password', 'role'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {

View File

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

View File

@ -17,6 +17,9 @@ public function up(): void
$table->string('email')->unique(); $table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable(); $table->timestamp('email_verified_at')->nullable();
$table->string('password'); $table->string('password');
$table->string('role')->default('user');
$table->rememberToken(); $table->rememberToken();
$table->timestamps(); $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 App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents; // use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@ -16,8 +17,10 @@ public function run(): void
// User::factory(10)->create(); // User::factory(10)->create();
User::factory()->create([ User::factory()->create([
'name' => 'Test User', 'name' => 'Admin',
'email' => 'test@example.com', '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", "@inertiajs/vue3": "^3.0.0",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"laravel-vite-plugin": "^3.0.0", "laravel-vite-plugin": "^3.0.0",
@ -18,6 +19,7 @@
"tailwindcss": "^4.1.1", "tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
"vue-input-otp": "^0.3.2", "vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0", "vue-sonner": "^2.0.0",
"ziggy-js": "^2.6.2" "ziggy-js": "^2.6.2"
@ -575,6 +577,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -2505,6 +2513,19 @@
"node": ">=8" "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": { "node_modules/class-variance-authority": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "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": { "node_modules/vue-eslint-parser": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", "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", "@inertiajs/vue3": "^3.0.0",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"laravel-vite-plugin": "^3.0.0", "laravel-vite-plugin": "^3.0.0",
@ -46,6 +47,7 @@
"tailwindcss": "^4.1.1", "tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
"vue-input-otp": "^0.3.2", "vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0", "vue-sonner": "^2.0.0",
"ziggy-js": "^2.6.2" "ziggy-js": "^2.6.2"

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <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 { route } from 'ziggy-js';
import { BookOpen, FolderGit2, LayoutGrid, Users } from 'lucide-vue-next'; import { BookOpen, FolderGit2, LayoutGrid, Users } from 'lucide-vue-next';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue'; import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue'; import NavUser from '@/components/NavUser.vue';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -17,19 +19,35 @@ import {
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import type { NavItem } from '@/types'; 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', title: 'Dashboard',
href: route('admin.dashboard'), href: route('admin.dashboard'),
icon: LayoutGrid, icon: LayoutGrid,
}, },
{ {
title: 'Users', title: 'Users Management',
href: route('admin.users.index'), href: route('admin.users.index'),
icon: Users, 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[] = [ const footerNavItems: NavItem[] = [
{ {
title: 'Repository', title: 'Repository',
@ -59,7 +77,7 @@ const footerNavItems: NavItem[] = [
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain :items="mainNavItems" /> <NavMain :items="filteredNavItems" />
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>

View File

@ -2,10 +2,16 @@
import AppLayout from '@/layouts/app/AppSidebarLayout.vue'; import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import Notification from './Notification.vue'; import Notification from './Notification.vue';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const { breadcrumbs = [] } = defineProps<{ const { breadcrumbs = [] } = defineProps<{
breadcrumbs?: BreadcrumbItem[]; 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> </script>
<template> <template>

View File

@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Card } from '@/components/ui/card';
import { User } from '@/types'; 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({ defineOptions({
layout: { 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> </script>
<template> <template>
<Head title="Dashboard" /> <Head title="Dashboard" />
<div <div class="flex h-full flex-1 flex-col gap-4 p-4 overflow-y-auto">
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
> <Card class="border-border bg-card">
<!-- card greetings --> <CardHeader class="pb-3">
<Card class="flex-1 border-border bg-card"> <CardTitle class="text-lg font-semibold text-foreground">
<div class="flex flex-col"> Selamat Datang, {{ user.name }}!
<div class="flex items-center justify-between"> </CardTitle>
<div> <CardDescription>
<h3 class="text-base font-medium text-foreground"> Anda masuk sebagai <span class="font-bold uppercase">{{ user.role }}</span>. Berikut ringkasan data klasifikasi kopi.
Selamat Datang {{ user.name }} </CardDescription>
</h3> </CardHeader>
<p class="text-sm text-muted-foreground"> </Card>
ini adalah dashboard admin
</p> <div class="grid gap-4 md:grid-cols-3">
</div> <Card>
</div> <CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
</div> <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> </Card>
</div> </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 Laravel\Fortify\Features;
use App\Http\Controllers\Admin\UserController; use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Public\ClassificationController; // Import Controller
Route::redirect('dashboard', 'admin/dashboard')->name('dashboard'); Route::redirect('dashboard', 'admin/dashboard')->name('dashboard');
@ -13,9 +14,14 @@
]); ]);
})->name('home'); })->name('home');
Route::post('/classifications', [ClassificationController::class, 'store'])->name('public.classifications.store');
Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified'])->group(function () { Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('users', UserController::class)->names('users'); 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');
}); });