diff --git a/app/Http/Controllers/Admin/CityController.php b/app/Http/Controllers/Admin/CityController.php index 121eb8d..4b74717 100644 --- a/app/Http/Controllers/Admin/CityController.php +++ b/app/Http/Controllers/Admin/CityController.php @@ -16,20 +16,29 @@ class CityController extends Controller $sortType = $request->input('sort_type', 'asc'); $page = $request->get('page', 1); + $menuAccessService = app(\App\Services\MenuAccessService::class); + + // メニューアクセス制御: 非ソーリンユーザーは所属自治体のみ表示 $query = City::query(); + if (!$menuAccessService->isSorin()) { + $operator = auth()->user(); + if ($operator && isset($operator->management_id)) { + $query->where('management_id', $operator->management_id); + } + } if ($request->filled('city_name')) { $query->where('city_name', 'like', '%' . $request->input('city_name') . '%'); } - // 排序处理 + // ソート処理 if (!empty($sort)) { $query->orderBy($sort, $sortType); } $list = $query->paginate(20); - // 页码越界处理 + // インデックス超過処理 if ($list->total() > 0 && $page > $list->lastPage()) { return redirect()->route('city', [ 'sort' => $sort, @@ -109,6 +118,12 @@ class CityController extends Controller abort(404); } + // メニューアクセス制御確認 + $menuAccessService = app(\App\Services\MenuAccessService::class); + if (!$menuAccessService->canAccessCity($city->city_id)) { + abort(403, 'この自治体へのアクセス権限がありません。'); + } + if ($request->isMethod('POST')) { $rules = [ 'city_name' => ['required', 'string', 'max:10', 'regex:/^[^ -~。-゚]+$/u'], @@ -171,4 +186,53 @@ class CityController extends Controller return redirect()->route('city')->with('error', __('削除に失敗しました。')); } } + + /** + * 自治体ダッシュボード + */ + public function dashboard(Request $request, $city_id) + { + $city = City::find($city_id); + if (!$city) { + return redirect()->route('city')->with('error', '指定された自治体が見つかりません。'); + } + + // この自治体に関連する駐輪場データを取得 + $parks = \App\Models\Park::where('city_id', $city_id)->get(); + $parkIds = $parks->pluck('park_id')->toArray(); + + // この自治体の統計情報を取得 + $contractsCount = 0; + $usersCount = 0; + $waitingCount = 0; + + if (!empty($parkIds)) { + // 契約数を取得 + $contractsCount = \App\Models\RegularContract::whereIn('park_id', $parkIds)->count(); + + // この自治体の駐輪場で契約しているユニークユーザー数を取得 + $userIds = \App\Models\RegularContract::whereIn('park_id', $parkIds) + ->distinct() + ->pluck('user_id') + ->toArray(); + $usersCount = count(array_filter($userIds)); + + // 予約待ち人数を取得(valid_flag = 1 かつ reserve_order が設定されているもの) + $waitingCount = DB::table('reserve') + ->whereIn('park_id', $parkIds) + ->where('valid_flag', 1) + ->whereNotNull('reserve_order') + ->where('reserve_order', '>', 0) + ->count(); + } + + $stats = [ + 'parks_count' => $parks->count(), + 'contracts_count' => $contractsCount, + 'users_count' => $usersCount, + 'waiting_count' => $waitingCount, + ]; + + return view('admin.CityMaster.dashboard', compact('city', 'parks', 'stats')); + } } \ No newline at end of file diff --git a/app/Http/Controllers/Admin/InformationController.php b/app/Http/Controllers/Admin/InformationController.php index 0b87019..05cfaaa 100644 --- a/app/Http/Controllers/Admin/InformationController.php +++ b/app/Http/Controllers/Admin/InformationController.php @@ -56,6 +56,12 @@ class InformationController extends Controller return view('admin.information.list', compact('jobs','period','type','status')); } + // ダッシュボード表示 + public function dashboard(Request $request) + { + return view('admin.information.dashboard'); + } + // ステータス一括更新(着手=2 / 対応完了=3) public function updateStatus(Request $request) { diff --git a/app/Http/Controllers/Admin/OpeController.php b/app/Http/Controllers/Admin/OpeController.php index d1e6c88..183b888 100644 --- a/app/Http/Controllers/Admin/OpeController.php +++ b/app/Http/Controllers/Admin/OpeController.php @@ -1,4 +1,5 @@ isMethod('get')) { - - return view('admin.opes.add', [ + // ※機能(画面)一覧を取得(プルダウン用) + $features = Feature::query() + ->orderBy('id') + ->get(['id', 'name']); + // ※操作権限一覧を取得(チェックボックス用) + $permissions = Permission::query() + ->orderBy('id') + ->get(['id', 'code', 'name']); + + if ($request->isMethod('get')) { + return view('admin.opes.add', [ 'isEdit' => false, - 'record' => new Ope(), - 'ope_id' => null, - 'ope_name' => '', - 'ope_type' => '', - 'ope_mail' => '', - 'ope_phone'=> '', + 'record' => new Ope(), + + 'ope_id' => null, + 'ope_name' => '', + 'ope_type' => '', + 'ope_mail' => '', + 'ope_phone' => '', 'ope_sendalart_que1' => 0, 'ope_sendalart_que2' => 0, 'ope_sendalart_que3' => 0, 'ope_sendalart_que4' => 0, 'ope_sendalart_que5' => 0, 'ope_sendalart_que6' => 0, 'ope_sendalart_que7' => 0, 'ope_sendalart_que8' => 0, 'ope_sendalart_que9' => 0, - 'ope_sendalart_que10'=> 0, 'ope_sendalart_que11'=> 0, 'ope_sendalart_que12'=> 0, - 'ope_sendalart_que13'=> 0, + 'ope_sendalart_que10' => 0, 'ope_sendalart_que11' => 0, 'ope_sendalart_que12' => 0, + 'ope_sendalart_que13' => 0, 'ope_auth1' => '', 'ope_auth2' => '', 'ope_auth3' => '', 'ope_auth4' => '', 'ope_quit_flag' => 0, 'ope_quitday' => '', + + // ▼追加:権限設定UI用 + 'features' => $features, + 'permissions' => $permissions, + 'selectedFeatureId' => old('feature_id', null), ]); } @@ -96,36 +113,73 @@ class OpeController extends Controller return redirect()->route('opes')->with('success', '登録しました。'); } - - /** * 編集(GET 画面 / POST 更新) + * ※権限(自治体×機能×操作)も同画面で設定する */ public function edit($id, Request $request) { $ope = Ope::getByPk($id); if (!$ope) abort(404); + // ※機能(画面)一覧を取得(プルダウン用) + $features = Feature::query() + ->orderBy('id') + ->get(['id', 'name']); + + // ※操作権限一覧を取得(チェックボックス用) + $permissions = Permission::query() + ->orderBy('id') + ->get(['id', 'code', 'name']); + + // ※自治体ID(opeに紐づく想定) + $municipalityId = (int)($ope->municipality_id ?? 0); + if ($request->isMethod('get')) { return view('admin.opes.edit', [ 'isEdit' => true, 'record' => $ope, + + // ▼追加:権限設定UI用 + 'features' => $features, + 'permissions' => $permissions, + 'selectedFeatureId' => old('feature_id', null), ]); } + /** + * ▼権限設定の保存(feature_id + permission_ids[]) + * ※画面側の保存ボタンを「権限も同時保存」にする場合はここで処理する + * ※もし「基本情報の更新」と「権限更新」をボタンで分けたい場合は、別アクションに分離推奨 + */ + if ($request->has('feature_id')) { + $request->validate([ + 'feature_id' => ['required', 'integer', 'exists:features,id'], + 'permission_ids' => ['nullable', 'array'], + 'permission_ids.*' => ['integer', 'exists:permissions,id'], + ]); + + $featureId = (int)$request->input('feature_id'); + $permissionIds = array_map('intval', (array)$request->input('permission_ids', [])); + + DB::transaction(function () use ($municipalityId, $featureId, $permissionIds) { + // ※機能単位で置換(自治体単位) + OpePermission::replaceByFeature($municipalityId, $featureId, $permissionIds); + }); + } + // 入力値を一旦取得 $data = $request->all(); // --- バリデーション --- $rules = [ - 'login_id' => "required|string|max:255|unique:ope,login_id,{$id},ope_id", // 編集時は自分を除外 + 'login_id' => "required|string|max:255|unique:ope,login_id,{$id},ope_id", 'ope_name' => 'required|string|max:255', 'ope_type' => 'required|string|max:50', 'ope_phone' => 'nullable|string|max:50', 'ope_mail' => [ 'required', function ($attribute, $value, $fail) { - // , でも ; でもOKにする $emails = array_map('trim', explode(';', str_replace(',', ';', $value))); foreach ($emails as $email) { if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { @@ -134,7 +188,7 @@ class OpeController extends Controller } } ], - 'password' => 'nullable|string|min:8|confirmed', // 編集時は任意 + 'password' => 'nullable|string|min:8|confirmed', ]; $request->validate($rules); @@ -157,18 +211,42 @@ class OpeController extends Controller return redirect()->route('opes')->with('success', '更新しました。'); } + /** + * 権限回顧(AJAX) + * /opes/{id}/permissions?feature_id=xx + * ※ope_permissionが自治体単位のため、opeの自治体IDで取得する + */ + public function getPermissionsByFeature(int $id, Request $request) + { + $ope = Ope::getByPk($id); + if (!$ope) abort(404); + + $featureId = (int)$request->query('feature_id'); + if ($featureId <= 0) { + return response()->json([]); + } + + $municipalityId = (int)($ope->municipality_id ?? 0); + + $ids = OpePermission::query() + ->where('municipality_id', $municipalityId) + ->where('feature_id', $featureId) + ->pluck('permission_id') + ->values(); + + return response()->json($ids); + } /** * 削除(単体 or 複数) */ - public function delete(Request $request) { $ids = []; // 単体削除 if ($request->filled('id')) { - $ids[] = (int) $request->input('id'); + $ids[] = (int)$request->input('id'); } // 複数削除 diff --git a/app/Http/Controllers/Auth/EmailOtpController.php b/app/Http/Controllers/Auth/EmailOtpController.php new file mode 100644 index 0000000..fad9c6f --- /dev/null +++ b/app/Http/Controllers/Auth/EmailOtpController.php @@ -0,0 +1,138 @@ +otpService = $otpService; + } + + /** + * OTP 入力フォームを表示 + * + * ログイン直後、ユーザーに6桁の OTP コードを入力させるページを表示します + * メールアドレスはマスク表示(例:a***@example.com) + */ + public function show(Request $request) + { + /** @var Ope */ + $user = $request->user(); + + // メールアドレスをマスク(最初の1文字のみ表示) + $maskedEmail = $this->otpService->maskEmail($user->ope_mail); + + // 次の重発までの待機時間 + $resendWaitSeconds = $this->otpService->getResendWaitSeconds($user); + + return view('auth.otp', [ + 'maskedEmail' => $maskedEmail, + 'resendWaitSeconds' => $resendWaitSeconds, + ]); + } + + /** + * OTP コード検証 + * + * ユーザーが入力した6桁のコードを検証します + * + * 成功時:email_otp_verified_at を更新し、ホームページにリダイレクト + * 失敗時:エラーメッセージと共に OTP 入力フォームに戻す + */ + public function verify(Request $request) + { + // 入力値を検証 + $validated = $this->validate($request, [ + 'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'], + ], [ + 'code.required' => 'OTPコードは必須です。', + 'code.size' => 'OTPコードは6桁である必要があります。', + 'code.regex' => 'OTPコードは6桁の数字である必要があります。', + ]); + + /** @var Ope */ + $user = $request->user(); + + // OTP コードを検証 + if ($this->otpService->verify($user, $validated['code'])) { + // 検証成功:ホームページにリダイレクト + return redirect()->intended(route('home')) + ->with('success', 'OTP認証が完了しました。'); + } + + // 検証失敗:エラーメッセージと共に戻す + return back() + ->withInput() + ->with('error', '無効なまたは有効期限切れのOTPコードです。'); + } + + /** + * OTP コード再送 + * + * ユーザーが OTP コード再送をリクエストした場合に実行 + * 60秒以内の連続再送はブロックします + */ + public function resend(Request $request) + { + /** @var Ope */ + $user = $request->user(); + + // 重発可能か確認 + if (!$this->otpService->canResend($user)) { + $waitSeconds = $this->otpService->getResendWaitSeconds($user); + + return back()->with('error', "後 {$waitSeconds} 秒待機してからリクエストしてください。"); + } + + try { + // 新しい OTP コードを発行 + $otpCode = $this->otpService->issue($user); + + // ope_mail はセミコロン区切りで複数アドレスを保持する可能性があるため、最初のアドレスのみ抽出 + $operatorEmails = explode(';', trim($user->ope_mail)); + $primaryEmail = trim($operatorEmails[0] ?? $user->ope_mail); + + Log::info('OTP 再送メール送信開始: ' . $primaryEmail); + + // メール送信 + Mail::to($primaryEmail)->send(new EmailOtpMail( + $otpCode, + $user->name ?? 'ユーザー' + )); + + Log::info('OTP 再送メール送信完了: ' . $primaryEmail); + + return back()->with('success', 'OTPコードを再送信しました。'); + } catch (\Exception $e) { + Log::error('OTP resend error: ' . $e->getMessage(), [ + 'exception' => $e, + 'user_id' => $user->ope_id ?? null, + 'user_email' => $user->ope_mail ?? null, + ]); + + return back()->with('error', 'OTP送信に失敗しました。もう一度お試しください。'); + } + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 9f8bce6..18ed0ba 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -3,8 +3,12 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Mail\EmailOtpMail; +use App\Services\EmailOtpService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -135,6 +139,10 @@ class LoginController extends Controller /** * ログイン成功時のレスポンス * + * OTP認証チェック: + * - 24時間以内に OTP 認証済みの場合:/home にリダイレクト + * - 未認証の場合:OTP メール送信 → /otp にリダイレクト + * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse */ @@ -147,7 +155,45 @@ class LoginController extends Controller // ここで保持する値も login_id(入力名は ope_id のまま) $request->session()->put('login_ope_id', $request->input('ope_id')); - return redirect()->intended($this->redirectTo); + // OTP認証チェック + $otpService = app(EmailOtpService::class); + $user = Auth::user(); + + // 24時間以内に OTP 認証済みの場合 + if ($otpService->isOtpRecent($user)) { + return redirect()->intended($this->redirectTo); + } + + // OTP 未認証の場合:OTP コード発行 → メール送信 → /otp にリダイレクト + try { + $otpCode = $otpService->issue($user); + + // ope_mail はセミコロン区切りで複数アドレスを保持する可能性があるため、最初のアドレスのみ抽出 + $operatorEmails = explode(';', trim($user->ope_mail)); + $primaryEmail = trim($operatorEmails[0] ?? $user->ope_mail); + + Log::info('OTP メール送信開始: ' . $primaryEmail); + + Mail::to($primaryEmail)->send(new EmailOtpMail( + $otpCode, + $user->name ?? 'ユーザー' + )); + + Log::info('OTP メール送信完了: ' . $primaryEmail); + + return redirect()->route('otp.show') + ->with('info', 'OTP認証コードをメール送信しました。'); + } catch (\Exception $e) { + Log::error('OTP issue/send failed: ' . $e->getMessage(), [ + 'exception' => $e, + 'user_id' => $user->ope_id ?? null, + 'user_email' => $user->ope_mail ?? null, + ]); + + // メール送信エラー時は home にリダイレクトするか、カスタムエラーを返す + return redirect($this->redirectTo) + ->with('warning', 'OTP認証メールの送信に失敗しました。'); + } } /** diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 8bcdd39..04f7ffe 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; +use App\Models\City; +use App\Services\MenuAccessService; class HomeController extends Controller { @@ -22,10 +24,15 @@ class HomeController extends Controller * アプリケーションのダッシュボードを表示 * 認証後のホーム画面 * + * @param MenuAccessService $menuAccessService メニューアクセス制御サービス * @return \Illuminate\Http\Response */ - public function index() + public function index(MenuAccessService $menuAccessService) { - return view('home'); + // ログイン中のオペレータが表示可能な自治体一覧を取得 + $visibleCities = $menuAccessService->visibleCities(); + $isSorin = $menuAccessService->isSorin(); + + return view('home', compact('visibleCities', 'isSorin')); } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/EnsureOtpVerified.php b/app/Http/Middleware/EnsureOtpVerified.php new file mode 100644 index 0000000..44f8b75 --- /dev/null +++ b/app/Http/Middleware/EnsureOtpVerified.php @@ -0,0 +1,57 @@ +otpService = $otpService; + } + + /** + * リクエストを処理 + * + * @param Request $request + * @param Closure $next + * @return Response + */ + public function handle(Request $request, Closure $next): Response + { + // ユーザーが認証されていない場合はスキップ + if (!$request->user()) { + return $next($request); + } + + // OTP ページ関連のリクエストはスキップ(無限ループ防止) + // /otp /* のパターンを許可 + if ($request->routeIs(['otp.show', 'otp.verify', 'otp.resend'])) { + return $next($request); + } + + // 24時間以内に OTP 認証が完了している場合はスキップ + if ($this->otpService->isOtpRecent($request->user())) { + return $next($request); + } + + // OTP 認証が必要な場合は OTP ページにリダイレクト + return redirect()->route('otp.show') + ->with('info', 'セキュリティ確認のため OTP 認証が必要です。'); + } +} diff --git a/app/Http/Middleware/ShareMenuAccessData.php b/app/Http/Middleware/ShareMenuAccessData.php new file mode 100644 index 0000000..bca3c9f --- /dev/null +++ b/app/Http/Middleware/ShareMenuAccessData.php @@ -0,0 +1,90 @@ + $menuAccessService->isSorin(), + 'visibleCities' => $menuAccessService->visibleCities(), + ]; + + // Nav bar に表示される ハード異常・タスク件数を取得 + if (auth()->check()) { + // ハード異常(que_class > 99)かつステータスが未対応(1)または進行中(2) + $hardwareIssues = DB::table('operator_que as oq') + ->leftJoin('user as u', 'oq.user_id', '=', 'u.user_id') + ->leftJoin('park as p', 'oq.park_id', '=', 'p.park_id') + ->select( + 'oq.que_id', 'oq.que_class', 'oq.que_comment', + 'oq.created_at', 'oq.updated_at', 'oq.que_status' + ) + ->where('oq.que_class', '>', 99) + ->whereIn('oq.que_status', [1, 2]) + ->orderBy('oq.created_at', 'DESC') + ->limit(5) + ->get(); + + // タスク(que_class < 99)かつステータスが未対応(1)または進行中(2) + $taskIssues = DB::table('operator_que as oq') + ->leftJoin('user as u', 'oq.user_id', '=', 'u.user_id') + ->leftJoin('park as p', 'oq.park_id', '=', 'p.park_id') + ->select( + 'oq.que_id', 'oq.que_class', 'oq.que_comment', + 'oq.created_at', 'oq.updated_at', 'oq.que_status' + ) + ->where('oq.que_class', '<', 99) + ->whereIn('oq.que_status', [1, 2]) + ->orderBy('oq.created_at', 'DESC') + ->limit(5) + ->get(); + + // ハード異常・タスク件数計算 + $hardCount = DB::table('operator_que') + ->where('que_class', '>', 99) + ->whereIn('que_status', [1, 2]) + ->count(); + + $taskCount = DB::table('operator_que') + ->where('que_class', '<', 99) + ->whereIn('que_status', [1, 2]) + ->count(); + + // 最新のハード異常・タスク日時 + $hardLatest = $hardwareIssues->first()?->created_at; + $taskLatest = $taskIssues->first()?->created_at; + + // Nav bar 関連データをマージ + $viewData = array_merge($viewData, [ + 'hardCount' => $hardCount, + 'hardLatest' => $hardLatest, + 'latestHards' => $hardwareIssues, + 'taskCount' => $taskCount, + 'taskLatest' => $taskLatest, + 'latestTasks' => $taskIssues, + ]); + } + + // すべてのビューでこれらのデータが利用可能 + View::share($viewData); + + return $next($request); + } +} diff --git a/app/Mail/EmailOtpMail.php b/app/Mail/EmailOtpMail.php new file mode 100644 index 0000000..c4c0906 --- /dev/null +++ b/app/Mail/EmailOtpMail.php @@ -0,0 +1,69 @@ +otpCode = $otpCode; + $this->operatorName = $operatorName; + } + + /** + * メールのエンベロープ + */ + public function envelope(): Envelope + { + return new Envelope( + subject: 'ログイン確認用OTPコード(有効期限:10分間)' + ); + } + + /** + * メールのコンテンツ + */ + public function content(): Content + { + return new Content( + view: 'emails.otp', + with: [ + 'otpCode' => $this->otpCode, + 'operatorName' => $this->operatorName, + ] + ); + } + + /** + * メールの添付ファイル + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/City.php b/app/Models/City.php index ccfbd51..080f8d7 100644 --- a/app/Models/City.php +++ b/app/Models/City.php @@ -16,6 +16,7 @@ class City extends Model 'print_layout', 'city_user', 'city_remarks', + 'management_id', 'created_at', 'updated_at', ]; @@ -26,11 +27,40 @@ class City extends Model public static function getList(?int $operatorId = null): array { return static::query() - ->when($operatorId, fn ($q) => $q->where('operator_id', $operatorId)) + ->when($operatorId, fn($q) => $q->where('operator_id', $operatorId)) ->orderBy('city_name') ->pluck('city_name', 'city_id') ->toArray(); } - - -} + + /** + * この都市が属する運営元を取得 + */ + public function management(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Management::class, 'management_id', 'management_id'); + } + + /** + * 自治体別ダッシュボード画面の表示 + * + * 指定された city_id に基づいて都市情報を取得し、 + * 該当データが存在しない場合は 404 エラーを返します。 + * 正常に取得できた場合は、ダッシュボード画面を表示します。 + * + * @param int $city_id 都市ID + * @return \Illuminate\View\View + */ + public function dashboard($city_id) + { + $city = City::find($city_id); + if (!$city) { + abort(404); + } + + // ここに自治体別ダッシュボードの処理を書く + return view('admin.CityMaster.dashboard', [ + 'city' => $city, + ]); + } +} diff --git a/app/Models/Feature.php b/app/Models/Feature.php new file mode 100644 index 0000000..a1cb981 --- /dev/null +++ b/app/Models/Feature.php @@ -0,0 +1,23 @@ +belongsTo(Management::class, 'management_id', 'management_id'); + } } \ No newline at end of file diff --git a/app/Models/OpePermission.php b/app/Models/OpePermission.php new file mode 100644 index 0000000..2382ac2 --- /dev/null +++ b/app/Models/OpePermission.php @@ -0,0 +1,65 @@ +where('municipality_id', $municipalityId) + ->where('feature_id', $featureId) + ->delete(); + + // ※新規追加 + $permissionIds = array_values(array_unique(array_map('intval', $permissionIds))); + foreach ($permissionIds as $pid) { + self::create([ + 'municipality_id' => $municipalityId, + 'feature_id' => $featureId, + 'permission_id' => $pid, + ]); + } + } + + /** + * 付与済み権限ID一覧を取得(自治体単位) + */ + public static function getPermissionIds( + int $municipalityId, + int $featureId + ): array { + return self::query() + ->where('municipality_id', $municipalityId) + ->where('feature_id', $featureId) + ->pluck('permission_id') + ->map(fn ($v) => (int)$v) + ->toArray(); + } +} diff --git a/app/Models/Permission.php b/app/Models/Permission.php new file mode 100644 index 0000000..d54739c --- /dev/null +++ b/app/Models/Permission.php @@ -0,0 +1,32 @@ +where('code', $code)->first(['id']); + return $row?->id; + } +} diff --git a/app/Models/management.php b/app/Models/management.php new file mode 100644 index 0000000..33b71dd --- /dev/null +++ b/app/Models/management.php @@ -0,0 +1,62 @@ + 'integer', + 'municipality_flag' => 'integer', + 'government_approval_required' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** 自治体かどうかを判定 */ + public function isMunicipality(): bool + { + return (int)$this->municipality_flag === 1; + } + + /** 役所承認が必要かどうか */ + public function requiresGovernmentApproval(): bool + { + return (int)$this->government_approval_required === 1; + } + + /** + * この運営元に属する自治体を取得 + */ + public function cities(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(City::class, 'management_id', 'management_id'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8d51665..85279e3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use App\Services\ShjNineService; use App\Services\ShjTenService; use App\Services\ShjSixService; use Illuminate\Support\Facades\DB; +use App\Services\MenuAccessService; class AppServiceProvider extends ServiceProvider { @@ -109,10 +110,15 @@ class AppServiceProvider extends ServiceProvider ->limit(5) ->get(); + $menuAccessService = app(MenuAccessService::class); + $isSorin = $menuAccessService->isSorin(); + $visibleCities = $menuAccessService->visibleCities(); + $view->with(compact( 'taskCount','taskLatest', 'hardCount','hardLatest', - 'latestTasks','latestHards' + 'latestTasks','latestHards', + 'isSorin', 'visibleCities' )); }); } diff --git a/app/Services/EmailOtpService.php b/app/Services/EmailOtpService.php new file mode 100644 index 0000000..6ecf05f --- /dev/null +++ b/app/Services/EmailOtpService.php @@ -0,0 +1,148 @@ +email_otp_code_hash = Hash::make($plainCode); + $ope->email_otp_expires_at = now()->addMinutes(self::OTP_VALIDITY_MINUTES); + $ope->email_otp_last_sent_at = now(); + $ope->save(); + + return $plainCode; + } + + /** + * OTP コードを検証 + * + * 入力されたコードが正しく、且つ有効期限内かを確認します + * + * @param Ope $ope オペレータモデル + * @param string $inputCode ユーザーが入力したコード + * @return bool 検証結果 + */ + public function verify(Ope $ope, string $inputCode): bool + { + // 有効期限チェック + if (!$ope->email_otp_expires_at || $ope->email_otp_expires_at < now()) { + return false; + } + + // hash化されたコードと比較 + if (!Hash::check($inputCode, $ope->email_otp_code_hash)) { + return false; + } + + // 検証成功:検証完了時刻を記録し、OTPコードをクリア + $ope->email_otp_verified_at = now(); + $ope->email_otp_code_hash = null; + $ope->email_otp_expires_at = null; + $ope->save(); + + return true; + } + + /** + * OTP が最近検証されたかを確認(24時間以内) + * + * @param Ope $ope オペレータモデル + * @return bool true: 24時間以内に検証済み、false: 未検証または24時間以上経過 + */ + public function isOtpRecent(Ope $ope): bool + { + if (!$ope->email_otp_verified_at) { + return false; + } + + return $ope->email_otp_verified_at > now()->subHours(self::OTP_FREE_PERIOD_HOURS); + } + + /** + * 重発可能かを確認 + * + * 前回送信から60秒以上経過しているかを確認します + * + * @param Ope $ope オペレータモデル + * @return bool true: 重発可能、false: 待機中 + */ + public function canResend(Ope $ope): bool + { + if (!$ope->email_otp_last_sent_at) { + return true; + } + + return $ope->email_otp_last_sent_at <= now()->subSeconds(self::OTP_RESEND_DELAY_SECONDS); + } + + /** + * 次の重発までの待機時間を取得(秒) + * + * @param Ope $ope オペレータモデル + * @return int 待機時間(秒)、0以下は重発可能 + */ + public function getResendWaitSeconds(Ope $ope): int + { + if (!$ope->email_otp_last_sent_at) { + return 0; + } + + $diffSeconds = now()->diffInSeconds($ope->email_otp_last_sent_at, absolute: false); + $waitSeconds = self::OTP_RESEND_DELAY_SECONDS - abs($diffSeconds); + + return max(0, $waitSeconds); + } + + /** + * OTP コードのマスク済みメールアドレスを取得 + * + * abc@example.com -> abc***@example.com のようなマスク表示用 + * + * @param string $email メールアドレス + * @return string マスク済みメールアドレス + */ + public function maskEmail(string $email): string + { + [$name, $domain] = explode('@', $email); + + // 名前部分の最初の1文字を残して、残りは*でマスク + $maskedName = substr($name, 0, 1) . str_repeat('*', max(0, strlen($name) - 1)); + + return "{$maskedName}@{$domain}"; + } +} diff --git a/app/Services/MenuAccessService.php b/app/Services/MenuAccessService.php new file mode 100644 index 0000000..74e5557 --- /dev/null +++ b/app/Services/MenuAccessService.php @@ -0,0 +1,133 @@ +getAuthOperator(); + if (!$operator || !isset($operator->management_id)) { + return null; + } + + return Management::find($operator->management_id); + } + + /** + * ログイン中のオペレータがソーリンであるか判定 + * + * ソーリン(management_name === 'ソーリン')の場合は全メニュー表示可能 + * + * @return bool true: ソーリン, false: その他 + */ + public function isSorin(): bool + { + $management = $this->getAuthManagement(); + if (!$management) { + return false; + } + + return $management->management_name === 'ソーリン'; + } + + /** + * オペレータが表示可能な自治体リストを取得 + * + * ソーリン: 全自治体(削除フラグなどは既存仕様に合わせる) + * その他: ope.city_id で指定された1つの自治体のみ + * + * @return Collection + */ + public function visibleCities(): Collection + { + if ($this->isSorin()) { + return City::query() + ->orderBy('city_name') + ->get(); + } + + // 非ソーリン時は ope.city_id で指定された自治体のみ + $operator = $this->getAuthOperator(); + if (!$operator || !isset($operator->city_id)) { + return collect(); + } + + // ope.city_id に一致する city のみを返す + $city = City::find($operator->city_id); + if (!$city) { + return collect(); + } + + return collect([$city]); + } + + /** + * 指定された自治体へのアクセスが許可されているか判定 + * + * ソーリン: 常に true + * その他: city.management_id == ope.management_id の場合のみ true + * + * @param int $cityId 自治体ID + * @return bool true: アクセス許可, false: アクセス拒否 + */ + public function canAccessCity(int $cityId): bool + { + if ($this->isSorin()) { + return true; + } + + $operator = $this->getAuthOperator(); + if (!$operator || !isset($operator->management_id)) { + return false; + } + + // city.management_id が ope.management_id と一致するか確認 + $city = City::find($cityId); + if (!$city) { + return false; + } + + return (int)$city->management_id === (int)$operator->management_id; + } + + /** + * 複数の自治体へのアクセス許可を一括確認 + * + * @param array $cityIds 自治体IDの配列 + * @return bool すべての自治体へのアクセスが許可されている場合のみ true + */ + public function canAccessCities(array $cityIds): bool + { + foreach ($cityIds as $cityId) { + if (!$this->canAccessCity($cityId)) { + return false; + } + } + + return true; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 8a80778..d0f5fe1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,6 +17,15 @@ return Application::configure(basePath: dirname(__DIR__)) '/shj4a', // SHJ-4A本番用エンドポイント '/webhook/wellnet', // SHJ-4A開発・デバッグ用エンドポイント ]); + + // グローバルミドルウェア登録(すべてのリクエストに適用) + $middleware->append(\App\Http\Middleware\ShareMenuAccessData::class); + + // ミドルウェアエイリアス登録 + $middleware->alias([ + 'check.city.access' => \App\Http\Middleware\CheckCityAccess::class, + 'ensure.otp.verified' => \App\Http\Middleware\EnsureOtpVerified::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..943eeca --- /dev/null +++ b/config/view.php @@ -0,0 +1,50 @@ + [ + resource_path('views'), + ], + + /* + |-------------------------------------------------------------------------- + | Compiled View Path + |-------------------------------------------------------------------------- + | + | This option determines where all the compiled Blade templates will be + | stored for your application. Typically, this is within the storage + | directory. However, as usual, you are free to change this value. + | + */ + + 'compiled' => env( + 'VIEW_COMPILED_PATH', + realpath(storage_path('framework/views')) + ), + + /* + |-------------------------------------------------------------------------- + | Blade Namespace Paths + |-------------------------------------------------------------------------- + | + | Define custom namespace paths for views. This allows you to organize + | views into different directories and reference them using namespace syntax. + | + */ + + 'namespaces' => [ + 'mail' => resource_path('views/emails'), + ], + +]; diff --git a/resources/views/admin/CityMaster/dashboard.blade.php b/resources/views/admin/CityMaster/dashboard.blade.php new file mode 100644 index 0000000..a99201c --- /dev/null +++ b/resources/views/admin/CityMaster/dashboard.blade.php @@ -0,0 +1,265 @@ +@extends('layouts.app') + +@section('title', $city->city_name . ' ダッシュボード') + +@section('content') +
+
+
+
+

{{ $city->city_name }} ダッシュボード

+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+

{{ $stats['parks_count'] }}

+

駐輪場数

+
+
+ +
+ + 詳細を見る + +
+
+ +
+
+
+

{{ number_format($stats['contracts_count']) }}

+

契約数

+
+
+ +
+ + 詳細を見る + +
+
+ +
+
+
+

{{ number_format($stats['users_count']) }}

+

利用者数

+
+
+ +
+ + 詳細を見る + +
+
+ +
+
+
+

{{ number_format($stats['waiting_count']) }}

+

予約待ち人数

+
+
+ +
+ + 詳細を見る + +
+
+
+ + +
+
+
+
+

{{ number_format($parks->sum('park_capacity') ?? 0) }}

+

総収容台数

+
+
+ +
+ +
+
+ +
+
+
+ @php + $totalCapacity = $parks->sum('park_capacity') ?? 0; + $utilizationRate = $totalCapacity > 0 ? round(($stats['contracts_count'] / $totalCapacity) * 100, 1) : 0; + @endphp +

{{ $utilizationRate }}%

+

利用率

+
+
+ +
+ +
+
+ +
+
+
+

{{ number_format($totalCapacity - $stats['contracts_count']) }}

+

空き台数

+
+
+ +
+ +
+
+ +
+
+
+ @php + $waitingRate = $stats['contracts_count'] > 0 ? round(($stats['waiting_count'] / $stats['contracts_count']) * 100, 1) : 0; + @endphp +

{{ $waitingRate }}%

+

予約待ち率

+
+
+ +
+ +
+
+
+ + +
+
+
+
+

{{ $city->city_name }}の駐輪場一覧

+ +
+
+ @if($parks->count() > 0) +
+ + + + + + + + + + + + + @foreach($parks as $park) + + + + + + + + + @endforeach + +
駐輪場ID駐輪場名住所収容台数状態操作
{{ $park->park_id }}{{ $park->park_name }}{{ $park->park_address ?? '-' }}{{ number_format($park->park_capacity ?? 0) }}台 + @if($park->park_status == 1) + 稼働中 + @else + 停止中 + @endif + + + 編集 + +
+
+ @else +
+ この自治体にはまだ駐輪場が登録されていません。 +
+ @endif +
+
+
+
+ + +
+
+
+
+

{{ $city->city_name }}関連のクイックアクション

+
+ +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/admin/information/dashboard.blade.php b/resources/views/admin/information/dashboard.blade.php new file mode 100644 index 0000000..d7a8630 --- /dev/null +++ b/resources/views/admin/information/dashboard.blade.php @@ -0,0 +1,381 @@ +@extends('layouts.app') + +@section('title', '総合ダッシュボード') + +@section('content') +
+
+
+
+

総合ダッシュボード

+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+

全体統計

+
+
+
+
+
+
+

{{ $totalStats['total_cities'] }}

+

自治体数

+
+
+ +
+
+
+
+
+
+

{{ $totalStats['total_parks'] }}

+

駐輪場数

+
+
+ +
+
+
+
+
+
+

{{ number_format($totalStats['total_contracts']) }}

+

総契約数

+
+
+ +
+
+
+
+
+
+

{{ number_format($totalStats['total_waiting']) }}

+

総予約待ち

+
+
+ +
+
+
+
+
+
+

{{ number_format($totalStats['total_capacity']) }}

+

総収容台数

+
+
+ +
+
+
+
+
+
+

{{ $totalStats['total_utilization_rate'] }}%

+

全体利用率

+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+

自治体別統計

+
+ +
+
+
+
+ + + + + + + + + + + + + + + + @foreach($cityStats as $stat) + + + + + + + + + + + + @endforeach + +
自治体名駐輪場数契約数利用者数予約待ち収容台数利用率空き台数操作
+ {{ $stat['city']->city_name }} + + {{ $stat['parks_count'] }} + + {{ number_format($stat['contracts_count']) }} + + {{ number_format($stat['users_count']) }} + + @if($stat['waiting_count'] > 0) + {{ number_format($stat['waiting_count']) }} + @else + 0 + @endif + + {{ number_format($stat['capacity']) }} + + @php + $rate = $stat['utilization_rate']; + $badgeClass = $rate >= 90 ? 'danger' : ($rate >= 70 ? 'warning' : 'success'); + @endphp + {{ $rate }}% + + @if($stat['available_spaces'] > 0) + {{ number_format($stat['available_spaces']) }} + @else + 満車 + @endif + + + 詳細 + +
+
+
+
+
+
+ + +
+
+
+
+

