Compare commits

..

No commits in common. "main" and "feature/6-livewire" have entirely different histories.

147 changed files with 846 additions and 6367 deletions

View file

@ -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
View file

@ -1,6 +1,5 @@
/composer.lock
/.phpunit.cache
/coverage
/node_modules
/public/build
/public/hot

View file

@ -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

View file

@ -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..."

View file

@ -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

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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('/');
}
}

View file

@ -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'));
}
}

View file

@ -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'));
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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');
}
}

View 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');
}
}

View file

@ -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();
}
}
}

View file

@ -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);
}
}
}

View file

@ -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];
}
}
}

View file

@ -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']);
}
}
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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',

View file

@ -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');

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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();

View file

@ -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": {

View file

@ -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

View file

@ -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'),
],
];

View file

@ -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');
});
}

View file

@ -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',
]);
});
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
});
}
};

View file

@ -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');
});
}
};

View file

@ -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');
}
}

View file

@ -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()));
}
}

View file

@ -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

View file

@ -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;

View file

@ -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>

View file

@ -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

View file

@ -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);
}

View file

@ -1 +1,5 @@
import './bootstrap';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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>

View file

@ -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');
});

View file

@ -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');
});

View file

@ -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"

View file

@ -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();
}
}

View file

@ -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();
}
}
}

View file

@ -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,
]);
}
}
});
}
}

View file

@ -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');
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -30,7 +30,6 @@ export default {
400: '#444760',
500: '#2B2C41',
600: '#24263C',
650: '#202239',
700: '#1D1E36',
800: '#131427',
900: '#0A0B1C',

View file

@ -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');
});
}
}

View file

@ -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);
}
}

View file

@ -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');
}
}

View file

@ -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');
});
});
}
}

View file

@ -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');
});
}
}

View file

@ -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
}

View file

@ -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);
}
});
}
*/
}

View file

@ -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);
}
});
}
}

View file

@ -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);
}
});
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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');
}
}

View file

@ -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"]',
];
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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');
});
}
}

View file

@ -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 . "']");
});
}
}

View file

@ -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');
});
}
}

View file

@ -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');
});
}
}

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -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
)
);
}
}

View file

@ -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