mirror of
https://github.com/henry4682/finance_app.git
synced 2026-07-16 08:20:00 +00:00
buid new app
This commit is contained in:
52
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
52
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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')]);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
69
app/Http/Controllers/Auth/NewPasswordController.php
Normal 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)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/Http/Controllers/Auth/PasswordController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
51
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal 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)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/CategoryController.php
Normal file
48
app/Http/Controllers/CategoryController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
37
app/Http/Controllers/CategoryRuleController.php
Normal file
37
app/Http/Controllers/CategoryRuleController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
57
app/Http/Controllers/DashboardController.php
Normal file
57
app/Http/Controllers/DashboardController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/ExpenseController.php
Normal file
114
app/Http/Controllers/ExpenseController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
258
app/Http/Controllers/InvoiceImportController.php
Normal file
258
app/Http/Controllers/InvoiceImportController.php
Normal 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} 筆",
|
||||
]);
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/ProfileController.php
Normal file
63
app/Http/Controllers/ProfileController.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
39
app/Http/Middleware/HandleInertiaRequests.php
Normal file
39
app/Http/Middleware/HandleInertiaRequests.php
Normal 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(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
85
app/Http/Requests/Auth/LoginRequest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/ProfileUpdateRequest.php
Normal file
30
app/Http/Requests/ProfileUpdateRequest.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user