自治体別利用率

+
+
+ +
+
+
+
+
+
+

予約待ち状況

+
+
+ +
+
+
+
+ + +
+
+
+
+

⚠️ 注意が必要な自治体

+
+
+ @php + $alertCities = collect($cityStats)->filter(function($stat) { + return $stat['utilization_rate'] >= 90 || $stat['waiting_count'] > 10; + }); + @endphp + + @if($alertCities->count() > 0) +
+ @foreach($alertCities as $stat) +
+
+
{{ $stat['city']->city_name }}
+ @if($stat['utilization_rate'] >= 90) +

利用率が {{ $stat['utilization_rate'] }}% と高くなっています

+ @endif + @if($stat['waiting_count'] > 10) +

予約待ちが {{ $stat['waiting_count'] }}人 います

+ @endif + 詳細確認 +
+
+ @endforeach +
+ @else +
+ 現在、特に注意が必要な自治体はありません。 +
+ @endif +
+
+
+
+
+
+@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/admin/opes/_form.blade.php b/resources/views/admin/opes/_form.blade.php index 840df74..acbe3b8 100644 --- a/resources/views/admin/opes/_form.blade.php +++ b/resources/views/admin/opes/_form.blade.php @@ -169,7 +169,7 @@ @endfor {{-- ▲ キュー1〜13 --}} - +{{--
@@ -245,7 +245,7 @@
- +--}}
@@ -278,6 +278,51 @@
+ +
+ +
+
+
+ +
+
+ + + +
+ +
+
+
+ @foreach($permissions as $perm) + + @endforeach +
+ + ※ 機能を選択後、現在設定済みの権限が自動で反映されます。 + +
+ + + + {{-- ▼ 下部ボタン --}} @@ -307,3 +352,67 @@ + + + diff --git a/resources/views/admin/seals/list.blade.php b/resources/views/admin/seals/list.blade.php index 4de92d7..e369ed9 100644 --- a/resources/views/admin/seals/list.blade.php +++ b/resources/views/admin/seals/list.blade.php @@ -32,6 +32,26 @@ + +
+ +
+ +
+
+ + + +
@@ -76,6 +96,7 @@
解除 +
diff --git a/resources/views/auth/otp.blade.php b/resources/views/auth/otp.blade.php new file mode 100644 index 0000000..03764c3 --- /dev/null +++ b/resources/views/auth/otp.blade.php @@ -0,0 +1,171 @@ +@extends('layouts.login') + +@section('content') + + + + + +@endsection diff --git a/resources/views/emails/otp.blade.php b/resources/views/emails/otp.blade.php new file mode 100644 index 0000000..b99fc27 --- /dev/null +++ b/resources/views/emails/otp.blade.php @@ -0,0 +1,105 @@ + + + + + + セキュリティ認証 + + + +
+
+

