Compare commits
No commits in common. "main" and "feature/6-livewire" have entirely different histories.
main
...
feature/6-
147 changed files with 846 additions and 6367 deletions
|
|
@ -1,24 +0,0 @@
|
|||
APP_NAME=DishPlanner
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:KSKZNT+cJuaBRBv4Y2HQqav6hzREKoLkNIKN8yszU1Q=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://dishplanner_app:8000
|
||||
|
||||
LOG_CHANNEL=single
|
||||
|
||||
# Test database
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dishplanner_test
|
||||
DB_USERNAME=dishplanner
|
||||
DB_PASSWORD=dishplanner
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=array
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
MAIL_MAILER=array
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
|||
/composer.lock
|
||||
/.phpunit.cache
|
||||
/coverage
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@ RUN install-php-extensions \
|
|||
opcache \
|
||||
zip \
|
||||
gd \
|
||||
intl \
|
||||
bcmath
|
||||
intl
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
|
@ -101,7 +100,7 @@ set -e
|
|||
# Wait for database to be ready
|
||||
echo "Waiting for database..."
|
||||
for i in $(seq 1 30); do
|
||||
if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then
|
||||
if php artisan db:monitor --database=mysql 2>/dev/null | grep -q "OK"; then
|
||||
echo "Database is ready!"
|
||||
break
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ RUN install-php-extensions \
|
|||
zip \
|
||||
gd \
|
||||
intl \
|
||||
bcmath \
|
||||
xdebug
|
||||
|
||||
# Install Composer
|
||||
|
|
@ -102,10 +101,6 @@ echo "Waiting for database..."
|
|||
sleep 5
|
||||
php artisan migrate --force || echo "Migration failed or not needed"
|
||||
|
||||
# Run development seeder (only in dev environment)
|
||||
echo "Running development seeder..."
|
||||
php artisan db:seed --class=DevelopmentSeeder --force || echo "Seeding skipped or already done"
|
||||
|
||||
# Generate app key if not set
|
||||
if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then
|
||||
echo "Generating application key..."
|
||||
|
|
|
|||
|
|
@ -72,10 +72,6 @@ # Database
|
|||
make seed # Seed database
|
||||
make fresh # Fresh migrate with seeds
|
||||
|
||||
# Testing
|
||||
make test # Run tests
|
||||
composer test:coverage-html # Run tests with coverage report (generates coverage/index.html)
|
||||
|
||||
# Utilities
|
||||
make shell # Enter app container
|
||||
make db-shell # Enter database shell
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CreateUserAction
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function execute(array $data): User
|
||||
{
|
||||
try {
|
||||
// Validate required fields first
|
||||
if (!isset($data['name']) || empty($data['name'])) {
|
||||
throw new InvalidArgumentException('Name is required');
|
||||
}
|
||||
|
||||
if (!isset($data['planner_id']) || empty($data['planner_id'])) {
|
||||
throw new InvalidArgumentException('Planner ID is required');
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
Log::info('CreateUserAction: Starting user creation', [
|
||||
'name' => $data['name'],
|
||||
'planner_id' => $data['planner_id'],
|
||||
]);
|
||||
|
||||
// Create the user
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'planner_id' => $data['planner_id'],
|
||||
]);
|
||||
|
||||
if (!$user) {
|
||||
throw new Exception('User creation returned null');
|
||||
}
|
||||
|
||||
Log::info('CreateUserAction: User creation result', [
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
// Verify the user was actually created
|
||||
$createdUser = User::find($user->id);
|
||||
if (!$createdUser) {
|
||||
throw new Exception('User creation did not persist to database');
|
||||
}
|
||||
|
||||
if ($createdUser->name !== $data['name']) {
|
||||
throw new Exception('User creation data mismatch');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('CreateUserAction: User successfully created', [
|
||||
'user_id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('CreateUserAction: User creation failed', [
|
||||
'name' => $data['name'] ?? 'N/A',
|
||||
'planner_id' => $data['planner_id'] ?? 'N/A',
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeleteUserAction
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function execute(User $user): bool
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
Log::info('DeleteUserAction: Starting user deletion', [
|
||||
'user_id' => $user->id,
|
||||
'user_name' => $user->name,
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
// Check for related data
|
||||
$userDishCount = $user->userDishes()->count();
|
||||
$dishCount = $user->dishes()->count();
|
||||
|
||||
Log::info('DeleteUserAction: User relationship counts', [
|
||||
'user_id' => $user->id,
|
||||
'user_dishes_count' => $userDishCount,
|
||||
'dishes_count' => $dishCount,
|
||||
]);
|
||||
|
||||
// Store user info before deletion for verification
|
||||
$userId = $user->id;
|
||||
$userName = $user->name;
|
||||
|
||||
// Delete the user (cascading deletes should handle related records)
|
||||
$result = $user->delete();
|
||||
|
||||
Log::info('DeleteUserAction: Delete result', [
|
||||
'result' => $result,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
if (! $result) {
|
||||
throw new Exception('User deletion returned false');
|
||||
}
|
||||
|
||||
// Verify the deletion actually happened
|
||||
$stillExists = User::find($userId);
|
||||
if ($stillExists) {
|
||||
throw new Exception('User deletion did not persist to database');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('DeleteUserAction: User successfully deleted', [
|
||||
'user_id' => $userId,
|
||||
'user_name' => $userName,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('DeleteUserAction: User deletion failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EditUserAction
|
||||
{
|
||||
public function execute(User $user, array $data): bool
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
Log::info('EditUserAction: Starting user update', [
|
||||
'user_id' => $user->id,
|
||||
'old_name' => $user->name,
|
||||
'new_name' => $data['name'],
|
||||
'planner_id' => $user->planner_id,
|
||||
]);
|
||||
|
||||
$result = $user->update([
|
||||
'name' => $data['name'],
|
||||
]);
|
||||
|
||||
Log::info('EditUserAction: Update result', [
|
||||
'result' => $result,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
if (!$result) {
|
||||
throw new \Exception('User update returned false');
|
||||
}
|
||||
|
||||
// Verify the update actually happened
|
||||
$user->refresh();
|
||||
if ($user->name !== $data['name']) {
|
||||
throw new \Exception('User update did not persist to database');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('EditUserAction: User successfully updated', [
|
||||
'user_id' => $user->id,
|
||||
'updated_name' => $user->name,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
Log::error('EditUserAction: User update failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AppModeEnum: string
|
||||
{
|
||||
case APP = 'app';
|
||||
case SAAS = 'saas';
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
return self::from(config('app.mode', 'app'));
|
||||
}
|
||||
|
||||
public function isApp(): bool
|
||||
{
|
||||
return $this === self::APP;
|
||||
}
|
||||
|
||||
public function isSaas(): bool
|
||||
{
|
||||
return $this === self::SAAS;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function showLoginForm()
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'These credentials do not match our records.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Planner;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
public function showRegistrationForm()
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
public function register(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:planners'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = Planner::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function checkout(Request $request)
|
||||
{
|
||||
$planner = $request->user();
|
||||
|
||||
if ($planner->subscribed()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$plan = $request->input('plan', 'monthly');
|
||||
$priceId = $plan === 'yearly'
|
||||
? config('services.stripe.price_yearly')
|
||||
: config('services.stripe.price_monthly');
|
||||
|
||||
return $planner->newSubscription('default', $priceId)
|
||||
->checkout([
|
||||
'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}',
|
||||
'cancel_url' => route('subscription.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function success(Request $request): RedirectResponse
|
||||
{
|
||||
$sessionId = $request->query('session_id');
|
||||
|
||||
if ($sessionId) {
|
||||
$planner = $request->user();
|
||||
$session = Cashier::stripe()->checkout->sessions->retrieve($sessionId, [
|
||||
'expand' => ['subscription'],
|
||||
]);
|
||||
|
||||
if ($session->subscription && ! $planner->subscribed()) {
|
||||
$subscription = $session->subscription;
|
||||
|
||||
$planner->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => $subscription->id,
|
||||
'stripe_status' => $subscription->status,
|
||||
'stripe_price' => $subscription->items->data[0]->price->id ?? null,
|
||||
'quantity' => $subscription->items->data[0]->quantity ?? 1,
|
||||
'trial_ends_at' => $subscription->trial_end ? now()->setTimestamp($subscription->trial_end) : null,
|
||||
'ends_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('dashboard')->with('success', 'Subscription activated!');
|
||||
}
|
||||
|
||||
public function billing(Request $request)
|
||||
{
|
||||
$planner = $request->user();
|
||||
$subscription = $planner->subscription();
|
||||
|
||||
if (! $subscription) {
|
||||
return redirect()->route('subscription.index');
|
||||
}
|
||||
|
||||
$planType = match ($subscription->stripe_price) {
|
||||
config('services.stripe.price_yearly') => 'Yearly',
|
||||
config('services.stripe.price_monthly') => 'Monthly',
|
||||
default => 'Unknown',
|
||||
};
|
||||
|
||||
$nextBillingDate = null;
|
||||
if ($subscription->stripe_status === 'active') {
|
||||
try {
|
||||
$stripeSubscription = Cashier::stripe()->subscriptions->retrieve($subscription->stripe_id);
|
||||
$nextBillingDate = $stripeSubscription->current_period_end
|
||||
? now()->setTimestamp($stripeSubscription->current_period_end)
|
||||
: null;
|
||||
} catch (\Exception $e) {
|
||||
// Stripe API error - continue without next billing date
|
||||
}
|
||||
}
|
||||
|
||||
return view('billing.index', [
|
||||
'subscription' => $subscription,
|
||||
'planner' => $planner,
|
||||
'planType' => $planType,
|
||||
'nextBillingDate' => $nextBillingDate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Request $request): RedirectResponse
|
||||
{
|
||||
$planner = $request->user();
|
||||
|
||||
if (! $planner->subscribed()) {
|
||||
return back()->with('error', 'No active subscription found.');
|
||||
}
|
||||
|
||||
$planner->subscription()->cancel();
|
||||
|
||||
return back()->with('success', 'Subscription canceled. Access will continue until the end of your billing period.');
|
||||
}
|
||||
|
||||
public function billingPortal(Request $request)
|
||||
{
|
||||
return $request->user()->redirectToBillingPortal(route('billing'));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RequireSaasMode
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! is_mode_saas()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RequireSubscription
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (is_mode_app()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$planner = $request->user();
|
||||
|
||||
if (! $planner?->subscribed()) {
|
||||
return redirect()->route('subscription.index');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
36
app/Livewire/Auth/Login.php
Normal file
36
app/Livewire/Auth/Login.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Rule;
|
||||
|
||||
class Login extends Component
|
||||
{
|
||||
#[Rule('required|email')]
|
||||
public $email = '';
|
||||
|
||||
#[Rule('required')]
|
||||
public $password = '';
|
||||
|
||||
public $remember = false;
|
||||
|
||||
public function login()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
if (Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
|
||||
session()->regenerate();
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
$this->addError('email', 'These credentials do not match our records.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.login')
|
||||
->layout('components.layouts.guest');
|
||||
}
|
||||
}
|
||||
45
app/Livewire/Auth/Register.php
Normal file
45
app/Livewire/Auth/Register.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\Planner;
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Rule;
|
||||
|
||||
class Register extends Component
|
||||
{
|
||||
#[Rule('required|string|max:255')]
|
||||
public $name = '';
|
||||
|
||||
#[Rule('required|email|unique:planners,email')]
|
||||
public $email = '';
|
||||
|
||||
#[Rule('required|min:8|confirmed')]
|
||||
public $password = '';
|
||||
|
||||
public $password_confirmation = '';
|
||||
|
||||
public function register()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$planner = Planner::create([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'password' => Hash::make($this->password),
|
||||
]);
|
||||
|
||||
Auth::login($planner);
|
||||
session()->regenerate();
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.register')
|
||||
->layout('components.layouts.guest');
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ public function render()
|
|||
->orderBy('name')
|
||||
->paginate(10);
|
||||
|
||||
$users = User::where('planner_id', auth()->id())
|
||||
$users = User::where('planner_id', auth()->user()->planner_id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ public function store()
|
|||
|
||||
$dish = Dish::create([
|
||||
'name' => $this->name,
|
||||
'planner_id' => auth()->id(),
|
||||
'planner_id' => auth()->user()->planner_id,
|
||||
]);
|
||||
|
||||
// Attach selected users
|
||||
|
|
@ -119,14 +119,4 @@ public function cancel()
|
|||
$this->showDeleteModal = false;
|
||||
$this->reset(['name', 'selectedUsers', 'editingDish', 'deletingDish']);
|
||||
}
|
||||
|
||||
public function toggleAllUsers(): void
|
||||
{
|
||||
$users = User::where('planner_id', auth()->id())->get();
|
||||
if (count($this->selectedUsers) === $users->count()) {
|
||||
$this->selectedUsers = [];
|
||||
} else {
|
||||
$this->selectedUsers = $users->pluck('id')->map(fn($id) => (string) $id)->toArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,8 @@
|
|||
|
||||
namespace App\Livewire\Schedule;
|
||||
|
||||
use App\Models\Dish;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDish;
|
||||
use Carbon\Carbon;
|
||||
use DishPlanner\Schedule\Services\ScheduleCalendarService;
|
||||
use DishPlanner\ScheduledUserDish\Actions\DeleteScheduledUserDishForDateAction;
|
||||
use DishPlanner\ScheduledUserDish\Actions\SkipScheduledUserDishForDateAction;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Component;
|
||||
|
||||
class ScheduleCalendar extends Component
|
||||
|
|
@ -24,52 +14,65 @@ class ScheduleCalendar extends Component
|
|||
public $showRegenerateModal = false;
|
||||
public $regenerateDate = null;
|
||||
public $regenerateUserId = null;
|
||||
|
||||
// Edit dish modal
|
||||
public $showEditDishModal = false;
|
||||
public $editDate = null;
|
||||
public $editUserId = null;
|
||||
public $selectedDishId = null;
|
||||
public $availableDishes = [];
|
||||
|
||||
// Add dish modal
|
||||
public $showAddDishModal = false;
|
||||
public $addDate = null;
|
||||
public $addUserIds = [];
|
||||
public $addSelectedDishId = null;
|
||||
public $addAvailableUsers = [];
|
||||
public $addAvailableDishes = [];
|
||||
|
||||
public function mount(): void
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->currentMonth = now()->month;
|
||||
$this->currentYear = now()->year;
|
||||
$this->loadCalendar();
|
||||
$this->generateCalendar();
|
||||
}
|
||||
|
||||
protected $listeners = ['schedule-generated' => 'refreshCalendar'];
|
||||
|
||||
public function render(): View
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.schedule.schedule-calendar');
|
||||
}
|
||||
|
||||
public function refreshCalendar(): void
|
||||
public function refreshCalendar()
|
||||
{
|
||||
$this->loadCalendar();
|
||||
$this->generateCalendar();
|
||||
}
|
||||
|
||||
public function loadCalendar(): void
|
||||
public function generateCalendar()
|
||||
{
|
||||
$service = new ScheduleCalendarService();
|
||||
$this->calendarDays = $service->getCalendarDays(
|
||||
auth()->user(),
|
||||
$this->currentMonth,
|
||||
$this->currentYear
|
||||
);
|
||||
$this->calendarDays = [];
|
||||
|
||||
// Get first day of the month and total days
|
||||
$firstDay = Carbon::createFromDate($this->currentYear, $this->currentMonth, 1);
|
||||
$daysInMonth = $firstDay->daysInMonth;
|
||||
|
||||
// Generate 31 days for consistency with React version
|
||||
for ($day = 1; $day <= 31; $day++) {
|
||||
if ($day <= $daysInMonth) {
|
||||
$date = Carbon::createFromDate($this->currentYear, $this->currentMonth, $day);
|
||||
|
||||
// Get scheduled dishes for this date
|
||||
$scheduledDishes = ScheduledUserDish::with(['user', 'dish'])
|
||||
->whereDate('date', $date->format('Y-m-d'))
|
||||
->get();
|
||||
|
||||
$this->calendarDays[] = [
|
||||
'day' => $day,
|
||||
'date' => $date,
|
||||
'isToday' => $date->isToday(),
|
||||
'scheduledDishes' => $scheduledDishes,
|
||||
'isEmpty' => $scheduledDishes->isEmpty()
|
||||
];
|
||||
} else {
|
||||
// Empty slot for days that don't exist in this month
|
||||
$this->calendarDays[] = [
|
||||
'day' => null,
|
||||
'date' => null,
|
||||
'isToday' => false,
|
||||
'scheduledDishes' => collect(),
|
||||
'isEmpty' => true
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function previousMonth(): void
|
||||
public function previousMonth()
|
||||
{
|
||||
if ($this->currentMonth === 1) {
|
||||
$this->currentMonth = 12;
|
||||
|
|
@ -77,10 +80,10 @@ public function previousMonth(): void
|
|||
} else {
|
||||
$this->currentMonth--;
|
||||
}
|
||||
$this->loadCalendar();
|
||||
$this->generateCalendar();
|
||||
}
|
||||
|
||||
public function nextMonth(): void
|
||||
public function nextMonth()
|
||||
{
|
||||
if ($this->currentMonth === 12) {
|
||||
$this->currentMonth = 1;
|
||||
|
|
@ -88,346 +91,62 @@ public function nextMonth(): void
|
|||
} else {
|
||||
$this->currentMonth++;
|
||||
}
|
||||
$this->loadCalendar();
|
||||
$this->generateCalendar();
|
||||
}
|
||||
|
||||
public function regenerateForUserDate($date, $userId): void
|
||||
{
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
public function regenerateForUserDate($date, $userId)
|
||||
{
|
||||
$this->regenerateDate = $date;
|
||||
$this->regenerateUserId = $userId;
|
||||
$this->showRegenerateModal = true;
|
||||
}
|
||||
|
||||
public function confirmRegenerate(): void
|
||||
public function confirmRegenerate()
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($this->regenerateUserId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$action = new DeleteScheduledUserDishForDateAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
Carbon::parse($this->regenerateDate),
|
||||
$this->regenerateUserId
|
||||
);
|
||||
|
||||
// Delete existing scheduled dish for this user on this date
|
||||
ScheduledUserDish::whereDate('date', $this->regenerateDate)
|
||||
->where('user_id', $this->regenerateUserId)
|
||||
->delete();
|
||||
|
||||
// You could call a specific regeneration method here
|
||||
// For now, we'll just delete and let the user generate again
|
||||
|
||||
$this->showRegenerateModal = false;
|
||||
$this->loadCalendar();
|
||||
|
||||
$this->generateCalendar(); // Refresh calendar
|
||||
|
||||
session()->flash('success', 'Schedule regenerated for the selected date!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $this->regenerateDate]);
|
||||
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error regenerating schedule: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function skipDay($date, $userId): void
|
||||
public function skipDay($date, $userId)
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$action = new SkipScheduledUserDishForDateAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
Carbon::parse($date),
|
||||
$userId
|
||||
);
|
||||
|
||||
$this->loadCalendar();
|
||||
|
||||
// Mark this day as skipped or delete the assignment
|
||||
ScheduledUserDish::whereDate('date', $date)
|
||||
->where('user_id', $userId)
|
||||
->delete();
|
||||
|
||||
$this->generateCalendar(); // Refresh calendar
|
||||
|
||||
session()->flash('success', 'Day skipped successfully!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Skip day failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
|
||||
session()->flash('error', 'Unable to skip day. Please try again.');
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error skipping day: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizeUser(int $userId): bool
|
||||
{
|
||||
$user = User::find($userId);
|
||||
return $user && $user->planner_id === auth()->id();
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
public function cancel()
|
||||
{
|
||||
$this->showRegenerateModal = false;
|
||||
$this->regenerateDate = null;
|
||||
$this->regenerateUserId = null;
|
||||
$this->showEditDishModal = false;
|
||||
$this->editDate = null;
|
||||
$this->editUserId = null;
|
||||
$this->selectedDishId = null;
|
||||
$this->availableDishes = [];
|
||||
$this->showAddDishModal = false;
|
||||
$this->addDate = null;
|
||||
$this->addUserIds = [];
|
||||
$this->addSelectedDishId = null;
|
||||
$this->addAvailableUsers = [];
|
||||
$this->addAvailableDishes = [];
|
||||
}
|
||||
|
||||
public function removeDish($date, $userId): void
|
||||
public function getMonthNameProperty()
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$schedule = Schedule::where('planner_id', auth()->id())
|
||||
->where('date', $date)
|
||||
->first();
|
||||
|
||||
if ($schedule) {
|
||||
ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$this->loadCalendar();
|
||||
session()->flash('success', 'Dish removed successfully!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Remove dish failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
|
||||
session()->flash('error', 'Unable to remove dish. Please try again.');
|
||||
}
|
||||
return Carbon::createFromDate($this->currentYear, $this->currentMonth, 1)->format('F Y');
|
||||
}
|
||||
|
||||
public function openAddDishModal($date): void
|
||||
{
|
||||
$this->addDate = $date;
|
||||
|
||||
// Load all users for this planner
|
||||
$this->addAvailableUsers = User::where('planner_id', auth()->id())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$this->addAvailableDishes = [];
|
||||
$this->addUserIds = [];
|
||||
$this->addSelectedDishId = null;
|
||||
|
||||
$this->showAddDishModal = true;
|
||||
}
|
||||
|
||||
public function toggleAllUsers(): void
|
||||
{
|
||||
if (count($this->addUserIds) === count($this->addAvailableUsers)) {
|
||||
$this->addUserIds = [];
|
||||
} else {
|
||||
$this->addUserIds = $this->addAvailableUsers->pluck('id')->map(fn($id) => (string) $id)->toArray();
|
||||
}
|
||||
$this->updateAvailableDishes();
|
||||
}
|
||||
|
||||
public function updatedAddUserIds(): void
|
||||
{
|
||||
$this->updateAvailableDishes();
|
||||
}
|
||||
|
||||
private function updateAvailableDishes(): void
|
||||
{
|
||||
if (empty($this->addUserIds)) {
|
||||
$this->addAvailableDishes = [];
|
||||
} else {
|
||||
// Load dishes that ALL selected users have in common
|
||||
$selectedCount = count($this->addUserIds);
|
||||
$this->addAvailableDishes = Dish::whereHas('users', function ($query) {
|
||||
$query->whereIn('users.id', $this->addUserIds);
|
||||
}, '=', $selectedCount)->orderBy('name')->get();
|
||||
}
|
||||
$this->addSelectedDishId = null;
|
||||
}
|
||||
|
||||
public function saveAddDish(): void
|
||||
{
|
||||
try {
|
||||
if (empty($this->addUserIds)) {
|
||||
session()->flash('error', 'Please select at least one user.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->addSelectedDishId) {
|
||||
session()->flash('error', 'Please select a dish.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or create the schedule for this date
|
||||
$schedule = Schedule::firstOrCreate(
|
||||
[
|
||||
'planner_id' => auth()->id(),
|
||||
'date' => $this->addDate,
|
||||
],
|
||||
['is_skipped' => false]
|
||||
);
|
||||
|
||||
$addedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($this->addUserIds as $userId) {
|
||||
if (!$this->authorizeUser((int) $userId)) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user already has a dish scheduled for this date
|
||||
$existing = ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the UserDish for this user and dish
|
||||
$userDish = UserDish::where('user_id', $userId)
|
||||
->where('dish_id', $this->addSelectedDishId)
|
||||
->first();
|
||||
|
||||
if (!$userDish) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the scheduled user dish
|
||||
ScheduledUserDish::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'user_id' => $userId,
|
||||
'user_dish_id' => $userDish->id,
|
||||
'is_skipped' => false,
|
||||
]);
|
||||
$addedCount++;
|
||||
}
|
||||
|
||||
$this->closeAddDishModal();
|
||||
$this->loadCalendar();
|
||||
|
||||
if ($addedCount > 0 && $skippedCount > 0) {
|
||||
session()->flash('success', "Dish added for {$addedCount} user(s). {$skippedCount} user(s) skipped (already scheduled).");
|
||||
} elseif ($addedCount > 0) {
|
||||
session()->flash('success', "Dish added for {$addedCount} user(s)!");
|
||||
} else {
|
||||
session()->flash('error', 'No users could be scheduled. They may already have dishes for this date.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error('Add dish failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to add dish. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
private function closeAddDishModal(): void
|
||||
{
|
||||
$this->showAddDishModal = false;
|
||||
$this->addDate = null;
|
||||
$this->addUserIds = [];
|
||||
$this->addSelectedDishId = null;
|
||||
$this->addAvailableUsers = [];
|
||||
$this->addAvailableDishes = [];
|
||||
}
|
||||
|
||||
public function editDish($date, $userId): void
|
||||
{
|
||||
if (!$this->authorizeUser($userId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editDate = $date;
|
||||
$this->editUserId = $userId;
|
||||
|
||||
// Load dishes available for this user (via UserDish pivot)
|
||||
$this->availableDishes = Dish::whereHas('users', function ($query) use ($userId) {
|
||||
$query->where('users.id', $userId);
|
||||
})->orderBy('name')->get();
|
||||
|
||||
// Get currently selected dish for this date/user if exists
|
||||
$schedule = Schedule::where('planner_id', auth()->id())
|
||||
->where('date', $date)
|
||||
->first();
|
||||
|
||||
if ($schedule) {
|
||||
$scheduledUserDish = ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($scheduledUserDish && $scheduledUserDish->userDish) {
|
||||
$this->selectedDishId = $scheduledUserDish->userDish->dish_id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->showEditDishModal = true;
|
||||
}
|
||||
|
||||
public function saveDish(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->authorizeUser($this->editUserId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->selectedDishId) {
|
||||
session()->flash('error', 'Please select a dish.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or create the schedule for this date
|
||||
$schedule = Schedule::firstOrCreate(
|
||||
[
|
||||
'planner_id' => auth()->id(),
|
||||
'date' => $this->editDate,
|
||||
],
|
||||
['is_skipped' => false]
|
||||
);
|
||||
|
||||
// Find the UserDish for this user and dish
|
||||
$userDish = UserDish::where('user_id', $this->editUserId)
|
||||
->where('dish_id', $this->selectedDishId)
|
||||
->first();
|
||||
|
||||
if (!$userDish) {
|
||||
session()->flash('error', 'This dish is not assigned to this user.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update or create the scheduled user dish
|
||||
ScheduledUserDish::updateOrCreate(
|
||||
[
|
||||
'schedule_id' => $schedule->id,
|
||||
'user_id' => $this->editUserId,
|
||||
],
|
||||
[
|
||||
'user_dish_id' => $userDish->id,
|
||||
'is_skipped' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->showEditDishModal = false;
|
||||
$this->editDate = null;
|
||||
$this->editUserId = null;
|
||||
$this->selectedDishId = null;
|
||||
$this->availableDishes = [];
|
||||
|
||||
$this->loadCalendar();
|
||||
session()->flash('success', 'Dish updated successfully!');
|
||||
} catch (Exception $e) {
|
||||
Log::error('Save dish failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to save dish. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getMonthNameProperty(): string
|
||||
{
|
||||
$service = new ScheduleCalendarService();
|
||||
return $service->getMonthName($this->currentMonth, $this->currentYear);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,120 +3,184 @@
|
|||
namespace App\Livewire\Schedule;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Dish;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use Carbon\Carbon;
|
||||
use DishPlanner\Schedule\Actions\ClearScheduleForMonthAction;
|
||||
use DishPlanner\Schedule\Actions\GenerateScheduleForMonthAction;
|
||||
use DishPlanner\Schedule\Actions\RegenerateScheduleForDateForUsersAction;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Component;
|
||||
|
||||
class ScheduleGenerator extends Component
|
||||
{
|
||||
private const YEARS_IN_PAST = 1;
|
||||
private const YEARS_IN_FUTURE = 5;
|
||||
|
||||
public $selectedMonth;
|
||||
public $selectedYear;
|
||||
public $selectedUsers = [];
|
||||
public $clearExisting = true;
|
||||
public $showAdvancedOptions = false;
|
||||
public $isGenerating = false;
|
||||
|
||||
public function mount(): void
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->selectedMonth = now()->month;
|
||||
$this->selectedYear = now()->year;
|
||||
|
||||
$this->selectedUsers = User::where('planner_id', auth()->id())
|
||||
|
||||
// Select all users by default
|
||||
$this->selectedUsers = User::where('planner_id', auth()->user()->planner_id)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View
|
||||
public function render()
|
||||
{
|
||||
$users = User::where('planner_id', auth()->id())
|
||||
$users = User::where('planner_id', auth()->user()->planner_id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$years = range(now()->year - self::YEARS_IN_PAST, now()->year + self::YEARS_IN_FUTURE);
|
||||
|
||||
|
||||
$months = [
|
||||
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
|
||||
5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
|
||||
9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
|
||||
];
|
||||
|
||||
$years = range(now()->year - 1, now()->year + 2);
|
||||
|
||||
return view('livewire.schedule.schedule-generator', [
|
||||
'users' => $users,
|
||||
'months' => $this->getMonthNames(),
|
||||
'months' => $months,
|
||||
'years' => $years
|
||||
]);
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
public function generate()
|
||||
{
|
||||
$this->validate([
|
||||
'selectedUsers' => 'required|array|min:1',
|
||||
'selectedMonth' => 'required|integer|min:1|max:12',
|
||||
'selectedYear' => 'required|integer|min:' . (now()->year - self::YEARS_IN_PAST) . '|max:' . (now()->year + self::YEARS_IN_FUTURE),
|
||||
'selectedYear' => 'required|integer|min:2020|max:2030',
|
||||
]);
|
||||
|
||||
$this->isGenerating = true;
|
||||
|
||||
try {
|
||||
$action = new GenerateScheduleForMonthAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
$this->selectedMonth,
|
||||
$this->selectedYear,
|
||||
$this->selectedUsers,
|
||||
$this->clearExisting
|
||||
);
|
||||
$startDate = Carbon::createFromDate($this->selectedYear, $this->selectedMonth, 1);
|
||||
$endDate = $startDate->copy()->endOfMonth();
|
||||
|
||||
// Clear existing schedule if requested
|
||||
if ($this->clearExisting) {
|
||||
ScheduledUserDish::whereBetween('date', [$startDate, $endDate])
|
||||
->whereIn('user_id', $this->selectedUsers)
|
||||
->delete();
|
||||
}
|
||||
|
||||
// Get all dishes assigned to selected users
|
||||
$userDishes = [];
|
||||
foreach ($this->selectedUsers as $userId) {
|
||||
$user = User::find($userId);
|
||||
$dishes = $user->dishes()->get();
|
||||
|
||||
if ($dishes->isNotEmpty()) {
|
||||
$userDishes[$userId] = $dishes->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate schedule for each day
|
||||
$currentDate = $startDate->copy();
|
||||
while ($currentDate <= $endDate) {
|
||||
foreach ($this->selectedUsers as $userId) {
|
||||
// Skip if user already has a dish for this day
|
||||
if (ScheduledUserDish::where('date', $currentDate->format('Y-m-d'))
|
||||
->where('user_id', $userId)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get available dishes for this user
|
||||
if (!isset($userDishes[$userId]) || empty($userDishes[$userId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$availableDishes = $userDishes[$userId];
|
||||
|
||||
// Simple random assignment (you can implement more complex logic here)
|
||||
if (!empty($availableDishes)) {
|
||||
$randomDish = $availableDishes[array_rand($availableDishes)];
|
||||
|
||||
ScheduledUserDish::create([
|
||||
'user_id' => $userId,
|
||||
'dish_id' => $randomDish['id'],
|
||||
'date' => $currentDate->format('Y-m-d'),
|
||||
'planner_id' => auth()->user()->planner_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$currentDate->addDay();
|
||||
}
|
||||
|
||||
$this->isGenerating = false;
|
||||
|
||||
// Emit event to refresh calendar
|
||||
$this->dispatch('schedule-generated');
|
||||
|
||||
session()->flash('success', 'Schedule generated successfully for ' .
|
||||
|
||||
session()->flash('success', 'Schedule generated successfully for ' .
|
||||
$this->getSelectedMonthName() . ' ' . $this->selectedYear);
|
||||
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->isGenerating = false;
|
||||
Log::error('Schedule generation failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to generate schedule. Please try again.');
|
||||
session()->flash('error', 'Error generating schedule: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerateForDate($date): void
|
||||
public function regenerateForDate($date)
|
||||
{
|
||||
try {
|
||||
$action = new RegenerateScheduleForDateForUsersAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
Carbon::parse($date),
|
||||
$this->selectedUsers
|
||||
);
|
||||
|
||||
// Clear existing assignments for this date
|
||||
ScheduledUserDish::whereDate('date', $date)
|
||||
->whereIn('user_id', $this->selectedUsers)
|
||||
->delete();
|
||||
|
||||
// Regenerate for this specific date
|
||||
$currentDate = Carbon::parse($date);
|
||||
|
||||
foreach ($this->selectedUsers as $userId) {
|
||||
$user = User::find($userId);
|
||||
$dishes = $user->dishes()->get();
|
||||
|
||||
if ($dishes->isNotEmpty()) {
|
||||
$randomDish = $dishes->random();
|
||||
|
||||
ScheduledUserDish::create([
|
||||
'user_id' => $userId,
|
||||
'dish_id' => $randomDish->id,
|
||||
'date' => $currentDate->format('Y-m-d'),
|
||||
'planner_id' => auth()->user()->planner_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->dispatch('schedule-generated');
|
||||
session()->flash('success', 'Schedule regenerated for ' . Carbon::parse($date)->format('M d, Y'));
|
||||
|
||||
session()->flash('success', 'Schedule regenerated for ' . $currentDate->format('M d, Y'));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $date]);
|
||||
session()->flash('error', 'Unable to regenerate schedule. Please try again.');
|
||||
session()->flash('error', 'Error regenerating schedule: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function clearMonth(): void
|
||||
public function clearMonth()
|
||||
{
|
||||
try {
|
||||
$action = new ClearScheduleForMonthAction();
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
$this->selectedMonth,
|
||||
$this->selectedYear,
|
||||
$this->selectedUsers
|
||||
);
|
||||
|
||||
$startDate = Carbon::createFromDate($this->selectedYear, $this->selectedMonth, 1);
|
||||
$endDate = $startDate->copy()->endOfMonth();
|
||||
|
||||
ScheduledUserDish::whereBetween('date', [$startDate, $endDate])
|
||||
->whereIn('user_id', $this->selectedUsers)
|
||||
->delete();
|
||||
|
||||
$this->dispatch('schedule-generated');
|
||||
session()->flash('success', 'Schedule cleared for ' .
|
||||
session()->flash('success', 'Schedule cleared for ' .
|
||||
$this->getSelectedMonthName() . ' ' . $this->selectedYear);
|
||||
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Clear month failed', ['exception' => $e]);
|
||||
session()->flash('error', 'Unable to clear schedule. Please try again.');
|
||||
session()->flash('error', 'Error clearing schedule: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,17 +189,14 @@ public function toggleAdvancedOptions()
|
|||
$this->showAdvancedOptions = !$this->showAdvancedOptions;
|
||||
}
|
||||
|
||||
private function getMonthNames(): array
|
||||
private function getSelectedMonthName()
|
||||
{
|
||||
return [
|
||||
$months = [
|
||||
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
|
||||
5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
|
||||
9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
|
||||
];
|
||||
|
||||
return $months[$this->selectedMonth];
|
||||
}
|
||||
|
||||
private function getSelectedMonthName(): string
|
||||
{
|
||||
return $this->getMonthNames()[$this->selectedMonth];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,6 @@
|
|||
namespace App\Livewire\Users;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Actions\User\CreateUserAction;
|
||||
use App\Actions\User\DeleteUserAction;
|
||||
use App\Actions\User\EditUserAction;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
|
|
@ -15,112 +10,127 @@ class UsersList extends Component
|
|||
{
|
||||
use WithPagination;
|
||||
|
||||
public bool $showCreateModal = false;
|
||||
public bool $showEditModal = false;
|
||||
public bool $showDeleteModal = false;
|
||||
|
||||
public ?User $editingUser = null;
|
||||
public ?User $deletingUser = null;
|
||||
|
||||
public $showCreateModal = false;
|
||||
public $showEditModal = false;
|
||||
public $showDeleteModal = false;
|
||||
|
||||
public $editingUser = null;
|
||||
public $deletingUser = null;
|
||||
|
||||
// Form fields
|
||||
public string $name = '';
|
||||
|
||||
protected array $rules = [
|
||||
public $name = '';
|
||||
public $email = '';
|
||||
public $password = '';
|
||||
public $password_confirmation = '';
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'required|min:8|confirmed',
|
||||
];
|
||||
|
||||
public function render(): View
|
||||
public function render()
|
||||
{
|
||||
$users = User::where('planner_id', auth()->id())
|
||||
$users = User::where('planner_id', auth()->user()->planner_id)
|
||||
->orderBy('name')
|
||||
->paginate(10);
|
||||
|
||||
|
||||
return view('livewire.users.users-list', [
|
||||
'users' => $users
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
public function create()
|
||||
{
|
||||
$this->reset(['name']);
|
||||
$this->reset(['name', 'email', 'password', 'password_confirmation']);
|
||||
$this->resetValidation();
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function store(): void
|
||||
public function store()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
(new CreateUserAction())->execute([
|
||||
'name' => $this->name,
|
||||
'planner_id' => auth()->id(),
|
||||
]);
|
||||
User::create([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'password' => bcrypt($this->password),
|
||||
'planner_id' => auth()->user()->planner_id,
|
||||
]);
|
||||
|
||||
$this->showCreateModal = false;
|
||||
$this->reset(['name']);
|
||||
|
||||
session()->flash('success', 'User created successfully.');
|
||||
} catch (Exception $e) {
|
||||
session()->flash('error', 'Failed to create user: ' . $e->getMessage());
|
||||
}
|
||||
$this->showCreateModal = false;
|
||||
$this->reset(['name', 'email', 'password', 'password_confirmation']);
|
||||
|
||||
session()->flash('success', 'User created successfully.');
|
||||
}
|
||||
|
||||
public function edit(User $user): void
|
||||
public function edit(User $user)
|
||||
{
|
||||
$this->editingUser = $user;
|
||||
$this->name = $user->name;
|
||||
$this->email = $user->email;
|
||||
$this->password = '';
|
||||
$this->password_confirmation = '';
|
||||
$this->resetValidation();
|
||||
$this->showEditModal = true;
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
public function update()
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
try {
|
||||
(new EditUserAction())->execute($this->editingUser, ['name' => $this->name]);
|
||||
|
||||
$this->showEditModal = false;
|
||||
$this->reset(['name', 'editingUser']);
|
||||
|
||||
session()->flash('success', 'User updated successfully.');
|
||||
|
||||
// Force component to re-render with fresh data
|
||||
$this->resetPage();
|
||||
} catch (Exception $e) {
|
||||
session()->flash('error', 'Failed to update user: ' . $e->getMessage());
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $this->editingUser->id,
|
||||
];
|
||||
|
||||
if ($this->password) {
|
||||
$rules['password'] = 'min:8|confirmed';
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
$this->editingUser->update([
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
]);
|
||||
|
||||
if ($this->password) {
|
||||
$this->editingUser->update([
|
||||
'password' => bcrypt($this->password)
|
||||
]);
|
||||
}
|
||||
|
||||
$this->showEditModal = false;
|
||||
$this->reset(['name', 'email', 'password', 'password_confirmation', 'editingUser']);
|
||||
|
||||
session()->flash('success', 'User updated successfully.');
|
||||
}
|
||||
|
||||
public function confirmDelete(User $user): void
|
||||
public function confirmDelete(User $user)
|
||||
{
|
||||
$this->deletingUser = $user;
|
||||
$this->showDeleteModal = true;
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
public function delete()
|
||||
{
|
||||
try {
|
||||
(new DeleteUserAction())->execute($this->deletingUser);
|
||||
|
||||
if ($this->deletingUser->id === auth()->id()) {
|
||||
session()->flash('error', 'You cannot delete your own account.');
|
||||
$this->showDeleteModal = false;
|
||||
$this->deletingUser = null;
|
||||
|
||||
session()->flash('success', 'User deleted successfully.');
|
||||
|
||||
// Force component to re-render with fresh data
|
||||
$this->resetPage();
|
||||
} catch (Exception $e) {
|
||||
session()->flash('error', 'Failed to delete user: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deletingUser->delete();
|
||||
$this->showDeleteModal = false;
|
||||
$this->deletingUser = null;
|
||||
|
||||
session()->flash('success', 'User deleted successfully.');
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
public function cancel()
|
||||
{
|
||||
$this->showCreateModal = false;
|
||||
$this->showEditModal = false;
|
||||
$this->showDeleteModal = false;
|
||||
$this->reset(['name', 'editingUser', 'deletingUser']);
|
||||
$this->reset(['name', 'email', 'password', 'password_confirmation', 'editingUser', 'deletingUser']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,15 @@
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property static PlannerFactory factory($count = null, $state = [])
|
||||
* @method static first()
|
||||
*/
|
||||
class Planner extends Authenticatable
|
||||
{
|
||||
use Billable, HasApiTokens, HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'email', 'password',
|
||||
|
|
@ -26,10 +24,6 @@ class Planner extends Authenticatable
|
|||
'password', 'remember_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
public function schedules(): HasMany
|
||||
{
|
||||
return $this->hasMany(Schedule::class);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
* @method static create(array $array)
|
||||
* @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and')
|
||||
* @method static ScheduleFactory factory($count = null, $state = [])
|
||||
* @method static firstOrCreate(array $array, false[] $array1)
|
||||
*/
|
||||
class Schedule extends Model
|
||||
{
|
||||
|
|
@ -57,6 +56,6 @@ public function scheduledUserDishes(): HasMany
|
|||
|
||||
public function hasAllUsersScheduled(): bool
|
||||
{
|
||||
return $this->scheduledUserDishes->count() === User::where('planner_id', $this->planner_id)->count();
|
||||
return $this->scheduledUserDishes->count() === User::all()->count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,25 +12,17 @@
|
|||
* @property int $id
|
||||
* @property int $schedule_id
|
||||
* @property Schedule $schedule
|
||||
* @property int $user_id
|
||||
* @property User $user
|
||||
* @property int $user_dish_id
|
||||
* @property UserDish $userDish
|
||||
* @property bool $is_skipped
|
||||
* @method static create(array $array)
|
||||
* @method static ScheduledUserDishFactory factory($count = null, $state = [])
|
||||
* @method static firstOrCreate(array $array, array $array1)
|
||||
*/
|
||||
class ScheduledUserDish extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'schedule_id',
|
||||
'user_id',
|
||||
'user_dish_id',
|
||||
'is_skipped'
|
||||
];
|
||||
protected $fillable = ['schedule_id', 'user_id', 'user_dish_id', 'is_skipped'];
|
||||
|
||||
protected $casts = [
|
||||
'is_skipped' => 'boolean',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
|
|
@ -19,17 +20,22 @@
|
|||
* @property Collection<UserDish> $userDishes
|
||||
* @method static User findOrFail(int $user_id)
|
||||
* @method static UserFactory factory($count = null, $state = [])
|
||||
* @method static create(array $array)
|
||||
* @method static where(string $string, int|string|null $id)
|
||||
*/
|
||||
class User extends Model
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'planner_id',
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
|
|
@ -37,6 +43,19 @@ protected static function booted(): void
|
|||
static::addGlobalScope(new BelongsToPlanner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function dishes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Dish::class, 'user_dishes', 'user_id', 'dish_id');
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@
|
|||
|
||||
use App\Exceptions\CustomException;
|
||||
use App\Models\Dish;
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDish;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use DishPlanner\Dish\Policies\DishPolicy;
|
||||
use DishPlanner\Schedule\Policies\SchedulePolicy;
|
||||
use DishPlanner\ScheduledUserDish\Policies\ScheduledUserDishPolicy;
|
||||
|
|
@ -47,8 +45,6 @@ public function render($request, Throwable $e)
|
|||
|
||||
public function boot(): void
|
||||
{
|
||||
Cashier::useCustomerModel(Planner::class);
|
||||
|
||||
Gate::policy(Dish::class, DishPolicy::class);
|
||||
Gate::policy(Schedule::class, SchedulePolicy::class);
|
||||
Gate::policy(ScheduledUserDish::class, ScheduledUserDishPolicy::class);
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AppModeEnum;
|
||||
|
||||
if (! function_exists('is_mode_app')) {
|
||||
function is_mode_app(): bool
|
||||
{
|
||||
return AppModeEnum::current()->isApp();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('is_mode_saas')) {
|
||||
function is_mode_saas(): bool
|
||||
{
|
||||
return AppModeEnum::current()->isSaas();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\ForceJsonResponse;
|
||||
use App\Http\Middleware\RequireSaasMode;
|
||||
use App\Http\Middleware\RequireSubscription;
|
||||
use App\Http\Middleware\HandleResourceNotFound;
|
||||
use App\Services\OutputService;
|
||||
use DishPlanner\Dish\Controllers\DishController;
|
||||
use DishPlanner\Schedule\Console\Commands\GenerateScheduleCommand;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\HandleCors;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
|
|
@ -19,24 +23,12 @@
|
|||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
then: function () {
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/web/subscription.php'));
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Apply ForceJsonResponse only to API routes
|
||||
$middleware->api(ForceJsonResponse::class);
|
||||
|
||||
$middleware->alias([
|
||||
'subscription' => RequireSubscription::class,
|
||||
'saas' => RequireSaasMode::class,
|
||||
]);
|
||||
|
||||
// Exclude Stripe webhook from CSRF verification
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'stripe/webhook',
|
||||
]);
|
||||
$middleware->append(StartSession::class);
|
||||
$middleware->append(HandleCors::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
|
|
@ -50,31 +42,21 @@
|
|||
/** @var OutputService $outputService */
|
||||
$outputService = resolve(OutputService::class);
|
||||
|
||||
$exceptions->render(function (ValidationException $e, Request $request) use ($outputService) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
return response()->json(
|
||||
$outputService->response(false, null, [$e->getMessage()]),
|
||||
404
|
||||
);
|
||||
}
|
||||
});
|
||||
$exceptions->render(fn (ValidationException $e, Request $request) => $outputService
|
||||
->response(false, null, [$e->getMessage()], 404)
|
||||
);
|
||||
|
||||
$exceptions->render(function (NotFoundHttpException $e, Request $request) use ($outputService) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
return response()->json(
|
||||
$outputService->response(false, null, ['MODEL_NOT_FOUND']),
|
||||
404
|
||||
);
|
||||
}
|
||||
});
|
||||
$exceptions->render(fn (NotFoundHttpException $e, Request $request) => response()->json(
|
||||
$outputService->response(false, null, ['MODEL_NOT_FOUND']),
|
||||
404
|
||||
));
|
||||
|
||||
$exceptions->render(function (AccessDeniedHttpException $e, Request $request) use ($outputService) {
|
||||
if ($request->is('api/*') || $request->expectsJson()) {
|
||||
return response()->json(
|
||||
$outputService->response(false, null, [$e->getMessage()]),
|
||||
403
|
||||
);
|
||||
}
|
||||
});
|
||||
$exceptions->render(fn (AccessDeniedHttpException $e, Request $request) => response()->json(
|
||||
$outputService->response(false, null, [$e->getMessage()]),
|
||||
403
|
||||
));
|
||||
})
|
||||
->withCommands([
|
||||
GenerateScheduleCommand::class,
|
||||
])
|
||||
->create();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/cashier": "^16.1",
|
||||
"laravel/framework": "^12.9.2",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.9",
|
||||
|
|
@ -18,7 +17,6 @@
|
|||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/dusk": "^8.3",
|
||||
"laravel/pail": "^1.1",
|
||||
"laravel/pint": "^1.13",
|
||||
"laravel/sail": "^1.26",
|
||||
|
|
@ -27,9 +25,6 @@
|
|||
"phpunit/phpunit": "^11.0.1"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"app/helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"DishPlanner\\": "src/DishPlanner/",
|
||||
|
|
@ -61,17 +56,6 @@
|
|||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan test"
|
||||
],
|
||||
"test:coverage": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -d xdebug.mode=coverage artisan test --coverage"
|
||||
],
|
||||
"test:coverage-html": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -d xdebug.mode=coverage vendor/bin/phpunit --coverage-html coverage --coverage-text"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Dish Planner'),
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -28,18 +28,6 @@
|
|||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Determines the application deployment mode: 'app' for self-hosted,
|
||||
| 'saas' for multi-tenant SaaS, 'demo' for demonstration instances.
|
||||
|
|
||||
*/
|
||||
|
||||
'mode' => env('APP_MODE', 'app'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|
|
|
|||
|
|
@ -35,14 +35,4 @@
|
|||
],
|
||||
],
|
||||
|
||||
'stripe' => [
|
||||
'key' => env('STRIPE_KEY'),
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
'webhook' => [
|
||||
'secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
],
|
||||
'price_monthly' => env('STRIPE_PRICE_MONTHLY'),
|
||||
'price_yearly' => env('STRIPE_PRICE_YEARLY'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public function up(): void
|
|||
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
|
||||
$table->foreign('user_dish_id')->references('id')->on('user_dishes')->onDelete('cascade');
|
||||
|
||||
$table->unique(['schedule_id', 'user_id']);
|
||||
$table->unique(['schedule_id', 'user_dish_id']);
|
||||
$table->index('user_dish_id');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('planners', function (Blueprint $table) {
|
||||
$table->string('stripe_id')->nullable()->index();
|
||||
$table->string('pm_type')->nullable();
|
||||
$table->string('pm_last_four', 4)->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('planners', function (Blueprint $table) {
|
||||
$table->dropIndex([
|
||||
'stripe_id',
|
||||
]);
|
||||
|
||||
$table->dropColumn([
|
||||
'stripe_id',
|
||||
'pm_type',
|
||||
'pm_last_four',
|
||||
'trial_ends_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<?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('subscriptions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('planner_id');
|
||||
$table->string('type');
|
||||
$table->string('stripe_id')->unique();
|
||||
$table->string('stripe_status');
|
||||
$table->string('stripe_price')->nullable();
|
||||
$table->integer('quantity')->nullable();
|
||||
$table->timestamp('trial_ends_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['planner_id', 'stripe_status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('subscriptions');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<?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('subscription_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('subscription_id');
|
||||
$table->string('stripe_id')->unique();
|
||||
$table->string('stripe_product');
|
||||
$table->string('stripe_price');
|
||||
$table->integer('quantity')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['subscription_id', 'stripe_price']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('subscription_items');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?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::table('subscription_items', function (Blueprint $table) {
|
||||
$table->string('meter_id')->nullable()->after('stripe_price');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->dropColumn('meter_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?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::table('subscription_items', function (Blueprint $table) {
|
||||
$table->string('meter_event_name')->nullable()->after('quantity');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscription_items', function (Blueprint $table) {
|
||||
$table->dropColumn('meter_event_name');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Dish;
|
||||
use App\Models\Planner;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DevelopmentSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Only run in development environment, not during testing
|
||||
if (app()->environment('testing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create main planner
|
||||
$planner = Planner::factory()->create([
|
||||
'name' => 'Development Planner',
|
||||
'email' => 'myrmidex@myrmidex.net',
|
||||
'password' => Hash::make('Password'),
|
||||
]);
|
||||
|
||||
// Create a few users (users don't have email/password - only planners do)
|
||||
$users = collect([
|
||||
User::factory()->create([
|
||||
'planner_id' => $planner->id,
|
||||
'name' => 'Alice Johnson',
|
||||
]),
|
||||
User::factory()->create([
|
||||
'planner_id' => $planner->id,
|
||||
'name' => 'Bob Smith',
|
||||
]),
|
||||
User::factory()->create([
|
||||
'planner_id' => $planner->id,
|
||||
'name' => 'Charlie Brown',
|
||||
]),
|
||||
]);
|
||||
|
||||
// Create various dishes
|
||||
$dishNames = [
|
||||
'Spaghetti Bolognese',
|
||||
'Chicken Curry',
|
||||
'Caesar Salad',
|
||||
'Beef Stir Fry',
|
||||
'Vegetable Lasagne',
|
||||
'Fish Tacos',
|
||||
'Mushroom Risotto',
|
||||
'BBQ Ribs',
|
||||
'Greek Salad',
|
||||
'Pad Thai',
|
||||
'Margherita Pizza',
|
||||
'Beef Burger',
|
||||
'Chicken Fajitas',
|
||||
'Vegetable Soup',
|
||||
'Salmon Teriyaki',
|
||||
];
|
||||
|
||||
foreach ($dishNames as $dishName) {
|
||||
$dish = Dish::factory()->create([
|
||||
'planner_id' => $planner->id,
|
||||
'name' => $dishName,
|
||||
]);
|
||||
|
||||
// Randomly assign dish to 1-3 users
|
||||
$assignedUsers = $users->random(rand(1, 3));
|
||||
$dish->users()->attach($assignedUsers->pluck('id'));
|
||||
}
|
||||
|
||||
$this->command->info('Development data seeded successfully!');
|
||||
$this->command->info('Login credentials: myrmidex@myrmidex.net / Password');
|
||||
}
|
||||
}
|
||||
|
|
@ -11,34 +11,23 @@ class DishesSeeder extends Seeder
|
|||
{
|
||||
public function run(): void
|
||||
{
|
||||
/** @var Planner $planner */
|
||||
$planner = Planner::first() ?? Planner::factory()->create();
|
||||
$users = User::all();
|
||||
$userOptions = collect([
|
||||
[$users->first()],
|
||||
[$users->last()],
|
||||
[$users->first(), $users->last()],
|
||||
]);
|
||||
|
||||
// Get users belonging to this planner
|
||||
$users = User::where('planner_id', $planner->id)->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->command->warn('No users found for planner. Skipping dishes seeder.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$userIds = $users->pluck('id')->toArray();
|
||||
|
||||
// Build possible user combinations (individual users + all users together)
|
||||
$userOptions = collect($userIds)->map(fn ($id) => [$id])->toArray();
|
||||
$userOptions[] = $userIds; // all users
|
||||
$planner = Planner::all()->first() ?? Planner::factory()->create();
|
||||
|
||||
collect([
|
||||
'Lasagne', 'Pizza', 'Burger', 'Fries', 'Salad', 'Sushi', 'Pancakes', 'Ice Cream', 'Spaghetti', 'Mac and Cheese',
|
||||
'Steak', 'Chicken', 'Beef', 'Pork', 'Fish', 'Chips', 'Cake',
|
||||
])->each(function (string $name) use ($planner, $userOptions) {
|
||||
$dish = Dish::factory()->create([
|
||||
'lasagne', 'pizza', 'burger', 'fries', 'salad', 'sushi', 'pancakes', 'ice cream', 'spaghetti', 'mac and cheese',
|
||||
'steak', 'chicken', 'beef', 'pork', 'fish', 'chips', 'cake',
|
||||
])->map(fn (string $name) => Dish::factory()
|
||||
->create([
|
||||
'planner_id' => $planner->id,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
$dish->users()->attach($userOptions[array_rand($userOptions)]);
|
||||
});
|
||||
])
|
||||
)->each(fn (Dish $dish) => $dish->users()->attach($userOptions->random()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ services:
|
|||
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
# Initialize with SQL scripts
|
||||
- ./docker/mysql-init:/docker-entrypoint-initdb.d
|
||||
# Optional: Initialize with SQL dump
|
||||
# - ./database/dumps:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
|
|
@ -84,21 +84,6 @@ services:
|
|||
networks:
|
||||
- dishplanner
|
||||
|
||||
# Selenium for E2E testing with Dusk
|
||||
selenium:
|
||||
image: selenium/standalone-chrome:latest
|
||||
container_name: dishplanner_selenium
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4444:4444" # Selenium server
|
||||
- "7900:7900" # VNC server for debugging
|
||||
volumes:
|
||||
- /dev/shm:/dev/shm
|
||||
networks:
|
||||
- dishplanner
|
||||
environment:
|
||||
- SE_VNC_PASSWORD=secret
|
||||
|
||||
# Optional: Redis for caching/sessions
|
||||
# redis:
|
||||
# image: redis:alpine
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
-- Create test database for Dusk E2E tests
|
||||
CREATE DATABASE IF NOT EXISTS dishplanner_test;
|
||||
|
||||
-- Grant all privileges on test database to the dishplanner user
|
||||
GRANT ALL PRIVILEGES ON dishplanner_test.* TO 'dishplanner'@'%' IDENTIFIED BY 'dishplanner';
|
||||
GRANT ALL PRIVILEGES ON dishplanner_test.* TO 'dishplanner'@'localhost' IDENTIFIED BY 'dishplanner';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit backupGlobals="false"
|
||||
beStrictAboutTestsThatDoNotTestAnything="false"
|
||||
colors="true"
|
||||
processIsolation="false"
|
||||
stopOnError="false"
|
||||
stopOnFailure="false"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
backupStaticProperties="false">
|
||||
<testsuites>
|
||||
<testsuite name="Browser Test Suite">
|
||||
<directory suffix="Test.php">./tests/Browser</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
13
phpunit.xml
13
phpunit.xml
|
|
@ -15,21 +15,8 @@
|
|||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory>app/Console</directory>
|
||||
<directory>app/Exceptions</directory>
|
||||
<directory>app/Providers</directory>
|
||||
</exclude>
|
||||
</source>
|
||||
<coverage>
|
||||
<report>
|
||||
<html outputDirectory="coverage"/>
|
||||
<text outputFile="coverage/coverage.txt" showOnlySummary="true"/>
|
||||
<clover outputFile="coverage/clover.xml"/>
|
||||
</report>
|
||||
</coverage>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
|
|
@ -94,38 +94,3 @@ .button-accent-outline {
|
|||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Checkbox Styles */
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: var(--color-gray-600);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-accent-blue);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
import './bootstrap';
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
@extends('components.layouts.guest')
|
||||
|
||||
@section('content')
|
||||
<h2 class="text-xl text-center text-accent-blue mb-4">Sign in to your account</h2>
|
||||
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@csrf
|
||||
|
||||
<x-input
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<x-input
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
|
||||
<x-checkbox name="remember" label="Remember me" />
|
||||
|
||||
<x-button type="submit" class="w-full">
|
||||
Sign In
|
||||
</x-button>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<span class="text-sm text-gray-400">Don't have an account?</span>
|
||||
<a href="{{ route('register') }}" class="text-accent-blue hover:underline text-sm ml-1">Register</a>
|
||||
</div>
|
||||
</form>
|
||||
@endsection
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
@extends('components.layouts.guest')
|
||||
|
||||
@section('content')
|
||||
<h2 class="text-xl text-center text-accent-blue mb-4">Create an account</h2>
|
||||
|
||||
<form method="POST" action="{{ route('register') }}">
|
||||
@csrf
|
||||
|
||||
<x-input
|
||||
type="text"
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="Enter your name"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<x-input
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
|
||||
<x-input
|
||||
type="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
|
||||
<x-input
|
||||
type="password"
|
||||
name="password_confirmation"
|
||||
label="Confirm Password"
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
/>
|
||||
|
||||
<x-button type="submit" class="w-full">
|
||||
Register
|
||||
</x-button>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<span class="text-sm text-gray-400">Already have an account?</span>
|
||||
<a href="{{ route('login') }}" class="text-accent-blue hover:underline text-sm ml-1">Login</a>
|
||||
</div>
|
||||
</form>
|
||||
@endsection
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
<x-layouts.app>
|
||||
<div class="px-4 sm:px-6 lg:px-8" x-data="{ showCancelModal: false }">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">BILLING</h1>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mb-6 border-2 border-success rounded-lg p-4 bg-success/10">
|
||||
<p class="text-success">{{ session('success') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="mb-6 border-2 border-danger rounded-lg p-4 bg-danger/10">
|
||||
<p class="text-danger">{{ session('error') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="border-2 border-secondary rounded-lg p-6">
|
||||
<h3 class="text-xl font-bold text-primary mb-6">Subscription Details</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||
<span class="text-gray-300">Plan</span>
|
||||
<span class="text-white font-bold">{{ $planType }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||
<span class="text-gray-300">Status</span>
|
||||
<span class="font-bold {{ $subscription->stripe_status === 'active' ? 'text-success' : 'text-warning' }}">
|
||||
{{ ucfirst($subscription->stripe_status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if($nextBillingDate)
|
||||
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||
<span class="text-gray-300">Next billing date</span>
|
||||
<span class="text-white">{{ $nextBillingDate->format('F j, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($subscription->ends_at)
|
||||
<div class="flex justify-between items-center border-b border-gray-600 pb-4">
|
||||
<span class="text-gray-300">Access until</span>
|
||||
<span class="text-warning">{{ $subscription->ends_at->format('F j, Y') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($planner->pm_last_four)
|
||||
<div class="flex justify-between items-center pb-4">
|
||||
<span class="text-gray-300">Payment method</span>
|
||||
<span class="text-white">
|
||||
<span class="text-gray-400">{{ ucfirst($planner->pm_type ?? 'Card') }}</span>
|
||||
•••• {{ $planner->pm_last_four }}
|
||||
<a href="{{ route('billing.portal') }}" class="ml-2 text-accent-blue hover:underline text-sm">Update</a>
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(!$subscription->ends_at)
|
||||
<div class="mt-6 pt-6 border-t border-gray-600">
|
||||
<button @click="showCancelModal = true" class="text-danger hover:text-red-400 text-sm transition-colors">
|
||||
Cancel subscription
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Confirmation Modal -->
|
||||
<div x-show="showCancelModal"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div @click.away="showCancelModal = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="bg-gray-700 border-2 border-secondary rounded-lg p-6 max-w-md mx-4">
|
||||
<h3 class="text-xl font-bold text-white mb-4">Cancel Subscription?</h3>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Are you sure you want to cancel your subscription? You will retain access until the end of your current billing period.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button @click="showCancelModal = false" class="px-4 py-2 text-gray-300 hover:text-white transition-colors">
|
||||
Keep subscription
|
||||
</button>
|
||||
<form action="{{ route('subscription.cancel') }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit" class="px-4 py-2 bg-danger hover:bg-red-600 text-white rounded transition-colors">
|
||||
Cancel subscription
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
@props([
|
||||
'variant' => 'primary',
|
||||
'type' => 'button',
|
||||
])
|
||||
|
||||
@php
|
||||
$baseClasses = 'px-4 py-2 rounded transition-colors duration-200 disabled:opacity-50';
|
||||
|
||||
$variantClasses = match($variant) {
|
||||
'primary' => 'bg-primary text-white hover:bg-secondary',
|
||||
'outline' => 'border-2 border-secondary text-gray-100 hover:bg-gray-700',
|
||||
'danger' => 'bg-danger text-white hover:bg-red-700',
|
||||
'danger-outline' => 'border-2 border-danger text-danger hover:bg-danger hover:text-white',
|
||||
default => 'bg-secondary text-white hover:bg-secondary',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<button type="{{ $type }}" {{ $attributes->merge(['class' => "$baseClasses $variantClasses"]) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
@props([
|
||||
'padding' => true,
|
||||
])
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'border-2 border-secondary rounded-lg bg-gray-650' . ($padding ? ' p-6' : '')]) }}>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
@props([
|
||||
'name',
|
||||
'label' => null,
|
||||
'checked' => false,
|
||||
])
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox"
|
||||
name="{{ $name }}"
|
||||
{{ $checked ? 'checked' : '' }}
|
||||
{{ $attributes }}>
|
||||
@if($label)
|
||||
<span class="ml-2 text-sm">{{ $label }}</span>
|
||||
@endif
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
@props([
|
||||
'type' => 'text',
|
||||
'name',
|
||||
'label' => null,
|
||||
'placeholder' => '',
|
||||
'value' => null,
|
||||
'required' => false,
|
||||
'autofocus' => false,
|
||||
])
|
||||
|
||||
<div class="mb-4">
|
||||
@if($label)
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-1">{{ $label }}</label>
|
||||
@endif
|
||||
|
||||
<input type="{{ $type }}"
|
||||
id="{{ $name }}"
|
||||
name="{{ $name }}"
|
||||
value="{{ old($name, $value) }}"
|
||||
placeholder="{{ $placeholder }}"
|
||||
{{ $required ? 'required' : '' }}
|
||||
{{ $autofocus ? 'autofocus' : '' }}
|
||||
{{ $attributes->merge(['class' => 'w-full p-2 border rounded bg-gray-700 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue' . ($errors->has($name) ? ' border-red-500' : '')]) }}>
|
||||
|
||||
@error($name)
|
||||
<span class="text-red-500 text-xs mt-1 block">{{ $message }}</span>
|
||||
@enderror
|
||||
</div>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-gray-600 text-gray-100" x-data="{ mobileMenuOpen: false }">
|
||||
<body class="font-sans antialiased bg-gray-600 text-gray-100">
|
||||
<div class="min-h-screen">
|
||||
<!-- Navigation -->
|
||||
<nav class="border-b-2 border-secondary shadow-sm z-50 mb-8 bg-gray-700">
|
||||
|
|
@ -20,31 +20,30 @@
|
|||
<div class="flex items-center">
|
||||
<!-- Logo -->
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a href="{{ route('dashboard') }}" class="flex items-center">
|
||||
<img src="{{ asset('images/logo-without-text.png') }}" alt="Dish Planner" class="h-8 mr-2">
|
||||
<span class="text-2xl font-syncopate text-primary relative top-[3px]">DISH PLANNER</span>
|
||||
<a href="{{ route('dashboard') }}" class="text-2xl font-syncopate text-primary">
|
||||
DISH PLANNER
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
@auth
|
||||
<div class="hidden space-x-8 sm:ml-10 sm:flex">
|
||||
<a href="{{ route('dashboard') }}"
|
||||
<a href="{{ route('dashboard') }}"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('dashboard') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ route('users.index') }}"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('users.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
|
||||
Users
|
||||
</a>
|
||||
<a href="{{ route('dishes.index') }}"
|
||||
<a href="{{ route('dishes.index') }}"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('dishes.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
|
||||
Dishes
|
||||
</a>
|
||||
<a href="{{ route('schedule.index') }}"
|
||||
<a href="{{ route('schedule.index') }}"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('schedule.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
|
||||
Schedule
|
||||
</a>
|
||||
<a href="{{ route('users.index') }}"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->routeIs('users.*') ? 'text-accent-blue' : 'text-gray-100 hover:text-accent-blue' }} transition">
|
||||
Users
|
||||
</a>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
|
|
@ -59,17 +58,12 @@ class="inline-flex items-center px-3 py-2 text-sm font-medium {{ request()->rout
|
|||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="open"
|
||||
<div x-show="open"
|
||||
@click.away="open = false"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-700 ring-1 ring-secondary">
|
||||
<div class="py-1">
|
||||
@if(is_mode_saas())
|
||||
<a href="{{ route('billing') }}" class="block px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue">
|
||||
Billing
|
||||
</a>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-600 hover:text-accent-blue">
|
||||
|
|
@ -88,86 +82,18 @@ class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-gray-
|
|||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="-mr-2 flex items-center sm:hidden">
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="inline-flex items-center justify-center p-2 rounded-md text-gray-100 hover:text-accent-blue hover:bg-gray-700 focus:outline-none focus:bg-gray-700 focus:text-accent-blue transition">
|
||||
<div class="-mr-2 flex items-center sm:hidden" x-data="{ open: false }">
|
||||
<button @click="open = !open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-100 hover:text-accent-blue hover:bg-gray-700 focus:outline-none focus:bg-gray-700 focus:text-accent-blue transition">
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path :class="{'hidden': mobileMenuOpen, 'inline-flex': !mobileMenuOpen }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{'hidden': !mobileMenuOpen, 'inline-flex': mobileMenuOpen }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
<path :class="{'hidden': open, 'inline-flex': !open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path :class="{'hidden': !open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu (full screen overlay) -->
|
||||
<div x-show="mobileMenuOpen"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="sm:hidden fixed inset-0 z-50 bg-gray-700">
|
||||
|
||||
<!-- Close button -->
|
||||
<div class="flex justify-end p-4">
|
||||
<button @click="mobileMenuOpen = false" class="p-2 text-gray-100 hover:text-accent-blue">
|
||||
<svg class="h-8 w-8" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu content -->
|
||||
<div class="flex flex-col items-center justify-center h-full -mt-16">
|
||||
@auth
|
||||
<div class="space-y-6 text-center">
|
||||
<a href="{{ route('dashboard') }}"
|
||||
class="block text-2xl font-medium {{ request()->routeIs('dashboard') ? 'text-accent-blue' : 'text-gray-100' }}">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ route('users.index') }}"
|
||||
class="block text-2xl font-medium {{ request()->routeIs('users.*') ? 'text-accent-blue' : 'text-gray-100' }}">
|
||||
Users
|
||||
</a>
|
||||
<a href="{{ route('dishes.index') }}"
|
||||
class="block text-2xl font-medium {{ request()->routeIs('dishes.*') ? 'text-accent-blue' : 'text-gray-100' }}">
|
||||
Dishes
|
||||
</a>
|
||||
<a href="{{ route('schedule.index') }}"
|
||||
class="block text-2xl font-medium {{ request()->routeIs('schedule.*') ? 'text-accent-blue' : 'text-gray-100' }}">
|
||||
Schedule
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 pt-6 border-t border-secondary text-center">
|
||||
<div class="text-gray-300 mb-4">{{ Auth::user()->name }}</div>
|
||||
@if(is_mode_saas())
|
||||
<a href="{{ route('billing') }}" class="block text-xl text-accent-blue mb-4">
|
||||
Billing
|
||||
</a>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-xl text-danger">
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-6 text-center">
|
||||
<a href="{{ route('login') }}" class="block text-2xl font-medium text-accent-blue">Login</a>
|
||||
@if (Route::has('register'))
|
||||
<a href="{{ route('register') }}" class="block text-2xl font-medium text-accent-blue">Register</a>
|
||||
@endif
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main>
|
||||
{{ $slot }}
|
||||
|
|
@ -175,60 +101,8 @@ class="block text-2xl font-medium {{ request()->routeIs('schedule.*') ? 'text-ac
|
|||
</div>
|
||||
|
||||
@livewireScripts
|
||||
|
||||
{{-- CSRF Token Auto-Refresh for Livewire --}}
|
||||
<script>
|
||||
// Handle CSRF token expiration gracefully
|
||||
Livewire.hook("request", ({ fail }) => {
|
||||
fail(async ({ status, preventDefault, retry }) => {
|
||||
if (status === 419) {
|
||||
// Prevent the default error handling
|
||||
preventDefault();
|
||||
|
||||
try {
|
||||
// Fetch a new CSRF token
|
||||
const response = await fetch("/refresh-csrf", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const newToken = data.token;
|
||||
|
||||
// Update the CSRF token in the meta tag
|
||||
const csrfMeta = document.querySelector("meta[name='csrf-token']");
|
||||
if (csrfMeta) {
|
||||
csrfMeta.setAttribute("content", newToken);
|
||||
}
|
||||
|
||||
// Update Livewire's CSRF token
|
||||
if (window.Livewire && Livewire.csrfToken) {
|
||||
Livewire.csrfToken = newToken;
|
||||
}
|
||||
|
||||
// Retry the original request with the new token
|
||||
retry();
|
||||
} else {
|
||||
// If we can't refresh the token, redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh CSRF token:', error);
|
||||
// Fallback: redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
@ -12,71 +12,18 @@
|
|||
@livewireStyles
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-gray-600 text-gray-100">
|
||||
<div class="min-h-screen flex flex-col items-center px-4 py-8">
|
||||
<!-- Logo -->
|
||||
<div class="w-full max-w-xs mb-8">
|
||||
<img src="{{ asset('images/logo-with-text.png') }}" alt="Dish Planner" class="w-full">
|
||||
</div>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center">
|
||||
<div class="lg:w-1/3 lg:mx-auto w-full px-4" style="margin-top: 15vh;">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-syncopate text-primary">DISH PLANNER</h1>
|
||||
</div>
|
||||
|
||||
<!-- Login box -->
|
||||
<div class="w-full max-w-sm">
|
||||
<x-card>
|
||||
@yield('content')
|
||||
</x-card>
|
||||
<div class="border-2 border-secondary rounded-lg px-5 pt-5 pb-3 lg:pt-10 lg:pb-7 bg-gray-600">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@livewireScripts
|
||||
|
||||
{{-- CSRF Token Auto-Refresh for Livewire --}}
|
||||
<script>
|
||||
// Handle CSRF token expiration gracefully
|
||||
Livewire.hook("request", ({ fail }) => {
|
||||
fail(async ({ status, preventDefault, retry }) => {
|
||||
if (status === 419) {
|
||||
// Prevent the default error handling
|
||||
preventDefault();
|
||||
|
||||
try {
|
||||
// Fetch a new CSRF token
|
||||
const response = await fetch("/refresh-csrf", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
},
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const newToken = data.token;
|
||||
|
||||
// Update the CSRF token in the meta tag
|
||||
const csrfMeta = document.querySelector("meta[name='csrf-token']");
|
||||
if (csrfMeta) {
|
||||
csrfMeta.setAttribute("content", newToken);
|
||||
}
|
||||
|
||||
// Update Livewire's CSRF token
|
||||
if (window.Livewire && Livewire.csrfToken) {
|
||||
Livewire.csrfToken = newToken;
|
||||
}
|
||||
|
||||
// Retry the original request with the new token
|
||||
retry();
|
||||
} else {
|
||||
console.error('Failed to refresh CSRF token');
|
||||
// For guest layout, just retry once more or show error
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh CSRF token:', error);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
@props([
|
||||
'name',
|
||||
'label' => null,
|
||||
'options' => [],
|
||||
'selected' => null,
|
||||
'required' => false,
|
||||
])
|
||||
|
||||
<div class="mb-4">
|
||||
@if($label)
|
||||
<label for="{{ $name }}" class="block text-sm font-medium mb-2">{{ $label }}</label>
|
||||
@endif
|
||||
|
||||
<select id="{{ $name }}"
|
||||
name="{{ $name }}"
|
||||
{{ $required ? 'required' : '' }}
|
||||
{{ $attributes->merge(['class' => 'w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue']) }}>
|
||||
@foreach($options as $value => $label)
|
||||
<option value="{{ $value }}" {{ $selected == $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
@error($name)
|
||||
<span class="text-danger text-xs mt-1 block">{{ $message }}</span>
|
||||
@enderror
|
||||
</div>
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
@props([
|
||||
'users',
|
||||
'selectedIds' => [],
|
||||
'wireModel' => null,
|
||||
'toggleAllMethod' => null,
|
||||
'label' => 'Users',
|
||||
])
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">{{ $label }}</label>
|
||||
<div class="space-y-2 max-h-40 overflow-y-auto p-2 bg-gray-700 rounded border border-secondary">
|
||||
@if($toggleAllMethod)
|
||||
<!-- Select All -->
|
||||
<label class="flex items-center cursor-pointer hover:bg-gray-600 p-1 rounded">
|
||||
<input type="checkbox"
|
||||
wire:click="{{ $toggleAllMethod }}"
|
||||
{{ count($selectedIds) === count($users) && count($users) > 0 ? 'checked' : '' }}
|
||||
class="mr-2">
|
||||
<span class="text-accent-blue font-medium">Select All</span>
|
||||
</label>
|
||||
<hr class="border-secondary">
|
||||
@endif
|
||||
<!-- Individual users -->
|
||||
@forelse($users as $user)
|
||||
<label class="flex items-center cursor-pointer hover:bg-gray-600 p-1 rounded">
|
||||
<input type="checkbox"
|
||||
@if($wireModel) wire:model.live="{{ $wireModel }}" @endif
|
||||
value="{{ $user->id }}"
|
||||
class="mr-2">
|
||||
<span>{{ $user->name }}</span>
|
||||
</label>
|
||||
@empty
|
||||
<p class="text-gray-400 text-sm italic p-1">No users available.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,30 +1,23 @@
|
|||
<x-layouts.app>
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
@if (session('success'))
|
||||
<div class="mb-6 border-2 border-success rounded-lg p-4 bg-success/10">
|
||||
<p class="text-success font-bold">Welcome to Dish Planner!</p>
|
||||
<p class="text-gray-100 text-sm">Your subscription is now active. Start planning your dishes!</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">DASHBOARD</h1>
|
||||
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">Welcome {{ auth()->user()->name }}!</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
|
||||
<h3 class="text-xl font-bold text-primary mb-2">Manage Users</h3>
|
||||
<p class="text-gray-100">Add and manage planner users</p>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('dishes.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
|
||||
<h3 class="text-xl font-bold text-accent-blue mb-2">Manage Dishes</h3>
|
||||
<h3 class="text-xl font-bold text-primary mb-2">Manage Dishes</h3>
|
||||
<p class="text-gray-100">Create and manage your dish collection</p>
|
||||
</a>
|
||||
|
||||
|
||||
<a href="{{ route('schedule.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
|
||||
<h3 class="text-xl font-bold text-success mb-2">View Schedule</h3>
|
||||
<h3 class="text-xl font-bold text-accent-blue mb-2">View Schedule</h3>
|
||||
<p class="text-gray-100">See your monthly dish schedule</p>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
|
||||
<h3 class="text-xl font-bold text-success mb-2">Manage Users</h3>
|
||||
<p class="text-gray-100">Add and manage planner users</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
45
resources/views/livewire/auth/login.blade.php
Normal file
45
resources/views/livewire/auth/login.blade.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<div>
|
||||
<h2 class="text-2xl text-center text-accent-blue mb-6">Login</h2>
|
||||
|
||||
<form wire:submit="login">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-2">Email</label>
|
||||
<input wire:model="email"
|
||||
type="email"
|
||||
id="email"
|
||||
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter your email"
|
||||
autofocus>
|
||||
@error('email') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-2">Password</label>
|
||||
<input wire:model="password"
|
||||
type="password"
|
||||
id="password"
|
||||
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter your password">
|
||||
@error('password') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input wire:model="remember"
|
||||
type="checkbox"
|
||||
class="rounded border-secondary bg-gray-600 text-primary focus:ring-accent-blue">
|
||||
<span class="ml-2 text-sm">Remember me</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full py-2 px-4 bg-primary text-white text-xl rounded hover:bg-secondary transition-colors duration-200 mb-4">
|
||||
Login
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ route('register') }}" class="text-accent-blue hover:underline text-sm">
|
||||
Don't have an account? Register here
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
56
resources/views/livewire/auth/register.blade.php
Normal file
56
resources/views/livewire/auth/register.blade.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<div>
|
||||
<h2 class="text-2xl text-center text-accent-blue mb-6">Register</h2>
|
||||
|
||||
<form wire:submit="register">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium mb-2">Name</label>
|
||||
<input wire:model="name"
|
||||
type="text"
|
||||
id="name"
|
||||
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter your name"
|
||||
autofocus>
|
||||
@error('name') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium mb-2">Email</label>
|
||||
<input wire:model="email"
|
||||
type="email"
|
||||
id="email"
|
||||
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter your email">
|
||||
@error('email') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium mb-2">Password</label>
|
||||
<input wire:model="password"
|
||||
type="password"
|
||||
id="password"
|
||||
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter your password">
|
||||
@error('password') <span class="text-danger text-xs block -mt-2 mb-2">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirmation" class="block text-sm font-medium mb-2">Confirm Password</label>
|
||||
<input wire:model="password_confirmation"
|
||||
type="password"
|
||||
id="password_confirmation"
|
||||
class="w-full p-2 mb-4 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Confirm your password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full py-2 px-4 bg-primary text-white text-xl rounded hover:bg-secondary transition-colors duration-200 mb-4">
|
||||
Register
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ route('login') }}" class="text-accent-blue hover:underline text-sm">
|
||||
Already have an account? Login here
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -83,28 +83,34 @@ class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focu
|
|||
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
@if($users->count() > 0)
|
||||
<x-user-multi-select
|
||||
:users="$users"
|
||||
:selectedIds="$selectedUsers"
|
||||
wireModel="selectedUsers"
|
||||
toggleAllMethod="toggleAllUsers"
|
||||
label="Assign to Users"
|
||||
/>
|
||||
@error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
@else
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-400 text-sm italic">No users available to assign. <a href="{{ route('users.index') }}" class="text-accent-blue hover:underline">Add users</a> to assign them to dishes.</p>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Assign to Users</label>
|
||||
<div class="space-y-2 max-h-40 overflow-y-auto border border-secondary rounded p-3 bg-gray-700">
|
||||
@foreach($users as $user)
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
wire:model="selectedUsers"
|
||||
value="{{ $user->id }}"
|
||||
class="rounded border-secondary bg-gray-600 text-primary focus:ring-accent-blue mr-2">
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold mr-2">
|
||||
{{ strtoupper(substr($user->name, 0, 1)) }}
|
||||
</div>
|
||||
{{ $user->name }}
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
wire:click="cancel"
|
||||
<button type="button"
|
||||
wire:click="cancel"
|
||||
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
|
||||
Create Dish
|
||||
</button>
|
||||
|
|
@ -129,28 +135,34 @@ class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focu
|
|||
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
@if($users->count() > 0)
|
||||
<x-user-multi-select
|
||||
:users="$users"
|
||||
:selectedIds="$selectedUsers"
|
||||
wireModel="selectedUsers"
|
||||
toggleAllMethod="toggleAllUsers"
|
||||
label="Assign to Users"
|
||||
/>
|
||||
@error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
@else
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-400 text-sm italic">No users available to assign. <a href="{{ route('users.index') }}" class="text-accent-blue hover:underline">Add users</a> to assign them to dishes.</p>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Assign to Users</label>
|
||||
<div class="space-y-2 max-h-40 overflow-y-auto border border-secondary rounded p-3 bg-gray-700">
|
||||
@foreach($users as $user)
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
wire:model="selectedUsers"
|
||||
value="{{ $user->id }}"
|
||||
class="rounded border-secondary bg-gray-600 text-primary focus:ring-accent-blue mr-2">
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-white text-xs font-bold mr-2">
|
||||
{{ strtoupper(substr($user->name, 0, 1)) }}
|
||||
</div>
|
||||
{{ $user->name }}
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@error('selectedUsers') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
wire:click="cancel"
|
||||
<button type="button"
|
||||
wire:click="cancel"
|
||||
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
|
||||
Update Dish
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -32,159 +32,67 @@ class="px-4 py-2 bg-gray-700 text-accent-blue rounded hover:bg-gray-600 transiti
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid - Desktop -->
|
||||
<div class="hidden md:block">
|
||||
<div class="grid grid-cols-7 gap-2 mb-4">
|
||||
<!-- Days of week headers -->
|
||||
@foreach(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as $day)
|
||||
<div class="text-center text-accent-blue font-bold p-2">{{ $day }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
@foreach($calendarDays as $dayData)
|
||||
<div class="min-h-[120px] border rounded-lg p-2
|
||||
{{ $dayData['day'] ? 'bg-gray-500' : 'bg-gray-800' }}
|
||||
{{ $dayData['isToday'] ? 'border-2 border-accent-blue' : 'border-gray-600' }}">
|
||||
|
||||
@if($dayData['day'])
|
||||
<!-- Day number and add button -->
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="font-bold {{ $dayData['isToday'] ? 'text-accent-blue' : 'text-gray-100' }}">
|
||||
{{ $dayData['day'] }}
|
||||
</span>
|
||||
<button wire:click="openAddDishModal('{{ $dayData['date']->format('Y-m-d') }}')"
|
||||
class="w-5 h-5 bg-gray-600 hover:bg-primary text-gray-300 hover:text-white rounded flex items-center justify-center text-sm transition-colors duration-200">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled dishes -->
|
||||
@if($dayData['scheduledDishes']->isNotEmpty())
|
||||
<div class="space-y-1">
|
||||
@foreach($dayData['scheduledDishes'] as $scheduled)
|
||||
<div class="bg-primary rounded p-1 text-xs text-white flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-4 h-4 bg-white text-primary rounded-full flex items-center justify-center text-xs font-bold mr-1">
|
||||
{{ strtoupper(substr($scheduled->user->name, 0, 1)) }}
|
||||
</div>
|
||||
<span class="truncate">{{ $scheduled->userDish?->dish?->name ?? 'Skipped' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex space-x-1" x-data="{ showActions: false }">
|
||||
<button @click="showActions = !showActions"
|
||||
class="text-white hover:text-gray-300">
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<div x-show="showActions"
|
||||
@click.away="showActions = false"
|
||||
x-cloak
|
||||
class="absolute bg-gray-700 border border-secondary rounded mt-4 ml-4 shadow-lg z-10">
|
||||
<button wire:click="editDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-white">
|
||||
Edit
|
||||
</button>
|
||||
<button wire:click="regenerateForUserDate('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-accent-blue">
|
||||
Regenerate
|
||||
</button>
|
||||
<button wire:click="skipDay('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-warning">
|
||||
Skip
|
||||
</button>
|
||||
<button wire:click="removeDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-danger">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-gray-400 text-xs">No dishes scheduled</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<!-- Calendar Grid -->
|
||||
<div class="grid grid-cols-7 gap-2 mb-4">
|
||||
<!-- Days of week headers -->
|
||||
@foreach(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as $day)
|
||||
<div class="text-center text-accent-blue font-bold p-2">{{ $day }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<!-- Calendar List - Mobile -->
|
||||
<div class="md:hidden space-y-2">
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
@foreach($calendarDays as $dayData)
|
||||
@if($dayData['day'])
|
||||
<div class="border rounded-lg p-3
|
||||
{{ $dayData['isToday'] ? 'border-2 border-accent-blue bg-gray-600' : 'border-gray-600 bg-gray-500' }}">
|
||||
|
||||
<!-- Day header -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="font-bold {{ $dayData['isToday'] ? 'text-accent-blue' : 'text-gray-100' }}">
|
||||
{{ $dayData['date']->format('D, M j') }}
|
||||
@if($dayData['isToday'])
|
||||
<span class="text-xs ml-2">(Today)</span>
|
||||
@endif
|
||||
</div>
|
||||
<button wire:click="openAddDishModal('{{ $dayData['date']->format('Y-m-d') }}')"
|
||||
class="w-7 h-7 bg-gray-600 hover:bg-primary text-gray-300 hover:text-white rounded flex items-center justify-center text-lg transition-colors duration-200">
|
||||
+
|
||||
</button>
|
||||
<div class="min-h-[120px] border rounded-lg p-2
|
||||
{{ $dayData['day'] ? 'bg-gray-500' : 'bg-gray-800' }}
|
||||
{{ $dayData['isToday'] ? 'border-2 border-accent-blue' : 'border-gray-600' }}">
|
||||
|
||||
@if($dayData['day'])
|
||||
<!-- Day number -->
|
||||
<div class="font-bold mb-2 {{ $dayData['isToday'] ? 'text-accent-blue' : 'text-gray-100' }}">
|
||||
{{ $dayData['day'] }}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Scheduled dishes -->
|
||||
@if($dayData['scheduledDishes']->isNotEmpty())
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
@foreach($dayData['scheduledDishes'] as $scheduled)
|
||||
<div class="bg-primary rounded p-2 text-sm text-white flex items-center justify-between">
|
||||
<div class="bg-primary rounded p-1 text-xs text-white flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-6 h-6 bg-white text-primary rounded-full flex items-center justify-center text-sm font-bold mr-2">
|
||||
<div class="w-4 h-4 bg-white text-primary rounded-full flex items-center justify-center text-xs font-bold mr-1">
|
||||
{{ strtoupper(substr($scheduled->user->name, 0, 1)) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{{ $scheduled->userDish?->dish?->name ?? 'Skipped' }}</div>
|
||||
<div class="text-xs opacity-75">{{ $scheduled->user->name }}</div>
|
||||
</div>
|
||||
<span class="truncate">{{ $scheduled->dish->name }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex space-x-2" x-data="{ showActions: false }">
|
||||
<button @click="showActions = !showActions"
|
||||
class="text-white hover:text-gray-300 p-1">
|
||||
<div class="flex space-x-1" x-data="{ showActions: false }">
|
||||
<button @click="showActions = !showActions"
|
||||
class="text-white hover:text-gray-300">
|
||||
⋮
|
||||
</button>
|
||||
|
||||
<div x-show="showActions"
|
||||
|
||||
<div x-show="showActions"
|
||||
@click.away="showActions = false"
|
||||
x-cloak
|
||||
class="absolute right-4 bg-gray-700 border border-secondary rounded shadow-lg z-10">
|
||||
<button wire:click="editDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-white">
|
||||
Edit
|
||||
</button>
|
||||
class="absolute bg-gray-700 border border-secondary rounded mt-4 ml-4 shadow-lg z-10">
|
||||
<button wire:click="regenerateForUserDate('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-accent-blue">
|
||||
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-accent-blue">
|
||||
Regenerate
|
||||
</button>
|
||||
<button wire:click="skipDay('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-warning">
|
||||
class="block w-full text-left px-3 py-1 text-xs hover:bg-gray-600 text-danger">
|
||||
Skip
|
||||
</button>
|
||||
<button wire:click="removeDish('{{ $dayData['date']->format('Y-m-d') }}', {{ $scheduled->user->id }})"
|
||||
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-danger">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-gray-400 text-sm">No dishes scheduled</div>
|
||||
<div class="text-gray-400 text-xs">No dishes scheduled</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
|
|
@ -197,13 +105,13 @@ class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-600 text-danger">
|
|||
<p class="text-gray-100 mb-6">
|
||||
This will clear the selected day and allow for regeneration. Continue?
|
||||
</p>
|
||||
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button wire:click="cancel"
|
||||
<button wire:click="cancel"
|
||||
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="confirmRegenerate"
|
||||
<button wire:click="confirmRegenerate"
|
||||
class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-colors duration-200">
|
||||
Regenerate
|
||||
</button>
|
||||
|
|
@ -212,103 +120,6 @@ class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-co
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Edit Dish Modal -->
|
||||
@if($showEditDishModal)
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-600 border-2 border-secondary rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 class="text-xl text-accent-blue mb-4">Edit Dish</h2>
|
||||
<p class="text-gray-300 text-sm mb-4">
|
||||
Choose a dish for <strong>{{ \App\Models\User::find($editUserId)?->name }}</strong> on {{ \Carbon\Carbon::parse($editDate)->format('M j, Y') }}
|
||||
</p>
|
||||
|
||||
@if(count($availableDishes) > 0)
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Dish</label>
|
||||
<select wire:model="selectedDishId"
|
||||
class="w-full p-2 border rounded bg-gray-700 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
|
||||
<option value="">Select a dish...</option>
|
||||
@foreach($availableDishes as $dish)
|
||||
<option value="{{ $dish->id }}">{{ $dish->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@else
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-400 text-sm italic">
|
||||
No dishes available for this user.
|
||||
<a href="{{ route('dishes.index') }}" class="text-accent-blue hover:underline">Add dishes</a> first.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button wire:click="cancel"
|
||||
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
|
||||
Cancel
|
||||
</button>
|
||||
@if(count($availableDishes) > 0)
|
||||
<button wire:click="saveDish"
|
||||
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
|
||||
Save
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Add Dish Modal -->
|
||||
@if($showAddDishModal)
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-600 border-2 border-secondary rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 class="text-xl text-accent-blue mb-4">Add Dish</h2>
|
||||
<p class="text-gray-300 text-sm mb-4">
|
||||
Add a dish for {{ \Carbon\Carbon::parse($addDate)->format('M j, Y') }}
|
||||
</p>
|
||||
|
||||
<x-user-multi-select
|
||||
:users="$addAvailableUsers"
|
||||
:selectedIds="$addUserIds"
|
||||
wireModel="addUserIds"
|
||||
toggleAllMethod="toggleAllUsers"
|
||||
/>
|
||||
|
||||
@if(count($addUserIds) > 0)
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Dish</label>
|
||||
@if(count($addAvailableDishes) > 0)
|
||||
<select wire:model="addSelectedDishId"
|
||||
class="w-full p-2 border rounded bg-gray-700 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
|
||||
<option value="">Select a dish...</option>
|
||||
@foreach($addAvailableDishes as $dish)
|
||||
<option value="{{ $dish->id }}">{{ $dish->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<p class="text-gray-400 text-sm italic">
|
||||
No dishes in common for selected users.
|
||||
<a href="{{ route('dishes.index') }}" class="text-accent-blue hover:underline">Add dishes</a> first.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button wire:click="cancel"
|
||||
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
|
||||
Cancel
|
||||
</button>
|
||||
@if(count($addAvailableUsers) > 0 && count($addAvailableDishes) > 0)
|
||||
<button wire:click="saveAddDish"
|
||||
class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-colors duration-200">
|
||||
Add
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
<div class="bg-gray-700 border-2 border-secondary rounded-lg p-6 mb-6" x-data="{ open: window.innerWidth >= 768 }">
|
||||
<button @click="open = !open" class="w-full flex justify-between items-center md:cursor-default">
|
||||
<h3 class="text-lg font-bold text-accent-blue">Generate Schedule</h3>
|
||||
<span class="md:hidden text-accent-blue" x-text="open ? '−' : '+'"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-collapse class="mt-4 md:!block" x-cloak>
|
||||
<div class="bg-gray-700 border-2 border-secondary rounded-lg p-6 mb-6">
|
||||
<h3 class="text-lg font-bold text-accent-blue mb-4">Generate Schedule</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<!-- Month Selection -->
|
||||
<div>
|
||||
|
|
@ -90,8 +86,8 @@ class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-col
|
|||
<span wire:loading wire:target="generate">Generating...</span>
|
||||
</button>
|
||||
|
||||
<button wire:click="clearMonth"
|
||||
class="px-4 py-2 border-2 border-danger text-danger rounded hover:bg-danger hover:text-white transition-colors duration-200">
|
||||
<button wire:click="clearMonth"
|
||||
class="px-4 py-2 bg-danger text-white rounded hover:bg-red-700 transition-colors duration-200">
|
||||
Clear Month
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -106,5 +102,4 @@ class="px-4 py-2 border-2 border-danger text-danger rounded hover:bg-danger hove
|
|||
Generating schedule...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -30,20 +30,21 @@ class="py-2 px-4 bg-primary text-white text-xl rounded hover:bg-secondary transi
|
|||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-100">{{ $user->name }}</h3>
|
||||
<p class="text-gray-300">{{ $user->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button wire:click="edit({{ $user->id }})"
|
||||
data-testid="user-edit-{{ $user->id }}"
|
||||
class="px-3 py-1 bg-accent-blue text-gray-900 rounded hover:bg-secondary transition-colors duration-200">
|
||||
Edit
|
||||
</button>
|
||||
<button wire:click="confirmDelete({{ $user->id }})"
|
||||
data-testid="user-delete-{{ $user->id }}"
|
||||
class="px-3 py-1 bg-danger text-white rounded hover:bg-red-700 transition-colors duration-200">
|
||||
Delete
|
||||
</button>
|
||||
@if($user->id !== auth()->id())
|
||||
<button wire:click="confirmDelete({{ $user->id }})"
|
||||
class="px-3 py-1 bg-danger text-white rounded hover:bg-red-700 transition-colors duration-200">
|
||||
Delete
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
|
|
@ -65,7 +66,7 @@ class="px-3 py-1 bg-danger text-white rounded hover:bg-red-700 transition-colors
|
|||
<h2 class="text-xl text-accent-blue mb-4">Add New User</h2>
|
||||
|
||||
<form wire:submit="store">
|
||||
<div class="mb-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Name</label>
|
||||
<input wire:model="name"
|
||||
type="text"
|
||||
|
|
@ -74,6 +75,32 @@ class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focu
|
|||
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Email</label>
|
||||
<input wire:model="email"
|
||||
type="email"
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter email">
|
||||
@error('email') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Password</label>
|
||||
<input wire:model="password"
|
||||
type="password"
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter password">
|
||||
@error('password') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Confirm Password</label>
|
||||
<input wire:model="password_confirmation"
|
||||
type="password"
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Confirm password">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
wire:click="cancel"
|
||||
|
|
@ -97,15 +124,39 @@ class="px-4 py-2 bg-primary text-white rounded hover:bg-secondary transition-col
|
|||
<h2 class="text-xl text-accent-blue mb-4">Edit User</h2>
|
||||
|
||||
<form wire:submit="update">
|
||||
<div class="mb-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Name</label>
|
||||
<input wire:model="name"
|
||||
type="text"
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Enter name">
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
|
||||
@error('name') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">Email</label>
|
||||
<input wire:model="email"
|
||||
type="email"
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
|
||||
@error('email') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">New Password (leave blank to keep current)</label>
|
||||
<input wire:model="password"
|
||||
type="password"
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="New password (optional)">
|
||||
@error('password') <span class="text-danger text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-2">Confirm New Password</label>
|
||||
<input wire:model="password_confirmation"
|
||||
type="password"
|
||||
class="w-full p-2 border rounded bg-gray-600 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue"
|
||||
placeholder="Confirm new password">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
wire:click="cancel"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<x-layouts.app>
|
||||
<div class="px-4 sm:px-6 lg:px-8 pb-12">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<livewire:schedule.schedule-calendar />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
<x-layouts.app>
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">SUBSCRIPTION</h1>
|
||||
|
||||
@if(auth()->user()->subscribed())
|
||||
<div class="border-2 border-success rounded-lg p-6 mb-6">
|
||||
<h3 class="text-xl font-bold text-success mb-2">Active Subscription</h3>
|
||||
<p class="text-gray-100 mb-4">You have an active subscription.</p>
|
||||
|
||||
<form action="{{ route('subscription.cancel') }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded transition-colors duration-200">
|
||||
Cancel Subscription
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<div class="border-2 border-secondary rounded-lg p-6">
|
||||
<h3 class="text-xl font-bold text-primary mb-4">Subscribe to Dish Planner</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<form action="{{ route('subscription.checkout') }}" method="POST">
|
||||
@csrf
|
||||
<input type="hidden" name="plan" value="monthly">
|
||||
<div class="border border-gray-600 rounded-lg p-4 hover:border-accent-blue transition-colors">
|
||||
<h4 class="text-lg font-bold text-white mb-2">Monthly</h4>
|
||||
<p class="text-gray-300 mb-4">Billed monthly</p>
|
||||
<button type="submit" class="w-full bg-accent-blue hover:bg-blue-600 text-white px-4 py-2 rounded font-bold transition-colors duration-200">
|
||||
Subscribe Monthly
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action="{{ route('subscription.checkout') }}" method="POST">
|
||||
@csrf
|
||||
<input type="hidden" name="plan" value="yearly">
|
||||
<div class="border border-gray-600 rounded-lg p-4 hover:border-success transition-colors">
|
||||
<h4 class="text-lg font-bold text-white mb-2">Yearly</h4>
|
||||
<p class="text-gray-300 mb-4">Billed annually</p>
|
||||
<button type="submit" class="w-full bg-success hover:bg-green-600 text-white px-4 py-2 rounded font-bold transition-colors duration-200">
|
||||
Subscribe Yearly
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Auth\RegisterController;
|
||||
use App\Http\Controllers\SubscriptionController;
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Auth\Register;
|
||||
|
||||
Route::get('/', function () {
|
||||
return redirect()->route('dashboard');
|
||||
|
|
@ -11,40 +10,33 @@
|
|||
|
||||
// Guest routes
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login');
|
||||
Route::post('/login', [LoginController::class, 'login']);
|
||||
Route::get('/register', [RegisterController::class, 'showRegistrationForm'])->name('register');
|
||||
Route::post('/register', [RegisterController::class, 'register']);
|
||||
Route::get('/login', Login::class)->name('login');
|
||||
Route::get('/register', Register::class)->name('register');
|
||||
});
|
||||
|
||||
// CSRF refresh route (available to both guest and authenticated users)
|
||||
Route::get('/refresh-csrf', function () {
|
||||
return response()->json(['token' => csrf_token()]);
|
||||
})->name('refresh-csrf');
|
||||
|
||||
// Authenticated routes
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
||||
|
||||
// Routes requiring active subscription in SaaS mode
|
||||
Route::middleware('subscription')->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard');
|
||||
})->name('dashboard');
|
||||
|
||||
Route::get('/dishes', function () {
|
||||
return view('dishes.index');
|
||||
})->name('dishes.index');
|
||||
|
||||
Route::get('/schedule', function () {
|
||||
return view('schedule.index');
|
||||
})->name('schedule.index');
|
||||
|
||||
Route::get('/users', function () {
|
||||
return view('users.index');
|
||||
})->name('users.index');
|
||||
|
||||
Route::get('/billing', [SubscriptionController::class, 'billing'])->name('billing')->middleware('saas');
|
||||
Route::get('/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('billing.portal')->middleware('saas');
|
||||
});
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard');
|
||||
})->name('dashboard');
|
||||
|
||||
Route::post('/logout', function () {
|
||||
auth()->logout();
|
||||
request()->session()->invalidate();
|
||||
request()->session()->regenerateToken();
|
||||
return redirect('/');
|
||||
})->name('logout');
|
||||
|
||||
// Placeholder routes for future Livewire components
|
||||
Route::get('/dishes', function () {
|
||||
return view('dishes.index');
|
||||
})->name('dishes.index');
|
||||
|
||||
Route::get('/schedule', function () {
|
||||
return view('schedule.index');
|
||||
})->name('schedule.index');
|
||||
|
||||
Route::get('/users', function () {
|
||||
return view('users.index');
|
||||
})->name('users.index');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\SubscriptionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Cashier\Http\Controllers\WebhookController;
|
||||
|
||||
// Stripe webhook (no auth, CSRF excluded in bootstrap/app.php)
|
||||
Route::post('/stripe/webhook', [WebhookController::class, 'handleWebhook'])->name('cashier.webhook');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/subscription', function () {
|
||||
return view('subscription.index');
|
||||
})->name('subscription.index');
|
||||
|
||||
Route::post('/subscription/checkout', [SubscriptionController::class, 'checkout'])->name('subscription.checkout');
|
||||
Route::get('/subscription/success', [SubscriptionController::class, 'success'])->name('subscription.success');
|
||||
Route::post('/subscription/cancel', [SubscriptionController::class, 'cancel'])->name('subscription.cancel');
|
||||
});
|
||||
17
shell.nix
17
shell.nix
|
|
@ -14,7 +14,7 @@ pkgs.mkShell {
|
|||
podman-compose
|
||||
|
||||
# Database client (optional, for direct DB access)
|
||||
mariadb.client
|
||||
mariadb-client
|
||||
|
||||
# Utilities
|
||||
git
|
||||
|
|
@ -88,7 +88,7 @@ pkgs.mkShell {
|
|||
local REGISTRY="codeberg.org"
|
||||
local NAMESPACE="lvl0"
|
||||
local IMAGE_NAME="dish-planner"
|
||||
|
||||
|
||||
echo "🔨 Building production image..."
|
||||
podman build -f Dockerfile -t ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG} .
|
||||
|
||||
|
|
@ -112,19 +112,6 @@ pkgs.mkShell {
|
|||
fi
|
||||
}
|
||||
|
||||
prod-build-nc() {
|
||||
local TAG="''${1:-latest}"
|
||||
local REGISTRY="codeberg.org"
|
||||
local NAMESPACE="lvl0"
|
||||
local IMAGE_NAME="dish-planner"
|
||||
|
||||
echo "🔨 Building production image (no cache)..."
|
||||
podman build --no-cache -f Dockerfile -t ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG} .
|
||||
|
||||
echo "✅ Build complete: ''${REGISTRY}/''${NAMESPACE}/''${IMAGE_NAME}:''${TAG}"
|
||||
echo "Run 'prod-push' to push to Codeberg"
|
||||
}
|
||||
|
||||
prod-build-push() {
|
||||
local TAG="''${1:-latest}"
|
||||
prod-build "$TAG" && prod-push "$TAG"
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace DishPlanner\Schedule\Actions;
|
||||
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ClearScheduleForMonthAction
|
||||
{
|
||||
public function execute(Planner $planner, int $month, int $year, array $userIds): void
|
||||
{
|
||||
$startDate = Carbon::createFromDate($year, $month, 1)->startOfDay();
|
||||
$endDate = $startDate->copy()->endOfMonth()->endOfDay();
|
||||
|
||||
$scheduleIds = Schedule::withoutGlobalScopes()
|
||||
->where('planner_id', $planner->id)
|
||||
->whereBetween('date', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
|
||||
->pluck('id');
|
||||
|
||||
ScheduledUserDish::whereIn('schedule_id', $scheduleIds)
|
||||
->whereIn('user_id', $userIds)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace DishPlanner\Schedule\Actions;
|
||||
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GenerateScheduleForMonthAction
|
||||
{
|
||||
public function execute(
|
||||
Planner $planner,
|
||||
int $month,
|
||||
int $year,
|
||||
array $userIds,
|
||||
bool $clearExisting = true
|
||||
): void {
|
||||
DB::transaction(function () use ($planner, $month, $year, $userIds, $clearExisting) {
|
||||
$startDate = Carbon::createFromDate($year, $month, 1);
|
||||
$endDate = $startDate->copy()->endOfMonth();
|
||||
|
||||
if ($clearExisting) {
|
||||
$this->clearExistingSchedules($planner, $startDate, $endDate, $userIds);
|
||||
}
|
||||
|
||||
$userDishesMap = $this->loadUserDishes($planner, $userIds);
|
||||
|
||||
$this->generateSchedulesForPeriod($planner, $startDate, $endDate, $userIds, $userDishesMap);
|
||||
});
|
||||
}
|
||||
|
||||
private function clearExistingSchedules(
|
||||
Planner $planner,
|
||||
Carbon $startDate,
|
||||
Carbon $endDate,
|
||||
array $userIds
|
||||
): void {
|
||||
$scheduleIds = Schedule::withoutGlobalScopes()
|
||||
->where('planner_id', $planner->id)
|
||||
->whereBetween('date', [$startDate, $endDate])
|
||||
->pluck('id');
|
||||
|
||||
ScheduledUserDish::whereIn('schedule_id', $scheduleIds)
|
||||
->whereIn('user_id', $userIds)
|
||||
->delete();
|
||||
}
|
||||
|
||||
private function loadUserDishes(Planner $planner, array $userIds): array
|
||||
{
|
||||
$users = User::query()
|
||||
->with('userDishes.dish')
|
||||
->whereIn('id', $userIds)
|
||||
->where('planner_id', $planner->id)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$userDishesMap = [];
|
||||
foreach ($users as $userId => $user) {
|
||||
if ($user->userDishes->isNotEmpty()) {
|
||||
$userDishesMap[$userId] = $user->userDishes;
|
||||
}
|
||||
}
|
||||
|
||||
return $userDishesMap;
|
||||
}
|
||||
|
||||
private function generateSchedulesForPeriod(
|
||||
Planner $planner,
|
||||
Carbon $startDate,
|
||||
Carbon $endDate,
|
||||
array $userIds,
|
||||
array $userDishesMap
|
||||
): void {
|
||||
$currentDate = $startDate->copy();
|
||||
|
||||
while ($currentDate <= $endDate) {
|
||||
$schedule = Schedule::firstOrCreate(
|
||||
['planner_id' => $planner->id, 'date' => $currentDate->format('Y-m-d')],
|
||||
['is_skipped' => false]
|
||||
);
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
if (!isset($userDishesMap[$userId]) || $userDishesMap[$userId]->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$randomUserDish = $userDishesMap[$userId]->random();
|
||||
|
||||
ScheduledUserDish::firstOrCreate(
|
||||
['schedule_id' => $schedule->id, 'user_id' => $userId],
|
||||
['user_dish_id' => $randomUserDish->id, 'is_skipped' => false]
|
||||
);
|
||||
}
|
||||
|
||||
$currentDate->addDay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace DishPlanner\Schedule\Actions;
|
||||
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateScheduleForDateForUsersAction
|
||||
{
|
||||
public function execute(Planner $planner, Carbon $date, array $userIds): void
|
||||
{
|
||||
DB::transaction(function () use ($planner, $date, $userIds) {
|
||||
$schedule = Schedule::firstOrCreate(
|
||||
['planner_id' => $planner->id, 'date' => $date->format('Y-m-d')],
|
||||
['is_skipped' => false]
|
||||
);
|
||||
|
||||
ScheduledUserDish::where('schedule_id', $schedule->id)
|
||||
->whereIn('user_id', $userIds)
|
||||
->delete();
|
||||
|
||||
$users = User::with('userDishes.dish')
|
||||
->whereIn('id', $userIds)
|
||||
->where('planner_id', $planner->id)
|
||||
->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($user->userDishes->isNotEmpty()) {
|
||||
$randomUserDish = $user->userDishes->random();
|
||||
|
||||
ScheduledUserDish::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'user_id' => $user->id,
|
||||
'user_dish_id' => $randomUserDish->id,
|
||||
'is_skipped' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace DishPlanner\Schedule\Services;
|
||||
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ScheduleCalendarService
|
||||
{
|
||||
public function getCalendarDays(Planner $planner, int $month, int $year): array
|
||||
{
|
||||
$firstDay = Carbon::createFromDate($year, $month, 1);
|
||||
$lastDay = $firstDay->copy()->endOfMonth();
|
||||
$daysInMonth = $firstDay->daysInMonth;
|
||||
|
||||
$schedules = $this->loadSchedulesForMonth($planner, $firstDay, $lastDay);
|
||||
|
||||
return $this->buildCalendarDays($year, $month, $daysInMonth, $schedules);
|
||||
}
|
||||
|
||||
private function loadSchedulesForMonth(Planner $planner, Carbon $startDate, Carbon $endDate): Collection
|
||||
{
|
||||
return Schedule::with(['scheduledUserDishes.user', 'scheduledUserDishes.userDish.dish'])
|
||||
->where('planner_id', $planner->id)
|
||||
->whereBetween('date', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
|
||||
->get()
|
||||
->keyBy(fn ($schedule) => $schedule->date->day);
|
||||
}
|
||||
|
||||
private function buildCalendarDays(int $year, int $month, int $daysInMonth, Collection $schedules): array
|
||||
{
|
||||
$calendarDays = [];
|
||||
|
||||
for ($day = 1; $day <= 31; $day++) {
|
||||
if ($day <= $daysInMonth) {
|
||||
$date = Carbon::createFromDate($year, $month, $day);
|
||||
$scheduledDishes = $schedules->get($day)?->scheduledUserDishes ?? collect();
|
||||
|
||||
$calendarDays[] = [
|
||||
'day' => $day,
|
||||
'date' => $date,
|
||||
'isToday' => $date->isToday(),
|
||||
'scheduledDishes' => $scheduledDishes,
|
||||
'isEmpty' => $scheduledDishes->isEmpty()
|
||||
];
|
||||
} else {
|
||||
$calendarDays[] = [
|
||||
'day' => null,
|
||||
'date' => null,
|
||||
'isToday' => false,
|
||||
'scheduledDishes' => collect(),
|
||||
'isEmpty' => true
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $calendarDays;
|
||||
}
|
||||
|
||||
public function getMonthName(int $month, int $year): string
|
||||
{
|
||||
return Carbon::createFromDate($year, $month, 1)->format('F Y');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace DishPlanner\ScheduledUserDish\Actions;
|
||||
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class DeleteScheduledUserDishForDateAction
|
||||
{
|
||||
public function execute(Planner $planner, Carbon $date, int $userId): bool
|
||||
{
|
||||
$schedule = Schedule::query()
|
||||
->where('planner_id', $planner->id)
|
||||
->whereDate('date', $date)
|
||||
->first();
|
||||
|
||||
if (! $schedule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ScheduledUserDish::query()
|
||||
->where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->delete() > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace DishPlanner\ScheduledUserDish\Actions;
|
||||
|
||||
use App\Models\Planner;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class SkipScheduledUserDishForDateAction
|
||||
{
|
||||
public function execute(Planner $planner, Carbon $date, int $userId): bool
|
||||
{
|
||||
$schedule = Schedule::query()
|
||||
->where('planner_id', $planner->id)
|
||||
->whereDate('date', $date)
|
||||
->first();
|
||||
|
||||
if (! $schedule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheduledUserDish = ScheduledUserDish::query()
|
||||
->where('schedule_id', $schedule->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $scheduledUserDish) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheduledUserDish->update([
|
||||
'is_skipped' => true,
|
||||
'user_dish_id' => null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,6 @@ export default {
|
|||
400: '#444760',
|
||||
500: '#2B2C41',
|
||||
600: '#24263C',
|
||||
650: '#202239',
|
||||
700: '#1D1E36',
|
||||
800: '#131427',
|
||||
900: '#0A0B1C',
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Auth;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use App\Models\Planner;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class LoginTest extends DuskTestCase
|
||||
{
|
||||
protected static $testPlanner = null;
|
||||
protected static $testEmail = null;
|
||||
protected static $testPassword = 'password';
|
||||
|
||||
protected function ensureTestPlannerExists(): void
|
||||
{
|
||||
if (self::$testPlanner === null) {
|
||||
// Generate unique email for this test run
|
||||
self::$testEmail = fake()->unique()->safeEmail();
|
||||
|
||||
self::$testPlanner = Planner::factory()->create([
|
||||
'email' => self::$testEmail,
|
||||
'password' => Hash::make(self::$testPassword),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSuccessfulLogin(): void
|
||||
{
|
||||
$this->ensureTestPlannerExists();
|
||||
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->driver->manage()->deleteAllCookies();
|
||||
$browser->visit('http://dishplanner_app:8000/login')
|
||||
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
|
||||
->clear('input[id="email"]')
|
||||
->type('input[id="email"]', self::$testEmail)
|
||||
->clear('input[id="password"]')
|
||||
->type('input[id="password"]', self::$testPassword)
|
||||
->press('Login')
|
||||
->waitForLocation('/dashboard', self::TIMEOUT_MEDIUM)
|
||||
->assertPathIs('/dashboard');
|
||||
});
|
||||
}
|
||||
|
||||
public function testLoginWithWrongCredentials(): void
|
||||
{
|
||||
$this->ensureTestPlannerExists();
|
||||
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->driver->manage()->deleteAllCookies();
|
||||
$browser->visit('http://dishplanner_app:8000/login')
|
||||
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
|
||||
->clear('input[id="email"]')
|
||||
->type('input[id="email"]', self::$testEmail)
|
||||
->clear('input[id="password"]')
|
||||
->type('input[id="password"]', 'wrongpassword')
|
||||
->press('Login')
|
||||
->pause(self::PAUSE_MEDIUM)
|
||||
->assertPathIs('/login')
|
||||
->assertSee('These credentials do not match our records');
|
||||
});
|
||||
}
|
||||
|
||||
public function testLoginFormRequiredFields(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->driver->manage()->deleteAllCookies();
|
||||
$browser->visit('http://dishplanner_app:8000/login')
|
||||
->waitFor('input[id="email"]', self::TIMEOUT_SHORT);
|
||||
|
||||
// Check that both fields have the required attribute
|
||||
$browser->assertAttribute('input[id="email"]', 'required', 'true');
|
||||
$browser->assertAttribute('input[id="password"]', 'required', 'true');
|
||||
|
||||
// Verify email field is type email
|
||||
$browser->assertAttribute('input[id="email"]', 'type', 'email');
|
||||
|
||||
// Verify password field is type password
|
||||
$browser->assertAttribute('input[id="password"]', 'type', 'password');
|
||||
|
||||
// Test that we stay on login page if we try to submit with empty fields
|
||||
$browser->press('Login')
|
||||
->pause(self::PAUSE_SHORT)
|
||||
->assertPathIs('/login');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Components;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Laravel\Dusk\Component as BaseComponent;
|
||||
|
||||
class DishModal extends BaseComponent
|
||||
{
|
||||
protected string $mode; // 'create' or 'edit'
|
||||
|
||||
public function __construct(string $mode = 'create')
|
||||
{
|
||||
$this->mode = $mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root selector for the component.
|
||||
*/
|
||||
public function selector(): string
|
||||
{
|
||||
// Livewire modals typically have a specific structure
|
||||
return '[role="dialog"], .fixed.inset-0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser page contains the component.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertVisible($this->selector());
|
||||
|
||||
if ($this->mode === 'create') {
|
||||
$browser->assertSee('Add New Dish');
|
||||
} else {
|
||||
$browser->assertSee('Edit Dish');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the component.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@name-input' => 'input[wire\\:model="name"]',
|
||||
'@description-input' => 'textarea[wire\\:model="description"]',
|
||||
'@users-section' => 'div:contains("Assign to Users")',
|
||||
'@submit-button' => $this->mode === 'create' ? 'button:contains("Create Dish")' : 'button:contains("Update Dish")',
|
||||
'@cancel-button' => 'button:contains("Cancel")',
|
||||
'@validation-error' => '.text-red-500',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the dish form.
|
||||
*/
|
||||
public function fillForm(Browser $browser, string $name, ?string $description = null): void
|
||||
{
|
||||
$browser->waitFor('@name-input')
|
||||
->clear('@name-input')
|
||||
->type('@name-input', $name);
|
||||
|
||||
if ($description !== null && $browser->element('@description-input')) {
|
||||
$browser->clear('@description-input')
|
||||
->type('@description-input', $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select users to assign the dish to.
|
||||
*/
|
||||
public function selectUsers(Browser $browser, array $userIds): void
|
||||
{
|
||||
foreach ($userIds as $userId) {
|
||||
$browser->check("input[type='checkbox'][value='{$userId}']");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form.
|
||||
*/
|
||||
public function submit(Browser $browser): void
|
||||
{
|
||||
$browser->press($this->mode === 'create' ? 'Create Dish' : 'Update Dish');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the modal.
|
||||
*/
|
||||
public function cancel(Browser $browser): void
|
||||
{
|
||||
$browser->press('Cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert validation error is shown.
|
||||
*/
|
||||
public function assertValidationError(Browser $browser, string $message = 'required'): void
|
||||
{
|
||||
$browser->assertSee($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Components;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Laravel\Dusk\Component as BaseComponent;
|
||||
|
||||
class LoginForm extends BaseComponent
|
||||
{
|
||||
/**
|
||||
* Get the root selector for the component.
|
||||
*/
|
||||
public function selector(): string
|
||||
{
|
||||
return 'form[method="POST"][action*="login"]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser page contains the component.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertVisible($this->selector())
|
||||
->assertVisible('@email')
|
||||
->assertVisible('@password')
|
||||
->assertVisible('@submit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the component.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@email' => 'input[id="email"]',
|
||||
'@password' => 'input[id="password"]',
|
||||
'@submit' => 'button[type="submit"]',
|
||||
'@remember' => 'input[name="remember"]',
|
||||
'@error' => '.text-red-500',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the login form.
|
||||
*/
|
||||
public function fillForm(Browser $browser, string $email, string $password): void
|
||||
{
|
||||
$browser->type('@email', $email)
|
||||
->type('@password', $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the login form.
|
||||
*/
|
||||
public function submit(Browser $browser): void
|
||||
{
|
||||
$browser->press('@submit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with the given credentials.
|
||||
*/
|
||||
public function loginWith(Browser $browser, string $email, string $password): void
|
||||
{
|
||||
$this->fillForm($browser, $email, $password);
|
||||
$this->submit($browser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the form fields are required.
|
||||
*/
|
||||
public function assertFieldsRequired(Browser $browser): void
|
||||
{
|
||||
$browser->assertAttribute('@email', 'required', 'true')
|
||||
->assertAttribute('@password', 'required', 'true')
|
||||
->assertAttribute('@email', 'type', 'email')
|
||||
->assertAttribute('@password', 'type', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the form has validation errors.
|
||||
*/
|
||||
public function assertHasErrors(Browser $browser): void
|
||||
{
|
||||
$browser->assertPresent('@error');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\Pages\DishesPage;
|
||||
use Tests\Browser\Components\DishModal;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
|
||||
class CreateDishFormValidationTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $createDishFormValidationTestPlanner = null;
|
||||
protected static $createDishFormValidationTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$createDishFormValidationTestPlanner;
|
||||
self::$testEmail = self::$createDishFormValidationTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Save the planner for next test method in this class
|
||||
self::$createDishFormValidationTestPlanner = self::$testPlanner;
|
||||
self::$createDishFormValidationTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCreateDishFormValidation(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser);
|
||||
|
||||
$browser->on(new DishesPage)
|
||||
->openCreateModal()
|
||||
->within(new DishModal('create'), function ($browser) {
|
||||
$browser->fillForm('', null)
|
||||
->submit()
|
||||
->pause(2000)
|
||||
->assertValidationError('required');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\Pages\DishesPage;
|
||||
use Tests\Browser\Components\DishModal;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
|
||||
class CreateDishSuccessTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $createDishSuccessTestPlanner = null;
|
||||
protected static $createDishSuccessTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$createDishSuccessTestPlanner;
|
||||
self::$testEmail = self::$createDishSuccessTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Save the planner for next test method in this class
|
||||
self::$createDishSuccessTestPlanner = self::$testPlanner;
|
||||
self::$createDishSuccessTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCanCreateDishSuccessfully(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$dishName = 'Test Dish ' . uniqid();
|
||||
|
||||
$this->loginAndGoToDishes($browser);
|
||||
|
||||
$browser->on(new DishesPage)
|
||||
->openCreateModal()
|
||||
->within(new DishModal('create'), function ($browser) use ($dishName) {
|
||||
$browser->fillForm($dishName)
|
||||
->submit();
|
||||
})
|
||||
->pause(3000)
|
||||
->assertDishVisible($dishName)
|
||||
->assertSee('Dish created successfully');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\Pages\DishesPage;
|
||||
use Tests\Browser\Components\DishModal;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
|
||||
class CreateDishTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $createDishTestPlanner = null;
|
||||
protected static $createDishTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$createDishTestPlanner;
|
||||
self::$testEmail = self::$createDishTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Save the planner for next test method in this class
|
||||
self::$createDishTestPlanner = self::$testPlanner;
|
||||
self::$createDishTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCanAccessDishesPage(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser);
|
||||
|
||||
$browser->on(new DishesPage)
|
||||
->assertSee('MANAGE DISHES')
|
||||
->assertSee('Add Dish');
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Moved to separate single-method test files to avoid static planner issues
|
||||
// See: OpenCreateDishModalTest, CreateDishFormValidationTest, CancelDishCreationTest, CreateDishSuccessTest
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use App\Models\Planner;
|
||||
|
||||
class DeleteDishTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $deleteDishTestPlanner = null;
|
||||
protected static $deleteDishTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$deleteDishTestPlanner;
|
||||
self::$testEmail = self::$deleteDishTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Save the planner for next test method in this class
|
||||
self::$deleteDishTestPlanner = self::$testPlanner;
|
||||
self::$deleteDishTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCanAccessDeleteFeature(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser)
|
||||
->assertPathIs('/dishes')
|
||||
->assertSee('MANAGE DISHES');
|
||||
|
||||
// Verify that delete functionality is available by looking for the text in the page source
|
||||
$pageSource = $browser->driver->getPageSource();
|
||||
$this->assertStringContainsString('Delete', $pageSource);
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Fix static planner issue causing login failures in suite runs
|
||||
// These tests pass in isolation but fail when run in full suite
|
||||
/*
|
||||
public function testDeleteModalComponents(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser)
|
||||
->assertSee('MANAGE DISHES')
|
||||
->assertSee('Add Dish');
|
||||
});
|
||||
}
|
||||
|
||||
public function testDeletionSafetyFeatures(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser);
|
||||
|
||||
// Check that Livewire component includes all CRUD features
|
||||
$pageSource = $browser->driver->getPageSource();
|
||||
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
|
||||
$this->assertStringContainsString('Add Dish', $pageSource);
|
||||
// Either we have dishes with Delete button OR "No dishes found" message
|
||||
if (str_contains($pageSource, 'No dishes found')) {
|
||||
$this->assertStringContainsString('No dishes found', $pageSource);
|
||||
} else {
|
||||
$this->assertStringContainsString('Delete', $pageSource);
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
|
||||
class DishDeletionSafetyTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $dishDeletionSafetyTestPlanner = null;
|
||||
protected static $dishDeletionSafetyTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$dishDeletionSafetyTestPlanner;
|
||||
self::$testEmail = self::$dishDeletionSafetyTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Save the planner for next test method in this class
|
||||
self::$dishDeletionSafetyTestPlanner = self::$testPlanner;
|
||||
self::$dishDeletionSafetyTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testDeletionSafetyFeatures(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser);
|
||||
|
||||
// Check that Livewire component includes all CRUD features
|
||||
$pageSource = $browser->driver->getPageSource();
|
||||
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
|
||||
$this->assertStringContainsString('Add Dish', $pageSource);
|
||||
// Either we have dishes with Delete button OR "No dishes found" message
|
||||
if (str_contains($pageSource, 'No dishes found')) {
|
||||
$this->assertStringContainsString('No dishes found', $pageSource);
|
||||
} else {
|
||||
$this->assertStringContainsString('Delete', $pageSource);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use App\Models\Planner;
|
||||
|
||||
class EditDishTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $editDishTestPlanner = null;
|
||||
protected static $editDishTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$editDishTestPlanner;
|
||||
self::$testEmail = self::$editDishTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Save the planner for next test method in this class
|
||||
self::$editDishTestPlanner = self::$testPlanner;
|
||||
self::$editDishTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCanAccessEditFeature(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser)
|
||||
->assertPathIs('/dishes')
|
||||
->assertSee('MANAGE DISHES');
|
||||
|
||||
// Verify that edit functionality is available by looking for the text in the page source
|
||||
$pageSource = $browser->driver->getPageSource();
|
||||
$this->assertStringContainsString('Edit', $pageSource);
|
||||
});
|
||||
}
|
||||
|
||||
public function testEditModalComponents(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser)
|
||||
->assertSee('MANAGE DISHES')
|
||||
->assertSee('Add Dish');
|
||||
});
|
||||
}
|
||||
|
||||
public function testDishesPageStructure(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToDishes($browser)
|
||||
->assertSee('MANAGE DISHES')
|
||||
->assertSee('Add Dish');
|
||||
|
||||
// Check that the dishes CRUD structure is present
|
||||
$pageSource = $browser->driver->getPageSource();
|
||||
// Either we have dishes with Edit/Delete buttons OR "No dishes found" message
|
||||
if (str_contains($pageSource, 'No dishes found')) {
|
||||
$this->assertStringContainsString('No dishes found', $pageSource);
|
||||
} else {
|
||||
$this->assertStringContainsString('Edit', $pageSource);
|
||||
$this->assertStringContainsString('Delete', $pageSource);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
trait LoginHelpers
|
||||
{
|
||||
protected static $testPlanner = null;
|
||||
protected static $testEmail = null;
|
||||
protected static $testPassword = 'password';
|
||||
|
||||
protected function ensureTestPlannerExists(): void
|
||||
{
|
||||
// Always create a fresh planner for each test class to avoid session conflicts
|
||||
if (self::$testPlanner === null || !self::$testPlanner->exists) {
|
||||
// Generate unique email for this test run
|
||||
self::$testEmail = fake()->unique()->safeEmail();
|
||||
|
||||
self::$testPlanner = \App\Models\Planner::factory()->create([
|
||||
'email' => self::$testEmail,
|
||||
'password' => \Illuminate\Support\Facades\Hash::make(self::$testPassword),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function loginAndNavigate(Browser $browser, string $page = '/dashboard'): Browser
|
||||
{
|
||||
$this->ensureTestPlannerExists();
|
||||
|
||||
// Clear browser session and cookies to start fresh
|
||||
$browser->driver->manage()->deleteAllCookies();
|
||||
|
||||
return $browser->visit('http://dishplanner_app:8000/login')
|
||||
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
|
||||
->clear('input[id="email"]')
|
||||
->type('input[id="email"]', self::$testEmail)
|
||||
->clear('input[id="password"]')
|
||||
->type('input[id="password"]', self::$testPassword)
|
||||
->press('Sign In')
|
||||
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM) // Wait for successful login redirect
|
||||
->pause(DuskTestCase::PAUSE_SHORT) // Brief pause for any initialization
|
||||
->visit('http://dishplanner_app:8000' . $page)
|
||||
->pause(DuskTestCase::PAUSE_MEDIUM); // Let Livewire components initialize
|
||||
}
|
||||
|
||||
protected function loginAndGoToDishes(Browser $browser): Browser
|
||||
{
|
||||
return $this->loginAndNavigate($browser, '/dishes');
|
||||
}
|
||||
|
||||
protected function loginAndGoToUsers(Browser $browser): Browser
|
||||
{
|
||||
return $this->loginAndNavigate($browser, '/users');
|
||||
}
|
||||
|
||||
protected function loginAndGoToSchedule(Browser $browser): Browser
|
||||
{
|
||||
return $this->loginAndNavigate($browser, '/schedule');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class DishesPage extends Page
|
||||
{
|
||||
/**
|
||||
* Get the URL for the page.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return '/dishes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser is on the page.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertPathIs($this->url())
|
||||
->assertSee('MANAGE DISHES');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@add-button' => 'button[wire\\:click="create"]',
|
||||
'@dishes-list' => '[wire\\:id]', // Livewire component
|
||||
'@search' => 'input[type="search"]',
|
||||
'@no-dishes' => '*[text*="No dishes found"]',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the create dish modal.
|
||||
*/
|
||||
public function openCreateModal(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('@add-button')
|
||||
->click('@add-button')
|
||||
->pause(1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click edit button for a dish.
|
||||
*/
|
||||
public function clickEditForDish(Browser $browser, string $dishName): void
|
||||
{
|
||||
$browser->within("tr:contains('{$dishName}')", function ($row) {
|
||||
$row->click('button.bg-accent-blue');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click delete button for a dish.
|
||||
*/
|
||||
public function clickDeleteForDish(Browser $browser, string $dishName): void
|
||||
{
|
||||
$browser->within("tr:contains('{$dishName}')", function ($row) {
|
||||
$row->click('button.bg-red-500');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a dish is visible in the list.
|
||||
*/
|
||||
public function assertDishVisible(Browser $browser, string $dishName): void
|
||||
{
|
||||
$browser->assertSee($dishName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert no dishes message is shown.
|
||||
*/
|
||||
public function assertNoDishes(Browser $browser): void
|
||||
{
|
||||
$browser->assertSee('No dishes found');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Components\LoginForm;
|
||||
|
||||
class LoginPage extends Page
|
||||
{
|
||||
/**
|
||||
* Get the URL for the page.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return '/login';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser is on the page.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertPathIs($this->url())
|
||||
->assertSee('Login')
|
||||
->assertPresent((new LoginForm)->selector());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@register-link' => 'a[href*="register"]',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the registration page.
|
||||
*/
|
||||
public function goToRegistration(Browser $browser): void
|
||||
{
|
||||
$browser->click('@register-link');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Page as BasePage;
|
||||
|
||||
abstract class Page extends BasePage
|
||||
{
|
||||
/**
|
||||
* Get the global element shortcuts for the site.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function siteElements(): array
|
||||
{
|
||||
return [
|
||||
'@nav' => 'nav',
|
||||
'@alert' => '[role="alert"]',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class SchedulePage extends Page
|
||||
{
|
||||
public function url(): string
|
||||
{
|
||||
return '/schedule';
|
||||
}
|
||||
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertPathIs($this->url())
|
||||
->assertSee('SCHEDULE');
|
||||
}
|
||||
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@generate-button' => 'button[wire\\:click="generate"]',
|
||||
'@clear-month-button' => 'button[wire\\:click="clearMonth"]',
|
||||
'@previous-month' => 'button[wire\\:click="previousMonth"]',
|
||||
'@next-month' => 'button[wire\\:click="nextMonth"]',
|
||||
'@month-select' => 'select[wire\\:model="selectedMonth"]',
|
||||
'@year-select' => 'select[wire\\:model="selectedYear"]',
|
||||
'@clear-existing-checkbox' => 'input[wire\\:model="clearExisting"]',
|
||||
'@calendar-grid' => '.grid.grid-cols-7',
|
||||
];
|
||||
}
|
||||
|
||||
public function clickGenerate(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('@generate-button')
|
||||
->click('@generate-button')
|
||||
->pause(2000); // Wait for generation
|
||||
}
|
||||
|
||||
public function clickClearMonth(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('@clear-month-button')
|
||||
->click('@clear-month-button')
|
||||
->pause(1000);
|
||||
}
|
||||
|
||||
public function goToPreviousMonth(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('@previous-month')
|
||||
->click('@previous-month')
|
||||
->pause(500);
|
||||
}
|
||||
|
||||
public function goToNextMonth(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('@next-month')
|
||||
->click('@next-month')
|
||||
->pause(500);
|
||||
}
|
||||
|
||||
public function selectMonth(Browser $browser, int $month): void
|
||||
{
|
||||
$browser->waitFor('@month-select')
|
||||
->select('@month-select', $month)
|
||||
->pause(500);
|
||||
}
|
||||
|
||||
public function selectYear(Browser $browser, int $year): void
|
||||
{
|
||||
$browser->waitFor('@year-select')
|
||||
->select('@year-select', $year)
|
||||
->pause(500);
|
||||
}
|
||||
|
||||
public function toggleClearExisting(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('@clear-existing-checkbox')
|
||||
->click('@clear-existing-checkbox');
|
||||
}
|
||||
|
||||
public function selectUser(Browser $browser, string $userName): void
|
||||
{
|
||||
$browser->check("input[type='checkbox'][value]", $userName);
|
||||
}
|
||||
|
||||
public function assertSuccessMessage(Browser $browser, string $message = null): void
|
||||
{
|
||||
if ($message) {
|
||||
$browser->assertSee($message);
|
||||
} else {
|
||||
$browser->assertPresent('.border-success');
|
||||
}
|
||||
}
|
||||
|
||||
public function assertDishScheduled(Browser $browser, string $dishName): void
|
||||
{
|
||||
$browser->assertSee($dishName);
|
||||
}
|
||||
|
||||
public function assertNoDishesScheduled(Browser $browser): void
|
||||
{
|
||||
$browser->assertSee('No dishes scheduled');
|
||||
}
|
||||
|
||||
public function assertMonthDisplayed(Browser $browser, string $monthYear): void
|
||||
{
|
||||
$browser->assertSee($monthYear);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class UsersPage extends Page
|
||||
{
|
||||
/**
|
||||
* Get the URL for the page.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return '/users';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser is on the page.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertPathIs($this->url())
|
||||
->assertSee('MANAGE USERS');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@add-button' => 'button[wire\\:click="create"]',
|
||||
'@users-list' => '[wire\\:id]', // Livewire component
|
||||
'@no-users' => '*[text*="No users found"]',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the create user modal.
|
||||
*/
|
||||
public function openCreateModal(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('@add-button')
|
||||
->click('@add-button')
|
||||
->pause(1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click delete button for a user.
|
||||
*/
|
||||
public function clickDeleteForUser(Browser $browser, string $userName): void
|
||||
{
|
||||
$browser->within("tr:contains('{$userName}')", function ($row) {
|
||||
$row->click('button.bg-danger');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the first available delete button.
|
||||
*/
|
||||
public function clickFirstDeleteButton(Browser $browser): void
|
||||
{
|
||||
$browser->waitFor('button.bg-danger', 5)
|
||||
->click('button.bg-danger')
|
||||
->pause(1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a user is visible in the list.
|
||||
*/
|
||||
public function assertUserVisible(Browser $browser, string $userName): void
|
||||
{
|
||||
$browser->assertSee($userName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a user is not visible in the list.
|
||||
*/
|
||||
public function assertUserNotVisible(Browser $browser, string $userName): void
|
||||
{
|
||||
$browser->assertDontSee($userName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert success message is shown.
|
||||
*/
|
||||
public function assertSuccessMessage(Browser $browser, string $message): void
|
||||
{
|
||||
$browser->assertSee($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
class RedirectTest extends DuskTestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Test that unauthenticated users are redirected to login
|
||||
*/
|
||||
public function testUnauthenticatedRedirectsToLogin()
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->visit('http://dishplanner_app:8000/dashboard')
|
||||
->assertPathIs('/login')
|
||||
->assertSee('Login');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that login page loads correctly
|
||||
*/
|
||||
public function testLoginPageLoads()
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->visit('http://dishplanner_app:8000/login')
|
||||
->assertPathIs('/login')
|
||||
->assertSee('Login')
|
||||
->assertSee('Email')
|
||||
->assertSee('Password');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Schedule;
|
||||
|
||||
use App\Models\Dish;
|
||||
use App\Models\Planner;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\Pages\SchedulePage;
|
||||
|
||||
class GenerateScheduleTest extends DuskTestCase
|
||||
{
|
||||
protected static $planner = null;
|
||||
protected static $email = null;
|
||||
protected static $password = 'password';
|
||||
protected static $user = null;
|
||||
protected static $dish = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create test data if not exists
|
||||
if (self::$planner === null) {
|
||||
self::$email = fake()->unique()->safeEmail();
|
||||
self::$planner = Planner::factory()->create([
|
||||
'email' => self::$email,
|
||||
'password' => Hash::make(self::$password),
|
||||
]);
|
||||
|
||||
// Create a user for this planner
|
||||
self::$user = User::factory()->create([
|
||||
'planner_id' => self::$planner->id,
|
||||
'name' => 'Test User',
|
||||
]);
|
||||
|
||||
// Create a dish and assign to user
|
||||
self::$dish = Dish::factory()->create([
|
||||
'planner_id' => self::$planner->id,
|
||||
'name' => 'Test Dish',
|
||||
]);
|
||||
|
||||
// Attach user to dish (creates UserDish)
|
||||
self::$dish->users()->attach(self::$user);
|
||||
}
|
||||
}
|
||||
|
||||
protected function loginAsPlanner(Browser $browser): Browser
|
||||
{
|
||||
$browser->driver->manage()->deleteAllCookies();
|
||||
|
||||
return $browser->visit('http://dishplanner_app:8000/login')
|
||||
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
|
||||
->clear('input[id="email"]')
|
||||
->type('input[id="email"]', self::$email)
|
||||
->clear('input[id="password"]')
|
||||
->type('input[id="password"]', self::$password)
|
||||
->press('Login')
|
||||
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM)
|
||||
->pause(DuskTestCase::PAUSE_SHORT)
|
||||
->visit('http://dishplanner_app:8000/schedule')
|
||||
->pause(DuskTestCase::PAUSE_MEDIUM);
|
||||
}
|
||||
|
||||
public function testCanGenerateScheduleWithUserAndDish(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAsPlanner($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->assertSee('Test User') // User should be in selection
|
||||
->clickGenerate()
|
||||
->pause(2000)
|
||||
// Verify schedule was generated by checking dish appears on calendar
|
||||
->assertSee('Test Dish');
|
||||
});
|
||||
}
|
||||
|
||||
public function testGeneratedScheduleShowsDishOnCalendar(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAsPlanner($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->clickGenerate()
|
||||
->pause(2000)
|
||||
// The dish should appear somewhere on the calendar
|
||||
->assertSee('Test Dish');
|
||||
});
|
||||
}
|
||||
|
||||
public function testCanClearMonthSchedule(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAsPlanner($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
// First generate a schedule
|
||||
->clickGenerate()
|
||||
->pause(2000)
|
||||
->assertSee('Test Dish') // Verify generated
|
||||
// Then clear it
|
||||
->clickClearMonth()
|
||||
->pause(1000)
|
||||
// After clearing, should see "No dishes scheduled" on calendar days
|
||||
->assertSee('No dishes scheduled');
|
||||
});
|
||||
}
|
||||
|
||||
public function testUserSelectionAffectsGeneration(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAsPlanner($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
// Verify the user checkbox is present
|
||||
->assertSee('Test User')
|
||||
// User should be selected by default
|
||||
->assertChecked("input[value='" . self::$user->id . "']");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Schedule;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\Pages\SchedulePage;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
|
||||
class SchedulePageTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $schedulePageTestPlanner = null;
|
||||
protected static $schedulePageTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::$testPlanner = self::$schedulePageTestPlanner;
|
||||
self::$testEmail = self::$schedulePageTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
self::$schedulePageTestPlanner = self::$testPlanner;
|
||||
self::$schedulePageTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCanAccessSchedulePage(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToSchedule($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->assertSee('SCHEDULE')
|
||||
->assertSee('Generate Schedule');
|
||||
});
|
||||
}
|
||||
|
||||
public function testSchedulePageHasMonthNavigation(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToSchedule($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->assertPresent('@previous-month')
|
||||
->assertPresent('@next-month')
|
||||
->assertSee(now()->format('F Y'));
|
||||
});
|
||||
}
|
||||
|
||||
public function testCanNavigateToNextMonth(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToSchedule($browser);
|
||||
|
||||
$nextMonth = now()->addMonth();
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->goToNextMonth()
|
||||
->assertSee($nextMonth->format('F Y'));
|
||||
});
|
||||
}
|
||||
|
||||
public function testCanNavigateToPreviousMonth(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToSchedule($browser);
|
||||
|
||||
$prevMonth = now()->subMonth();
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->goToPreviousMonth()
|
||||
->assertSee($prevMonth->format('F Y'));
|
||||
});
|
||||
}
|
||||
|
||||
public function testScheduleGeneratorShowsUserSelection(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToSchedule($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->assertSee('Select Users')
|
||||
->assertPresent('@generate-button')
|
||||
->assertPresent('@clear-month-button');
|
||||
});
|
||||
}
|
||||
|
||||
public function testCalendarDisplaysDaysOfWeek(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToSchedule($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->assertSee('Mon')
|
||||
->assertSee('Tue')
|
||||
->assertSee('Wed')
|
||||
->assertSee('Thu')
|
||||
->assertSee('Fri')
|
||||
->assertSee('Sat')
|
||||
->assertSee('Sun');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Users;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
use Tests\Browser\Pages\UsersPage;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
|
||||
class CreateUserTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
protected static $createUserTestPlanner = null;
|
||||
protected static $createUserTestEmail = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$createUserTestPlanner;
|
||||
self::$testEmail = self::$createUserTestEmail;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Save the planner for next test method in this class
|
||||
self::$createUserTestPlanner = self::$testPlanner;
|
||||
self::$createUserTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testCanAccessUsersPage(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToUsers($browser);
|
||||
|
||||
$browser->on(new UsersPage)
|
||||
->assertSee('MANAGE USERS')
|
||||
->assertSee('Add User');
|
||||
});
|
||||
}
|
||||
|
||||
public function testCanOpenCreateUserModal(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToUsers($browser);
|
||||
|
||||
$browser->on(new UsersPage)
|
||||
->openCreateModal()
|
||||
->assertSee('Add New User')
|
||||
->assertSee('Name')
|
||||
->assertSee('Cancel')
|
||||
->assertSee('Create User');
|
||||
});
|
||||
}
|
||||
|
||||
public function testCreateUserFormValidation(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToUsers($browser);
|
||||
|
||||
$browser->on(new UsersPage)
|
||||
->openCreateModal()
|
||||
->press('Create User')
|
||||
->pause(self::PAUSE_MEDIUM)
|
||||
->assertSee('The name field is required');
|
||||
});
|
||||
}
|
||||
|
||||
public function testCanCreateUser(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$userName = 'TestCreate_' . uniqid();
|
||||
|
||||
$this->loginAndGoToUsers($browser);
|
||||
|
||||
$browser->on(new UsersPage)
|
||||
->openCreateModal()
|
||||
->type('input[wire\\:model="name"]', $userName)
|
||||
->press('Create User')
|
||||
->pause(self::PAUSE_MEDIUM)
|
||||
->assertSee('User created successfully')
|
||||
->assertSee($userName);
|
||||
});
|
||||
}
|
||||
|
||||
public function testCanCancelUserCreation(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToUsers($browser);
|
||||
|
||||
$browser->on(new UsersPage)
|
||||
->openCreateModal()
|
||||
->type('input[wire\\:model="name"]', 'Test Cancel User')
|
||||
->press('Cancel')
|
||||
->pause(self::PAUSE_SHORT)
|
||||
// Modal should be closed, we should be back on users page
|
||||
->assertSee('MANAGE USERS')
|
||||
->assertDontSee('Add New User');
|
||||
});
|
||||
}
|
||||
}
|
||||
2
tests/Browser/console/.gitignore
vendored
2
tests/Browser/console/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
tests/Browser/screenshots/.gitignore
vendored
2
tests/Browser/screenshots/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
tests/Browser/source/.gitignore
vendored
2
tests/Browser/source/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Dusk\TestCase as BaseTestCase;
|
||||
use PHPUnit\Framework\Attributes\BeforeClass;
|
||||
|
||||
abstract class DuskTestCase extends BaseTestCase
|
||||
{
|
||||
// Timeout constants for consistent timing across all Dusk tests
|
||||
public const TIMEOUT_SHORT = 2; // 2 seconds max for most operations
|
||||
public const TIMEOUT_MEDIUM = 3; // 3 seconds for slower operations
|
||||
public const PAUSE_SHORT = 500; // 0.5 seconds for quick pauses
|
||||
public const PAUSE_MEDIUM = 1000; // 1 second for medium pauses
|
||||
|
||||
/**
|
||||
* Prepare for Dusk test execution.
|
||||
*/
|
||||
#[BeforeClass]
|
||||
public static function prepare(): void
|
||||
{
|
||||
// Don't start ChromeDriver - we're using Selenium
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the RemoteWebDriver instance.
|
||||
*/
|
||||
protected function driver(): RemoteWebDriver
|
||||
{
|
||||
$options = (new ChromeOptions)->addArguments([
|
||||
'--window-size=1920,1080',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--headless=new',
|
||||
'--disable-extensions',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
]);
|
||||
|
||||
return RemoteWebDriver::create(
|
||||
'http://selenium:4444/wd/hub', // Connect to Selenium container
|
||||
DesiredCapabilities::chrome()->setCapability(
|
||||
ChromeOptions::CAPABILITY, $options
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Tests\TestCase;
|
||||
use App\Models\Planner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewIs('auth.login');
|
||||
$response->assertSee('Login');
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_using_the_login_screen(): void
|
||||
{
|
||||
Planner::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect('/dashboard');
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password(): void
|
||||
{
|
||||
Planner::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_session_is_created_on_login_page(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
|
||||
// Check if session was started
|
||||
$this->assertNotNull(session()->getId());
|
||||
|
||||
// Check if CSRF token is generated
|
||||
$this->assertNotNull(csrf_token());
|
||||
|
||||
// Check session driver
|
||||
$sessionDriver = config('session.driver');
|
||||
$this->assertNotEquals('array', $sessionDriver, 'Session driver should not be array for authentication');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
public function test_csrf_token_is_validated_on_login(): void
|
||||
{
|
||||
// Try to post without CSRF token by disabling middleware that auto-adds it
|
||||
$response = $this
|
||||
->withoutMiddleware(VerifyCsrfToken::class)
|
||||
->withHeaders([
|
||||
'Accept' => 'text/html',
|
||||
])
|
||||
->post('/login', [
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(302);
|
||||
}
|
||||
|
||||
public function test_users_can_logout(): void
|
||||
{
|
||||
$user = Planner::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue