buid new app

This commit is contained in:
2026-03-09 01:00:49 +08:00
commit 97aed742af
126 changed files with 19341 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): Response
{
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): Response
{
return Inertia::render('Auth/ConfirmPassword');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|Response
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): Response
{
return Inertia::render('Auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
return redirect()->route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): Response
{
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
if ($status == Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): Response
{
return Inertia::render('Auth/Register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
use AuthorizesRequests;
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'color' => 'nullable|string|max:7',
'icon' => 'nullable|string|max:50',
]);
Category::create([
...$validated,
'user_id' => auth()->id(),
]);
return redirect()->back();
}
public function update(Request $request, Category $category)
{
$this->authorize('update', $category);
$validated = $request->validate([
'name' => 'required|string|max:255',
'color' => 'nullable|string|max:7',
]);
$category->update($validated);
return redirect()->back();
}
public function destroy(Category $category)
{
$this->authorize('delete', $category);
$category->delete();
return redirect()->back();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Models\CategoryRule;
use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class CategoryRuleController extends Controller
{
use AuthorizesRequests;
public function store(Request $request)
{
$validated = $request->validate([
'keyword' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
]);
CategoryRule::create([
...$validated,
'user_id' => auth()->id(),
]);
return redirect()->back();
}
public function destroy(CategoryRule $categoryRule)
{
if ($categoryRule->user_id !== auth()->id()) {
abort(403);
}
$categoryRule->delete();
return redirect()->back();
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\Expense;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Carbon\Carbon;
class DashboardController extends Controller
{
public function index()
{
$userId = Auth::id();
$now = Carbon::now();
// 本月花費
$monthlyTotal = Expense::where('user_id', $userId)
->whereYear('date', $now->year)
->whereMonth('date', $now->month)
->sum('amount');
// 本月分類統計(圓餅圖)
$categoryStats = Expense::with('category')
->where('user_id', $userId)
->whereYear('date', $now->year)
->whereMonth('date', $now->month)
->get()
->groupBy(fn($e) => $e->category?->name ?? '未分類')
->map(fn($group, $name) => [
'name' => $name,
'total' => $group->sum('amount'),
'color' => $group->first()->category?->color ?? '#6B7280',
])
->values();
// 近6個月趨勢
$monthlyTrend = collect(range(5, 0))->map(function ($i) use ($userId, $now) {
$month = $now->copy()->subMonths($i);
$total = Expense::where('user_id', $userId)
->whereYear('date', $month->year)
->whereMonth('date', $month->month)
->sum('amount');
return [
'month' => $month->format('Y/m'),
'total' => (float) $total,
];
});
return Inertia::render('Dashboard', [
'monthlyTotal' => (float) $monthlyTotal,
'categoryStats' => $categoryStats,
'monthlyTrend' => $monthlyTrend,
]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\CategoryRule;
use App\Models\Expense;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ExpenseController extends Controller
{
use AuthorizesRequests;
/**
* Display a listing of the resource.
*/
public function index()
{
$expenses = Expense::with('category')
->where('user_id', auth()->id())
->orderBy('date', 'desc')
->get();
$categories = Category::where('user_id', auth()->id())->get();
$rules = CategoryRule::with('category')
->where('user_id', auth()->id())
->get();
return Inertia::render('Expenses/Index', [
'expenses' => $expenses,
'categories' => $categories,
'rules' => $rules,
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'amount' => 'required|numeric|min:0',
'category_id' => 'nullable|exists:categories,id',
'note' => 'nullable|string|max:255',
'date' => 'required|date',
]);
Expense::create([
...$validated,
'user_id' => auth()->id(),
]);
return redirect()->back();
}
/**
* Display the specified resource.
*/
public function show(Expense $expense)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Expense $expense)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Expense $expense)
{
$this->authorize('update', $expense);
$validated = $request->validate([
'amount' => 'required|numeric|min:0',
'category_id' => 'nullable|exists:categories,id',
'note' => 'nullable|string|max:255',
'date' => 'required|date',
]);
$expense->update($validated);
return redirect()->back();
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Expense $expense)
{
$this->authorize('delete', $expense);
$expense->delete();
return redirect()->back();
}
public function destroyBatch(Request $request)
{
$request->validate(['ids' => 'required|array']);
Expense::whereIn('id', $request->ids)
->where('user_id', auth()->id())
->delete();
return redirect()->back();
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Http\Controllers;
use App\Models\Expense;
use App\Models\Category;
use App\Models\CategoryRule;
use Illuminate\Http\Request;
class InvoiceImportController extends Controller
{
// ── 自動偵測格式 ──
private function detectFormat(array $firstRow): string
{
// AndroMoney 第一行是 "Windows Excel", "理財幫手AndroMoney", 日期
if (isset($firstRow[1]) && str_contains($firstRow[1], 'AndroMoney')) {
return 'andromoney';
}
return 'invoice';
}
// ── 取得或建立分類 ──
private function findOrCreateCategory(string $name, ?int $parentId = null): int
{
$category = Category::firstOrCreate(
[
'user_id' => auth()->id(),
'name' => $name,
'parent_id' => $parentId,
],
[
'color' => $this->randomColor(),
]
);
return $category->id;
}
private function randomColor(): string
{
$colors = [
'#EF4444',
'#F59E0B',
'#10B981',
'#3B82F6',
'#8B5CF6',
'#EC4899',
'#14B8A6',
'#F97316',
];
return $colors[array_rand($colors)];
}
// ── 發票格式匯入 ──
private function importInvoice($handle, $rules): array
{
$imported = 0;
$updated = 0;
while (($row = fgetcsv($handle)) !== false) {
if (count($row) < 8) continue;
$invoiceNumber = trim($row[2] ?? '');
$date = trim($row[1] ?? '');
$amount = trim($row[3] ?? '0');
$sellerName = trim($row[7] ?? '');
$itemName = trim($row[13] ?? '');
if (empty($invoiceNumber)) continue;
if ((float)$amount < 0) {
$updated++;
continue;
}
$formattedDate = null;
if (strlen($date) === 8) {
$formattedDate = substr($date, 0, 4) . '-'
. substr($date, 4, 2) . '-'
. substr($date, 6, 2);
}
$exists = Expense::where('user_id', auth()->id())
->where('invoice_number', $invoiceNumber)
->exists();
if ($exists) {
$updated++;
continue;
}
$categoryId = $this->guessCategory($sellerName, $rules);
$expense = Expense::updateOrCreate(
[
'user_id' => auth()->id(),
'external_id' => $invoiceNumber,
],
[
'type' => 'expense',
'amount' => (float)$amount,
'date' => $formattedDate,
'seller_name' => $sellerName,
'item_name' => $itemName,
'invoice_number' => $invoiceNumber,
'note' => $sellerName,
'category_id' => $categoryId,
]
);
if ($expense->wasRecentlyCreated) {
$imported++;
} else {
$updated++;
}
}
return [$imported, $updated];
}
// ── AndroMoney 格式匯入 ──
private function importAndroMoney($handle): array
{
$imported = 0;
$updated = 0;
// 跳過標題列(第二行)
fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) {
// 轉換 Big5 → UTF-8
$row = array_map(fn($val) => mb_convert_encoding($val, 'UTF-8', 'Big5'), $row);
if (count($row) < 6) continue;
// 欄位對應
// A=0:id, B=1:幣別, C=2:金額, D=3:分類, E=4:子分類
// F=5:日期, G=6:付款, H=7:收款, I=8:備註, M=12:商家, N=13:uid
$amount = trim($row[2] ?? '0');
$catName = trim($row[3] ?? '');
$subCatName = trim($row[4] ?? '');
$date = trim($row[5] ?? '');
$payOut = trim($row[6] ?? '');
$payIn = trim($row[7] ?? '');
$note = trim($row[8] ?? '');
$sellerName = trim($row[13] ?? '');
// 過濾掉看起來像 uid 的值32碼以上的hex字串
if (preg_match('/^[0-9a-f]{20,}$/i', $note)) {
$note = '';
}
if (empty($amount) || empty($date)) continue;
if ((float)$amount == 0) continue;
// 日期格式轉換 20260106 → 2026-01-06
$formattedDate = null;
if (strlen($date) === 8 && is_numeric($date)) {
$formattedDate = substr($date, 0, 4) . '-'
. substr($date, 4, 2) . '-'
. substr($date, 6, 2);
}
// 日期無效就跳過
if (!$formattedDate) {
$updated++;
continue;
}
// 判斷收入/支出
// 付款欄有值 = 支出,收款欄有值 = 收入
$type = !empty($payIn) ? 'income' : 'expense';
// 取得或建立主分類
$categoryId = null;
if (!empty($catName)) {
$categoryId = $this->findOrCreateCategory($catName);
}
// 取得或建立子分類(掛在主分類下)
$subcategoryId = null;
if (!empty($subCatName) && $categoryId) {
$subcategoryId = $this->findOrCreateCategory($subCatName, $categoryId);
}
// uid 是 row[12]
$uid = trim($row[12] ?? '');
$expense = Expense::updateOrCreate(
[
'user_id' => auth()->id(),
'external_id' => $uid ?: null,
],
[
'type' => $type,
'amount' => abs((float)$amount),
'date' => $formattedDate,
'category_id' => $categoryId,
'subcategory_id' => $subcategoryId,
'note' => $note ?: $sellerName,
'seller_name' => $sellerName,
]
);
if ($expense->wasRecentlyCreated) {
$imported++;
} else {
$updated++;
}
}
return [$imported, $updated];
}
// ── 自動分類(發票用)──
private function guessCategory($sellerName, $rules)
{
foreach ($rules as $rule) {
if (str_contains($sellerName, $rule->keyword)) {
return $rule->category_id;
}
}
return null;
}
public function index()
{
return inertia('Expenses/Import');
}
public function store(Request $request)
{
$request->validate([
'file' => 'required|file|mimes:csv,txt',
]);
$file = $request->file('file');
$handle = fopen($file->getPathname(), 'r');
// 讀第一行偵測格式
$firstRow = fgetcsv($handle);
$format = $this->detectFormat($firstRow);
$rules = CategoryRule::with('category')
->where('user_id', auth()->id())
->get();
if ($format === 'andromoney') {
[$imported, $updated] = $this->importAndroMoney($handle);
} else {
[$imported, $updated] = $this->importInvoice($handle, $rules);
}
fclose($handle);
$formatName = $format === 'andromoney' ? 'AndroMoney' : '發票';
return redirect()->route('expenses.index')->with([
'message' => "{$formatName}】匯入完成!新增 {$imported} 筆,更新 {$updated}",
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): Response
{
return Inertia::render('Profile/Edit', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => session('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that is loaded on the first page visit.
*
* @var string
*/
protected $rootView = 'app';
/**
* Determine the current asset version.
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user(),
],
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

25
app/Models/Category.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
protected $fillable = ['user_id', 'parent_id', 'name', 'color', 'icon'];
public function parent()
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children()
{
return $this->hasMany(Category::class, 'parent_id');
}
public function expenses()
{
return $this->hasMany(Expense::class);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CategoryRule extends Model
{
protected $fillable = ['user_id', 'category_id', 'keyword'];
public function category()
{
return $this->belongsTo(Category::class);
}
}

42
app/Models/Expense.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Expense extends Model
{
protected $fillable = [
'user_id',
'category_id',
'subcategory_id',
'type',
'amount',
'note',
'date',
'invoice_number',
'seller_name',
'item_name',
'external_id',
];
protected $casts = [
'date' => 'date:Y-m-d',
'amount' => 'decimal:2',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function category()
{
return $this->belongsTo(Category::class);
}
public function subcategory()
{
return $this->belongsTo(Category::class, 'subcategory_id');
}
}

48
app/Models/User.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Policies;
use App\Models\Category;
use App\Models\User;
class CategoryPolicy
{
public function update(User $user, Category $category): bool
{
return $user->id === $category->user_id;
}
public function delete(User $user, Category $category): bool
{
return $user->id === $category->user_id;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\Expense;
use App\Models\User;
class ExpensePolicy
{
public function update(User $user, Expense $expense): bool
{
return $user->id === $expense->user_id;
}
public function delete(User $user, Expense $expense): bool
{
return $user->id === $expense->user_id;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Vite::prefetch(concurrency: 3);
}
}