🔐 セキュリティ認証

+
+ +
+

{{ $operatorName }} 様

+ +

いつもご利用いただきありがとうございます。

+ +

ログイン認証用の OTP(ワンタイムパスワード)をお送りしました。

+ +

認証コード

+ +
{{ $otpCode }}
+ +

+ ⏱️ 有効期限:10分間 +

+ +

このコードを入力フォームに入力して、認証を完了してください。

+ +
+ セキュリティに関するご注意: +
    +
  • このコードは絶対に他の方に教えないでください
  • +
  • このメールの送信に心当たりがない場合は、無視してください
  • +
  • 本システムは認証コードをメールで送信することはありません。リンククリックは不要です。
  • +
+
+ +

ご不明な点やサポートが必要な場合は、お気軽にお問い合わせください。

+ +

よろしくお願いいたします。

+
+ + +
+ + diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index fbbab5d..45767ed 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -1,117 +1,159 @@ @extends('layouts.app') @section('title', 'インフォメーション') @section('content') -@php - use App\Models\OperatorLog; - use App\Models\Ope; - $logs = OperatorLog::orderByDesc('created_at')->limit(20)->get(); - $operatorNames = []; - $queLabels = []; - $queIcons = []; - $queClassNums = []; - foreach ($logs as $log) { - // オペレータ名取得 - $operatorNames[$log->operator_id] = $operatorNames[$log->operator_id] ?? (\App\Models\Ope::find($log->operator_id)->ope_name ?? $log->operator_id); - // operator_queからque_class, que_status取得 - $que = null; - if (!empty($log->user_id) && !empty($log->contract_id)) { - $que = \App\Models\OperatorQue::where('user_id', $log->user_id)->where('contract_id', $log->contract_id)->first(); + @php + use App\Models\OperatorLog; + use App\Models\Ope; + $logs = OperatorLog::orderByDesc('created_at')->limit(20)->get(); + $operatorNames = []; + $queLabels = []; + $queIcons = []; + $queClassNums = []; + foreach ($logs as $log) { + // オペレータ名取得 + $operatorNames[$log->operator_id] = + $operatorNames[$log->operator_id] ?? + (\App\Models\Ope::find($log->operator_id)->ope_name ?? $log->operator_id); + // operator_queからque_class, que_status取得 + $que = null; + if (!empty($log->user_id) && !empty($log->contract_id)) { + $que = \App\Models\OperatorQue::where('user_id', $log->user_id) + ->where('contract_id', $log->contract_id) + ->first(); + } + // 条件を満たさない場合はスキップ + if (!$que || !in_array($que->que_status, [3, 4])) { + continue; + } + if ($que->que_class < 100) { + $queLabels[$log->operator_log_id] = 'タスク'; + $queIcons[$log->operator_log_id] = ''; + } elseif ($que->que_class > 99) { + $queLabels[$log->operator_log_id] = 'ハード異常'; + $queIcons[$log->operator_log_id] = ''; + } else { + continue; + } + $queClassNums[$log->operator_log_id] = + \App\Models\OperatorQue::QueClass[$que->que_class] ?? $que->que_class; } - // 条件を満たさない場合はスキップ - if (!$que || !in_array($que->que_status, [3,4])) { - continue; - } - if ($que->que_class < 100) { - $queLabels[$log->operator_log_id] = 'タスク'; - $queIcons[$log->operator_log_id] = ''; - } elseif ($que->que_class > 99) { - $queLabels[$log->operator_log_id] = 'ハード異常'; - $queIcons[$log->operator_log_id] = ''; - } else { - continue; - } - $queClassNums[$log->operator_log_id] = \App\Models\OperatorQue::QueClass[$que->que_class] ?? $que->que_class; - } -@endphp -
-
- -
-
-
-
-
インフォメーション
-
- @php - $infoQue = \App\Models\OperatorQue::whereIn('que_status', [1,2]) - ->orderByDesc('created_at') - ->limit(5) - ->get(); - @endphp - @if(count($infoQue) > 0) - @foreach($infoQue as $q) -
-
-
-
- {{ $q->created_at }} -
- @if($q->que_class > 99) - ハード異常 - @elseif($q->que_class < 100) - タスク - @endif -
- -
+ @endphp +
+
+ +
+
+
+
+
インフォメーション
+
+ @php + // ▼ 各メニューの件数を取得(que_status は運用に合わせて調整) + // 例:未処理/処理中を 1,2 として集計 + $statusTargets = [1, 2]; + + // ▼ QueClass は実際の定義に合わせて値を変更してください + // ここでは例として que_class を使って種別を分ける想定 + $cntPersonalCheck = \App\Models\OperatorQue::whereIn('que_status', $statusTargets) + ->whereIn('que_class', [ + /* 本人確認の que_class を入れる */ + ]) + ->count(); + + $cntCancelRequest = \App\Models\OperatorQue::whereIn('que_status', $statusTargets) + ->whereIn('que_class', [ + /* 解約リクエストの que_class を入れる */ + ]) + ->count(); + + $cntUserInfoChange = \App\Models\OperatorQue::whereIn('que_status', $statusTargets) + ->whereIn('que_class', [ + /* ユーザー情報変更の que_class を入れる */ + ]) + ->count(); + + // ▼ 0埋め(000件 表示用) + $fmt = fn($n) => str_pad((string) $n, 3, '0', STR_PAD_LEFT) . '件'; + @endphp + + {{-- 本人確認処理 --}} +
+
+ + 本人確認処理 + + {{ $fmt($cntPersonalCheck) }}
- @endforeach - @endif +
+ + {{-- 解約リクエスト --}} +
+
+ + 解約リクエスト + + {{ $fmt($cntCancelRequest) }} +
+
+ + {{-- ユーザー情報変更 --}} +
+
+ + ユーザー情報変更 + + {{ $fmt($cntUserInfoChange) }} +
+
+
-
-
-
-
操作履歴
-
- - - - - - - - - - - - @php $hasData = false; @endphp - @foreach($logs as $log) - @if(isset($queLabels[$log->operator_log_id])) - @php $hasData = true; @endphp +
+
+
操作履歴
+
+
操作内容オペレーター日時
+ - - - - - + + + + + - @endif - @endforeach - @if(!$hasData) - - @endif - -
{!! $queIcons[$log->operator_log_id] !!}{!! $queLabels[$log->operator_log_id] !!}{{ $queClassNums[$log->operator_log_id] }}{{ $operatorNames[$log->operator_id] ?? $log->operator_id }}{{ $log->created_at }}操作内容オペレーター日時
データがありません
+ + + @php $hasData = false; @endphp + @foreach ($logs as $log) + @if (isset($queLabels[$log->operator_log_id])) + @php $hasData = true; @endphp + + {!! $queIcons[$log->operator_log_id] !!}{!! $queLabels[$log->operator_log_id] !!} + + {{ $queClassNums[$log->operator_log_id] }} + {{ $operatorNames[$log->operator_id] ?? $log->operator_id }} + {{ $log->created_at }} + + @endif + @endforeach + @if (!$hasData) + + データがありません + + @endif + + +
-
@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 7b9c196..30ae36e 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -46,17 +46,7 @@ @endif + +