Skip to content

Commit f458913

Browse files
author
jakub-przepiora
committed
release: v0.8.0 — onboarding wizard, help icon, welcome popup, UI fixes
2 parents 3a441f0 + 17b24ff commit f458913

15 files changed

Lines changed: 726 additions & 4 deletions

backend/app/Http/Controllers/Web/AuthController.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function login(Request $request)
3636
]);
3737

3838
// Attempt authentication
39-
if (!Auth::attempt([
39+
if (! Auth::attempt([
4040
'username' => $request->input('username'),
4141
'password' => $request->input('password'),
4242
], $request->filled('remember'))) {
@@ -71,15 +71,15 @@ public function loginWithPin(\App\Http\Requests\PinLoginRequest $request)
7171
true
7272
);
7373

74-
if (!$pinEnabled) {
74+
if (! $pinEnabled) {
7575
throw ValidationException::withMessages([
7676
'pin' => ['PIN login is not enabled.'],
7777
]);
7878
}
7979

8080
$user = User::where('username', $request->input('username'))->first();
8181

82-
if (!$user || empty($user->pin) || !Hash::check($request->input('pin'), $user->pin)) {
82+
if (! $user || empty($user->pin) || ! Hash::check($request->input('pin'), $user->pin)) {
8383
throw ValidationException::withMessages([
8484
'username' => ['Invalid username or PIN.'],
8585
]);
@@ -155,6 +155,10 @@ protected function redirectToDashboard()
155155
$user = auth()->user();
156156

157157
if ($user->hasRole('Admin')) {
158+
if (OnboardingController::shouldShowWizard()) {
159+
return redirect()->route('onboarding.index');
160+
}
161+
158162
return redirect()->route('admin.dashboard');
159163
}
160164

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Web;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\Line;
7+
use App\Models\ProcessTemplate;
8+
use App\Models\ProductType;
9+
use App\Models\TemplateStep;
10+
use App\Services\WorkOrder\WorkOrderService;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\DB;
13+
14+
class OnboardingController extends Controller
15+
{
16+
public function index()
17+
{
18+
if ($this->isCompleted()) {
19+
return redirect()->route('admin.dashboard');
20+
}
21+
22+
return redirect()->route('onboarding.step1');
23+
}
24+
25+
public function step1()
26+
{
27+
return view('onboarding.step1-line');
28+
}
29+
30+
public function storeStep1(Request $request)
31+
{
32+
$validated = $request->validate([
33+
'code' => 'required|string|max:50|unique:lines,code',
34+
'name' => 'required|string|max:255',
35+
'description' => 'nullable|string',
36+
]);
37+
38+
$line = Line::create([...$validated, 'is_active' => true]);
39+
$line->users()->attach(auth()->id());
40+
41+
$request->session()->put('onboarding.line_id', $line->id);
42+
43+
return redirect()->route('onboarding.step2');
44+
}
45+
46+
public function step2(Request $request)
47+
{
48+
if (! $request->session()->has('onboarding.line_id')) {
49+
return redirect()->route('onboarding.step1');
50+
}
51+
52+
return view('onboarding.step2-product-type');
53+
}
54+
55+
public function storeStep2(Request $request)
56+
{
57+
$validated = $request->validate([
58+
'code' => 'required|string|max:50|unique:product_types,code',
59+
'name' => 'required|string|max:255',
60+
'unit_of_measure' => 'nullable|string|max:20',
61+
]);
62+
63+
$validated['unit_of_measure'] = $validated['unit_of_measure'] ?? 'pcs';
64+
$validated['is_active'] = true;
65+
66+
$productType = ProductType::create($validated);
67+
68+
$lineId = $request->session()->get('onboarding.line_id');
69+
if ($lineId) {
70+
Line::find($lineId)?->productTypes()->attach($productType->id);
71+
}
72+
73+
$request->session()->put('onboarding.product_type_id', $productType->id);
74+
75+
return redirect()->route('onboarding.step3');
76+
}
77+
78+
public function step3(Request $request)
79+
{
80+
if (! $request->session()->has('onboarding.product_type_id')) {
81+
return redirect()->route('onboarding.step1');
82+
}
83+
84+
return view('onboarding.step3-process-template');
85+
}
86+
87+
public function storeStep3(Request $request)
88+
{
89+
$validated = $request->validate([
90+
'name' => 'required|string|max:255',
91+
'steps' => 'required|array|min:1',
92+
'steps.*.name' => 'required|string|max:255',
93+
'steps.*.estimated_duration_minutes' => 'nullable|integer|min:0',
94+
]);
95+
96+
$productTypeId = $request->session()->get('onboarding.product_type_id');
97+
98+
$template = ProcessTemplate::create([
99+
'product_type_id' => $productTypeId,
100+
'name' => $validated['name'],
101+
'version' => 1,
102+
'is_active' => true,
103+
]);
104+
105+
foreach ($validated['steps'] as $i => $stepData) {
106+
TemplateStep::create([
107+
'process_template_id' => $template->id,
108+
'step_number' => $i + 1,
109+
'name' => $stepData['name'],
110+
'estimated_duration_minutes' => $stepData['estimated_duration_minutes'] ?? null,
111+
]);
112+
}
113+
114+
$request->session()->put('onboarding.template_id', $template->id);
115+
116+
return redirect()->route('onboarding.step4');
117+
}
118+
119+
public function step4(Request $request)
120+
{
121+
if (! $request->session()->has('onboarding.template_id')) {
122+
return redirect()->route('onboarding.step1');
123+
}
124+
125+
return view('onboarding.step4-work-order');
126+
}
127+
128+
public function storeStep4(Request $request, WorkOrderService $workOrderService)
129+
{
130+
$validated = $request->validate([
131+
'order_no' => 'required|string|max:100|unique:work_orders,order_no',
132+
'planned_qty' => 'required|numeric|min:0.01',
133+
'description' => 'nullable|string',
134+
]);
135+
136+
$workOrderService->createWorkOrder([
137+
'order_no' => $validated['order_no'],
138+
'line_id' => $request->session()->get('onboarding.line_id'),
139+
'product_type_id' => $request->session()->get('onboarding.product_type_id'),
140+
'planned_qty' => $validated['planned_qty'],
141+
'description' => $validated['description'] ?? null,
142+
]);
143+
144+
return redirect()->route('onboarding.complete');
145+
}
146+
147+
public function complete(Request $request)
148+
{
149+
$this->markCompleted();
150+
$request->session()->forget('onboarding');
151+
152+
return view('onboarding.complete');
153+
}
154+
155+
public function skip(Request $request)
156+
{
157+
$this->markCompleted();
158+
$request->session()->forget('onboarding');
159+
160+
return redirect()->route('admin.dashboard')->with('success', 'Onboarding skipped. You can re-launch it from Settings.');
161+
}
162+
163+
public static function shouldShowWizard(): bool
164+
{
165+
$completed = json_decode(
166+
DB::table('system_settings')->where('key', 'onboarding_completed')->value('value') ?? 'true',
167+
true
168+
);
169+
170+
return ! $completed && Line::count() === 0;
171+
}
172+
173+
private function isCompleted(): bool
174+
{
175+
return ! self::shouldShowWizard();
176+
}
177+
178+
private function markCompleted(): void
179+
{
180+
DB::table('system_settings')
181+
->where('key', 'onboarding_completed')
182+
->update(['value' => json_encode(true)]);
183+
}
184+
}

backend/config/version.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?php
22

33
return [
4-
'current' => 'v0.7.0',
4+
'current' => 'v0.8.0',
55
];
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Support\Facades\DB;
5+
6+
return new class extends Migration
7+
{
8+
public function up(): void
9+
{
10+
DB::table('system_settings')->insertOrIgnore([
11+
'key' => 'onboarding_completed',
12+
'value' => json_encode(false),
13+
'description' => 'Whether the onboarding wizard has been completed',
14+
]);
15+
}
16+
17+
public function down(): void
18+
{
19+
DB::table('system_settings')->where('key', 'onboarding_completed')->delete();
20+
}
21+
};

backend/resources/views/layouts/app.blade.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,5 +269,28 @@ class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium te
269269
});
270270
})();
271271
</script>
272+
@auth
273+
@if(auth()->user()->hasRole('Admin') && \App\Http\Controllers\Web\OnboardingController::shouldShowWizard())
274+
<div x-data="{ open: !sessionStorage.getItem('wizard_dismissed') }" x-show="open" x-cloak
275+
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
276+
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
277+
<div class="fixed inset-0 bg-black/50" @click="open = false; sessionStorage.setItem('wizard_dismissed','1')"></div>
278+
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full p-8 text-center"
279+
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100">
280+
<div class="w-14 h-14 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center mx-auto mb-4">
281+
<svg class="w-7 h-7 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
282+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
283+
</svg>
284+
</div>
285+
<h3 class="text-xl font-bold text-gray-800 dark:text-white mb-2">Welcome to OpenMES!</h3>
286+
<p class="text-gray-600 dark:text-gray-300 mb-6">Looks like this is a fresh installation. Would you like to run the setup wizard? It takes about 2 minutes.</p>
287+
<div class="flex flex-col gap-3">
288+
<a href="{{ route('onboarding.step1') }}" class="btn-touch btn-primary w-full">Start Setup Wizard</a>
289+
<button @click="open = false; sessionStorage.setItem('wizard_dismissed','1')" class="btn-touch btn-secondary w-full">I'll do it later</button>
290+
</div>
291+
</div>
292+
</div>
293+
@endif
294+
@endauth
272295
</body>
273296
</html>

backend/resources/views/layouts/components/sidebar.blade.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ class="flex items-center gap-2.5 min-w-0 overflow-hidden">
3737
<span class="block text-slate-400 text-[10px] leading-none truncate">{{ config('version.current', 'v0.1') }}</span>
3838
</span>
3939
</a>
40+
@auth
41+
@if(auth()->user()->hasRole('Admin'))
42+
<a href="{{ route('onboarding.step1') }}" title="Setup Wizard"
43+
class="ml-auto shrink-0 p-1.5 rounded-md text-slate-400 hover:text-white hover:bg-slate-700 transition-colors hidden lg:block"
44+
x-show="!collapsed || mobileOpen" x-cloak>
45+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
46+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
47+
</svg>
48+
</a>
49+
@endif
50+
@endauth
4051
{{-- Mobile close button --}}
4152
<button @click="mobileOpen = false"
4253
class="lg:hidden ml-auto p-1.5 rounded-md text-slate-400 hover:text-white
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
@extends('onboarding.layout', ['step' => 5])
2+
3+
@section('content')
4+
<div class="text-center py-8">
5+
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
6+
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
7+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
8+
</svg>
9+
</div>
10+
<h2 class="text-2xl font-bold text-gray-800 mb-2">Setup Complete!</h2>
11+
<p class="text-gray-600 mb-6">Your production line, product type, process template, and first work order have been created.</p>
12+
13+
<div class="space-y-3">
14+
<a href="{{ route('admin.dashboard') }}" class="btn-touch btn-primary block">Go to Dashboard</a>
15+
<a href="{{ route('operator.select-line') }}" class="btn-touch btn-secondary block">Start as Operator</a>
16+
</div>
17+
</div>
18+
@endsection
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>OpenMES — Setup Wizard</title>
7+
@vite(['resources/css/app.css', 'resources/js/app.js'])
8+
</head>
9+
<body class="bg-gray-100 min-h-screen flex flex-col items-center justify-center p-4">
10+
<div class="w-full max-w-2xl">
11+
<!-- Logo -->
12+
<div class="text-center mb-8">
13+
<img src="/logo_open_mes.png" alt="OpenMES" class="h-10 mx-auto mb-2">
14+
<p class="text-sm text-gray-500">Setup Wizard</p>
15+
</div>
16+
17+
<!-- Stepper -->
18+
<div class="flex items-center justify-center mb-8">
19+
@php $currentStep = $step ?? 1; @endphp
20+
@foreach(['Line', 'Product', 'Process', 'Work Order'] as $i => $label)
21+
<div class="flex items-center">
22+
<div class="flex flex-col items-center">
23+
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
24+
{{ $i + 1 < $currentStep ? 'bg-green-500 text-white' :
25+
($i + 1 === $currentStep ? 'bg-blue-600 text-white' : 'bg-gray-300 text-gray-600') }}">
26+
@if($i + 1 < $currentStep)
27+
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
28+
@else
29+
{{ $i + 1 }}
30+
@endif
31+
</div>
32+
<span class="text-xs mt-1 {{ $i + 1 === $currentStep ? 'text-blue-600 font-medium' : 'text-gray-500' }}">{{ $label }}</span>
33+
</div>
34+
@if($i < 3)
35+
<div class="w-12 h-0.5 mx-1 {{ $i + 1 < $currentStep ? 'bg-green-500' : 'bg-gray-300' }}"></div>
36+
@endif
37+
</div>
38+
@endforeach
39+
</div>
40+
41+
<!-- Content -->
42+
<div class="bg-white rounded-xl shadow-lg p-8">
43+
@yield('content')
44+
</div>
45+
46+
<!-- Skip -->
47+
<div class="text-center mt-4">
48+
<form action="{{ route('onboarding.skip') }}" method="POST" class="inline">
49+
@csrf
50+
<button type="submit" class="text-sm text-gray-400 hover:text-gray-600">Skip wizard →</button>
51+
</form>
52+
</div>
53+
</div>
54+
</body>
55+
</html>

0 commit comments

Comments
 (0)