diff --git a/website/app/Http/Controllers/Account/ProfileController.php b/website/app/Http/Controllers/Account/ProfileController.php index 524b1a9..d24105c 100644 --- a/website/app/Http/Controllers/Account/ProfileController.php +++ b/website/app/Http/Controllers/Account/ProfileController.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace App\Http\Controllers\Account; use App\Http\Controllers\Controller; +use App\Http\Requests\UpdatePasswordRequest; +use App\Http\Requests\UpdateProfileRequest; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -13,21 +16,82 @@ class ProfileController extends Controller { public function show(Request $request): Response { + $user = $request->user(); + $user->load('profile'); + + $profile = $user->profile; + return Inertia::render('Profile/Show', [ - 'user' => $request->user()->load('profile'), + 'user' => $user, + 'profile' => $profile ? [ + 'billing_address_line1' => $profile->billing_address_line1, + 'billing_address_line2' => $profile->billing_address_line2, + 'billing_city' => $profile->billing_city, + 'billing_state' => $profile->billing_state, + 'billing_zip' => $profile->billing_zip, + 'billing_country' => $profile->billing_country, + 'tax_id' => $profile->tax_id, + 'tax_exempt' => $profile->tax_exempt, + 'company_name' => $profile->company_name, + 'company_vat' => $profile->company_vat, + ] : null, + 'twoFactorEnabled' => (bool) $user->two_factor_secret, ]); } - public function update(Request $request): \Illuminate\Http\RedirectResponse + public function update(UpdateProfileRequest $request): RedirectResponse + { + $validated = $request->validated(); + $user = $request->user(); + + $user->update([ + 'name' => $validated['first_name'].' '.$validated['last_name'], + 'phone' => $validated['phone'] ?? null, + 'company' => $validated['company'] ?? null, + ]); + + $user->profile()->updateOrCreate( + ['user_id' => $user->id], + [ + 'billing_address_line1' => $validated['address_line1'] ?? null, + 'billing_address_line2' => $validated['address_line2'] ?? null, + 'billing_city' => $validated['city'] ?? null, + 'billing_state' => $validated['state'] ?? null, + 'billing_zip' => $validated['zip'] ?? null, + 'billing_country' => $validated['country'] ?? null, + ] + ); + + return back()->with('success', 'Profile updated successfully.'); + } + + public function updatePassword(UpdatePasswordRequest $request): RedirectResponse + { + $request->user()->update([ + 'password' => $request->validated('password'), + ]); + + return back()->with('success', 'Password updated successfully.'); + } + + public function updateBilling(Request $request): RedirectResponse { $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'phone' => ['nullable', 'string', 'max:20'], - 'company' => ['nullable', 'string', 'max:255'], + 'billing_address_line1' => ['nullable', 'string', 'max:255'], + 'billing_address_line2' => ['nullable', 'string', 'max:255'], + 'billing_city' => ['nullable', 'string', 'max:100'], + 'billing_state' => ['nullable', 'string', 'max:100'], + 'billing_zip' => ['nullable', 'string', 'max:20'], + 'billing_country' => ['nullable', 'string', 'max:100'], + 'tax_id' => ['nullable', 'string', 'max:50'], + 'company_vat' => ['nullable', 'string', 'max:50'], ]); - $request->user()->update($validated); + $request->user()->profile()->updateOrCreate( + ['user_id' => $request->user()->id], + $validated + ); - return back(); + return back()->with('success', 'Billing information updated successfully.'); } } diff --git a/website/app/Http/Controllers/Admin/DashboardController.php b/website/app/Http/Controllers/Admin/DashboardController.php index 3fbb89e..252dba3 100644 --- a/website/app/Http/Controllers/Admin/DashboardController.php +++ b/website/app/Http/Controllers/Admin/DashboardController.php @@ -5,19 +5,134 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Models\Invoice; +use App\Models\Plan; use App\Models\Service; use App\Models\User; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; +use Laravel\Cashier\Subscription; class DashboardController extends Controller { public function index(): Response { + $totalCustomers = User::role('customer')->count(); + + // MRR: sum of plan prices for active subscriptions + $mrr = Subscription::query() + ->where('stripe_status', 'active') + ->whereNotNull('plan_id') + ->join('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->sum('plans.price'); + + // Total revenue: sum of paid invoice totals + $totalRevenue = Invoice::query() + ->where('status', 'paid') + ->sum('total'); + + $activeServices = Service::query() + ->where('status', 'active') + ->count(); + + // Pending invoices + $pendingInvoicesCount = Invoice::query() + ->where('status', 'pending') + ->count(); + + $pendingInvoicesAmount = Invoice::query() + ->where('status', 'pending') + ->sum('total'); + + // Overdue invoices + $overdueCount = Invoice::query() + ->where('status', 'overdue') + ->count(); + + $overdueAmount = Invoice::query() + ->where('status', 'overdue') + ->sum('total'); + + // Recent invoices with user info + $recentInvoices = Invoice::query() + ->with('user:id,name,email') + ->latest() + ->limit(10) + ->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']); + + // Recent subscriptions with user and plan + $recentSubscriptions = Subscription::query() + ->select([ + 'subscriptions.id', + 'subscriptions.user_id', + 'subscriptions.plan_id', + 'subscriptions.type', + 'subscriptions.stripe_status', + 'subscriptions.gateway', + 'subscriptions.created_at', + ]) + ->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->addSelect([ + 'plans.name as plan_name', + 'plans.price as plan_price', + 'plans.billing_cycle as plan_billing_cycle', + ]) + ->leftJoin('users', 'subscriptions.user_id', '=', 'users.id') + ->addSelect([ + 'users.name as user_name', + 'users.email as user_email', + ]) + ->orderByDesc('subscriptions.created_at') + ->limit(10) + ->get(); + + // Popular plans: ordered by active subscription count + $popularPlans = Plan::query() + ->withCount(['services as active_services_count' => function ($query): void { + $query->where('status', 'active'); + }]) + ->where('status', 'active') + ->orderByDesc('active_services_count') + ->limit(8) + ->get(['id', 'name', 'service_type', 'price', 'billing_cycle']); + + // Revenue by service type from plans linked through invoices + $revenueByServiceType = Invoice::query() + ->where('invoices.status', 'paid') + ->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id') + ->join('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->select('plans.service_type', DB::raw('SUM(invoices.total) as revenue'), DB::raw('COUNT(invoices.id) as invoice_count')) + ->groupBy('plans.service_type') + ->orderByDesc('revenue') + ->get(); + + // New customers this month + $newCustomersThisMonth = User::role('customer') + ->where('created_at', '>=', now()->startOfMonth()) + ->count(); + + // Revenue this month + $revenueThisMonth = Invoice::query() + ->where('status', 'paid') + ->where('paid_at', '>=', now()->startOfMonth()) + ->sum('total'); + return Inertia::render('Admin/Dashboard', [ - 'totalCustomers' => User::role('customer')->count(), - 'totalServices' => Service::count(), - 'activeServices' => Service::where('status', 'active')->count(), + 'totalCustomers' => $totalCustomers, + 'mrr' => (float) $mrr, + 'totalRevenue' => (float) $totalRevenue, + 'activeServices' => $activeServices, + 'pendingInvoicesCount' => $pendingInvoicesCount, + 'pendingInvoicesAmount' => (float) $pendingInvoicesAmount, + 'overdueCount' => $overdueCount, + 'overdueAmount' => (float) $overdueAmount, + 'recentInvoices' => $recentInvoices, + 'recentSubscriptions' => $recentSubscriptions, + 'popularPlans' => $popularPlans, + 'revenueByServiceType' => $revenueByServiceType, + 'newCustomersThisMonth' => $newCustomersThisMonth, + 'revenueThisMonth' => (float) $revenueThisMonth, ]); } } diff --git a/website/app/Http/Requests/UpdatePasswordRequest.php b/website/app/Http/Requests/UpdatePasswordRequest.php new file mode 100644 index 0000000..f7a6c86 --- /dev/null +++ b/website/app/Http/Requests/UpdatePasswordRequest.php @@ -0,0 +1,35 @@ +> */ + public function rules(): array + { + return [ + 'current_password' => ['required', 'string', 'current_password'], + 'password' => ['required', 'string', Password::defaults(), 'confirmed'], + 'password_confirmation' => ['required', 'string'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'current_password.current_password' => 'The current password is incorrect.', + 'password.confirmed' => 'The password confirmation does not match.', + ]; + } +} diff --git a/website/app/Http/Requests/UpdateProfileRequest.php b/website/app/Http/Requests/UpdateProfileRequest.php new file mode 100644 index 0000000..c7a5b7c --- /dev/null +++ b/website/app/Http/Requests/UpdateProfileRequest.php @@ -0,0 +1,41 @@ +> */ + public function rules(): array + { + return [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'phone' => ['nullable', 'string', 'max:20'], + 'company' => ['nullable', 'string', 'max:255'], + 'address_line1' => ['nullable', 'string', 'max:255'], + 'address_line2' => ['nullable', 'string', 'max:255'], + 'city' => ['nullable', 'string', 'max:100'], + 'state' => ['nullable', 'string', 'max:100'], + 'zip' => ['nullable', 'string', 'max:20'], + 'country' => ['nullable', 'string', 'max:100'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'first_name.required' => 'First name is required.', + 'last_name.required' => 'Last name is required.', + ]; + } +} diff --git a/website/resources/ts/Pages/Admin/Dashboard.vue b/website/resources/ts/Pages/Admin/Dashboard.vue index 9937995..f616457 100644 --- a/website/resources/ts/Pages/Admin/Dashboard.vue +++ b/website/resources/ts/Pages/Admin/Dashboard.vue @@ -1,24 +1,128 @@ diff --git a/website/resources/ts/Pages/Profile/AccountTab.vue b/website/resources/ts/Pages/Profile/AccountTab.vue new file mode 100644 index 0000000..40934de --- /dev/null +++ b/website/resources/ts/Pages/Profile/AccountTab.vue @@ -0,0 +1,275 @@ + + + diff --git a/website/resources/ts/Pages/Profile/BillingTab.vue b/website/resources/ts/Pages/Profile/BillingTab.vue new file mode 100644 index 0000000..bf7efee --- /dev/null +++ b/website/resources/ts/Pages/Profile/BillingTab.vue @@ -0,0 +1,202 @@ + + + diff --git a/website/resources/ts/Pages/Profile/SecurityTab.vue b/website/resources/ts/Pages/Profile/SecurityTab.vue new file mode 100644 index 0000000..d8bc90b --- /dev/null +++ b/website/resources/ts/Pages/Profile/SecurityTab.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/website/resources/ts/Pages/Profile/Show.vue b/website/resources/ts/Pages/Profile/Show.vue index f22e319..f3d3f47 100644 --- a/website/resources/ts/Pages/Profile/Show.vue +++ b/website/resources/ts/Pages/Profile/Show.vue @@ -1,83 +1,87 @@ diff --git a/website/resources/ts/navigation/account.ts b/website/resources/ts/navigation/account.ts index 59445ca..adca59e 100644 --- a/website/resources/ts/navigation/account.ts +++ b/website/resources/ts/navigation/account.ts @@ -10,5 +10,5 @@ export const accountNavItems: NavItem[] = [ { title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' }, { title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' }, { title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' }, - { title: 'Profile', href: '/profile', icon: 'tabler-user', matchPrefix: '/profile' }, + { title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' }, ] diff --git a/website/resources/ts/types/index.ts b/website/resources/ts/types/index.ts index 89fa814..4c74328 100644 --- a/website/resources/ts/types/index.ts +++ b/website/resources/ts/types/index.ts @@ -2,10 +2,25 @@ export interface User { id: number name: string email: string + phone: string | null + company: string | null status: string two_factor_enabled?: boolean } +export interface UserProfile { + billing_address_line1: string | null + billing_address_line2: string | null + billing_city: string | null + billing_state: string | null + billing_zip: string | null + billing_country: string | null + tax_id: string | null + tax_exempt: boolean + company_name: string | null + company_vat: string | null +} + export interface AuthProps { user: User | null } diff --git a/website/routes/account.php b/website/routes/account.php index 4bec321..2444701 100644 --- a/website/routes/account.php +++ b/website/routes/account.php @@ -14,6 +14,8 @@ Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.d Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile'); Route::put('/profile', [ProfileController::class, 'update'])->name('account.profile.update'); +Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('account.profile.password'); +Route::put('/profile/billing', [ProfileController::class, 'updateBilling'])->name('account.profile.billing'); // Plans Route::get('/plans', [PlanController::class, 'index'])->name('account.plans.index');