Merge branch 'main' of https://git.so-manager-dev.com/so-manager/krgm.so-manager-dev.com
All checks were successful
Deploy main / deploy (push) Successful in 22s

This commit is contained in:
kin.rinzen 2026-01-22 13:19:33 +09:00
commit 5949e912b4
31 changed files with 2887 additions and 217 deletions

22
.env
View File

@ -46,16 +46,22 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
#MAIL_MAILER=smtp
#MAIL_SCHEME=null
MAIL_HOST=tomatofox9.sakura.ne.jp
MAIL_PORT=587
MAIL_USERNAME=demo@so-rin.jp
MAIL_PASSWORD=rokuchou4665
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=demo@so-rin.jp
#MAIL_HOST=tomatofox9.sakura.ne.jp
#MAIL_PORT=587
#MAIL_USERNAME=demo@so-rin.jp
#MAIL_PASSWORD=rokuchou4665
#MAIL_ENCRYPTION=tls
#MAIL_FROM_ADDRESS=demo@so-rin.jp
#MAIL_FROM_NAME="${APP_NAME}"
#MAIL_ADMIN=demo@so-rin.jp
MAIL_MAILER=sendmail
MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"
MAIL_FROM_ADDRESS=demo@so-manager-dev.com
MAIL_FROM_NAME="${APP_NAME}"
MAIL_ADMIN=demo@so-rin.jp
MAIL_ADMIN=demo@so-manager-dev.com
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

View File

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

View File

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

View File

@ -1,4 +1,5 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
@ -7,6 +8,9 @@ use App\Models\Ope;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Models\Feature;
use App\Models\Permission;
use App\Models\OpePermission;
class OpeController extends Controller
{
@ -23,11 +27,10 @@ class OpeController extends Controller
];
// Blade 側は $list / $sort / $sort_type を参照
$list = Ope::search($inputs);
$sort = $inputs['sort'];
$sort_type = $inputs['sort_type'];
$list = Ope::search($inputs);
$sort = $inputs['sort'];
$sort_type = $inputs['sort_type'];
return view('admin.opes.list', compact('list', 'sort', 'sort_type'));
}
@ -36,25 +39,39 @@ class OpeController extends Controller
*/
public function add(Request $request)
{
if ($request->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']);
// ※自治体IDopeに紐づく想定
$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');
}
// 複数削除

View File

@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Mail\EmailOtpMail;
use App\Models\Ope;
use App\Services\EmailOtpService;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* OTP メール認証コントローラー
*
* ログイン後の OTPワンタイムパスワード検証プロセスを処理します
*/
class EmailOtpController extends Controller
{
use ValidatesRequests;
protected EmailOtpService $otpService;
/**
* コンストラクタ
*/
public function __construct(EmailOtpService $otpService)
{
$this->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送信に失敗しました。もう一度お試しください。');
}
}
}

View File

@ -7,6 +7,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
class ForgotPasswordController extends Controller
{
@ -50,12 +51,22 @@ class ForgotPasswordController extends Controller
);
// メール送信
$resetUrl = url('/reset-password?token=' . $token . '&email=' . urlencode($user->ope_mail));
Mail::raw("下記URLからパスワード再設定を行ってください。\n\n{$resetUrl}", function ($message) use ($user) {
$message->to($user->ope_mail)
->subject('パスワード再設定のご案内');
});
try {
$resetUrl = url('/reset-password?token=' . $token . '&email=' . urlencode($user->ope_mail));
Mail::raw("下記URLからパスワード再設定を行ってください。\n\n{$resetUrl}", function ($message) use ($user) {
$message->to($user->ope_mail)
->from(config('mail.from.address'), config('mail.from.name'))
->subject('パスワード再設定のご案内');
});
} catch (\Throwable $e) {
Log::error('ForgotPassword mail send failed', [
'to' => $user->ope_mail,
'error' => $e->getMessage(),
]);
return back()->withErrors(['email' => 'メール送信に失敗しました。サーバログを確認してください。']);
}
return back()->with('status', 'パスワード再設定メールを送信しました。');
}
}
}

View File

@ -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認証メールの送信に失敗しました。');
}
}
/**

View File

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

View File

@ -0,0 +1,57 @@
<?php
namespace App\Http\Middleware;
use App\Services\EmailOtpService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* OTP 認証チェックミドルウェア
*
* ログイン後、OTP 認証が完了していないユーザーを OTP 入力ページにリダイレクト
* 24時間以内に OTP 認証が完了している場合はスキップ
*/
class EnsureOtpVerified
{
protected EmailOtpService $otpService;
/**
* コンストラクタ
*/
public function __construct(EmailOtpService $otpService)
{
$this->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 認証が必要です。');
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Services\MenuAccessService;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\DB;
/**
* メニューアクセス制御データをビューに共有する Middleware
*
* すべてのビューで $isSorin $visibleCities が利用可能になります。
* また、nav bar に表示される ハード異常・タスク情報も同時に共有します。
*/
class ShareMenuAccessData
{
public function handle(Request $request, Closure $next)
{
$menuAccessService = app(MenuAccessService::class);
// メニュー関連データ
$viewData = [
'isSorin' => $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);
}
}

69
app/Mail/EmailOtpMail.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* OTP メール送信クラス
*/
class EmailOtpMail extends Mailable
{
use Queueable, SerializesModels;
/**
* OTP コード6桁
*/
public string $otpCode;
/**
* オペレータ名
*/
public string $operatorName;
/**
* コンストラクタ
*/
public function __construct(string $otpCode, string $operatorName)
{
$this->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 [];
}
}

View File

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

23
app/Models/Feature.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Feature extends Model
{
// テーブル名Laravel規約なら省略可だが明示
protected $table = 'features';
// 主キー
protected $primaryKey = 'id';
// タイムスタンプ使用
public $timestamps = true;
// 一括代入許可カラム
protected $fillable = [
'code', // 機能コード
'name', // 機能名
];
}

View File

@ -19,9 +19,7 @@ class Ope extends Authenticatable
// オペレータタイプ定数(旧システムから継承)
const OPE_TYPE = [
'管理者',
'マネージャー',
'オペレーター',
'エリアマネージャー',
'職員',
];
protected $table = 'ope'; // データベーステーブル名(旧システムと同じ)
@ -29,7 +27,7 @@ class Ope extends Authenticatable
/**
* 一括代入可能な属性
* Laravel 5.7から引き継いだフィールド構成
* Laravel 5.7から引き継いだフィールド構成 + OTP認証フィールド
*/
protected $fillable = [
'//TODO オペレータID not found in database specs',
@ -58,6 +56,11 @@ class Ope extends Authenticatable
'ope_auth4',
'ope_quit_flag',
'ope_quitday',
// OTP認証関連フィールドメール二段階認証
'email_otp_code_hash',
'email_otp_expires_at',
'email_otp_last_sent_at',
'email_otp_verified_at',
];
/**
@ -178,4 +181,12 @@ class Ope extends Authenticatable
{
return self::pluck('ope_name', 'ope_id');
}
/**
* このオペレータが属する運営元を取得
*/
public function management(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Management::class, 'management_id', 'management_id');
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OpePermission extends Model
{
// テーブル名
protected $table = 'ope_permission';
// 主キー
protected $primaryKey = 'id';
// created_at / updated_at を使用
public $timestamps = true;
// 一括代入許可カラム
protected $fillable = [
'municipality_id', // 自治体ID外部キー
'feature_id', // 機能ID外部キー
'permission_id', // 操作権限ID外部キー
];
/**
* 機能単位で権限を置換(自治体単位)
* municipality_id + feature_id の組み合わせを置換する
*/
public static function replaceByFeature(
int $municipalityId,
int $featureId,
array $permissionIds
): void {
// ※既存削除
self::query()
->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();
}
}

32
app/Models/Permission.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Permission extends Model
{
// テーブル名
protected $table = 'permissions';
// 主キー
protected $primaryKey = 'id';
// created_at / updated_at を使用
public $timestamps = true;
// 一括代入許可カラム
protected $fillable = [
'code', // 操作コードread/create/update/delete/export
'name', // 操作名(閲覧/登録/編集/削除/CSV出力
];
/**
* 操作コードからIDを取得存在しない場合はnull
*/
public static function idByCode(string $code): ?int
{
$row = self::query()->where('code', $code)->first(['id']);
return $row?->id;
}
}

62
app/Models/management.php Normal file
View File

@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Management extends Model
{
/** テーブル名 */
protected $table = 'management';
/** 主キー */
protected $primaryKey = 'management_id';
/** 自動インクリメント */
public $incrementing = true;
/** 主キー型 */
protected $keyType = 'int';
/** タイムスタンプcreated_at / updated_at */
public $timestamps = true;
/** 一括代入を許可する項目 */
protected $fillable = [
'management_name', // 運営元名
'management_code', // 運営元コードURL用
'municipality_flag', // 自治体フラグ
'tel', // 電話番号
'service_time', // 受付時間
'government_approval_required', // 役所承認要否
];
/** 型キャスト */
protected $casts = [
'management_id' => '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');
}
}

View File

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

View File

@ -0,0 +1,148 @@
<?php
namespace App\Services;
use App\Models\Ope;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* メール OTP 認証サービス
*
* 6桁の OTP コード生成、送信、検証、および24時間免OTPの判定を担当します
*/
class EmailOtpService
{
// OTP コード長6桁
const OTP_CODE_LENGTH = 6;
// OTP 有効期限(分)
const OTP_VALIDITY_MINUTES = 10;
// OTP 重発制限時間(秒)
const OTP_RESEND_DELAY_SECONDS = 60;
// 24時間免OTP期限時間
const OTP_FREE_PERIOD_HOURS = 24;
/**
* OTP コードを生成・保存して送信
*
* 6桁の数字コードを生成し、hash化して保存します
* 同時に有効期限と最後の送信時刻を更新します
*
* @param Ope $ope オペレータモデル
* @return string 明文のOTPコードメール送信用
*/
public function issue(Ope $ope): string
{
// 6桁の数字コードを生成
$plainCode = str_pad(random_int(0, 999999), self::OTP_CODE_LENGTH, '0', STR_PAD_LEFT);
// hash化して保存
$ope->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}";
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use App\Models\City;
use App\Models\Management;
/**
* メニュー・アクセス制御サービス
*
* ログイン中のオペレータが属する運営元に基づいて、
* 表示可能なメニュー項目およびアクセス可能な画面を制御します。
* ソーリンは全メニュー/全画面OK、その他は所属自治体のみアクセス可。
*/
class MenuAccessService
{
/**
* ログイン中のオペレータを取得
*/
private function getAuthOperator()
{
return Auth::user();
}
/**
* ログイン中のオペレータが属する運営元を取得
*/
private function getAuthManagement(): ?Management
{
$operator = $this->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<City>
*/
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<int> $cityIds 自治体IDの配列
* @return bool すべての自治体へのアクセスが許可されている場合のみ true
*/
public function canAccessCities(array $cityIds): bool
{
foreach ($cityIds as $cityId) {
if (!$this->canAccessCity($cityId)) {
return false;
}
}
return true;
}
}

View File

@ -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) {
//

50
config/view.php Normal file
View File

@ -0,0 +1,50 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| View Storage Paths
|--------------------------------------------------------------------------
|
| Most templating systems load templates from disk. Here you may specify
| an array of paths that should be checked when loading a view. Of course
| the usual Laravel view path has already been registered for you.
|
*/
'paths' => [
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'),
],
];

View File

@ -0,0 +1,265 @@
@extends('layouts.app')
@section('title', $city->city_name . ' ダッシュボード')
@section('content')
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">{{ $city->city_name }} ダッシュボード</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('information') }}">ホーム</a></li>
<li class="breadcrumb-item active">{{ $city->city_name }}</li>
</ol>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
<!-- 統計情報カード -->
<div class="row">
<div class="col-lg-3 col-6">
<div class="small-box bg-info">
<div class="inner">
<h3>{{ $stats['parks_count'] }}</h3>
<p>駐輪場数</p>
</div>
<div class="icon">
<i class="fa fa-building"></i>
</div>
<a href="{{ route('parks', ['city_id' => $city->city_id]) }}" class="small-box-footer">
詳細を見る <i class="fa fa-arrow-circle-right"></i>
</a>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box bg-success">
<div class="inner">
<h3>{{ number_format($stats['contracts_count']) }}</h3>
<p>契約数</p>
</div>
<div class="icon">
<i class="fa fa-file-text"></i>
</div>
<a href="{{ route('regularcontracts') }}" class="small-box-footer">
詳細を見る <i class="fa fa-arrow-circle-right"></i>
</a>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box bg-warning">
<div class="inner">
<h3>{{ number_format($stats['users_count']) }}</h3>
<p>利用者数</p>
</div>
<div class="icon">
<i class="fa fa-users"></i>
</div>
<a href="{{ route('users') }}" class="small-box-footer">
詳細を見る <i class="fa fa-arrow-circle-right"></i>
</a>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box bg-danger">
<div class="inner">
<h3>{{ number_format($stats['waiting_count']) }}</h3>
<p>予約待ち人数</p>
</div>
<div class="icon">
<i class="fa fa-clock-o"></i>
</div>
<a href="{{ route('reserves') }}" class="small-box-footer">
詳細を見る <i class="fa fa-arrow-circle-right"></i>
</a>
</div>
</div>
</div>
<!-- 第2行追加情報 -->
<div class="row">
<div class="col-lg-3 col-6">
<div class="small-box bg-purple">
<div class="inner">
<h3>{{ number_format($parks->sum('park_capacity') ?? 0) }}</h3>
<p>総収容台数</p>
</div>
<div class="icon">
<i class="fa fa-bicycle"></i>
</div>
<div class="small-box-footer" style="height: 30px;">
&nbsp;
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box bg-teal">
<div class="inner">
@php
$totalCapacity = $parks->sum('park_capacity') ?? 0;
$utilizationRate = $totalCapacity > 0 ? round(($stats['contracts_count'] / $totalCapacity) * 100, 1) : 0;
@endphp
<h3>{{ $utilizationRate }}%</h3>
<p>利用率</p>
</div>
<div class="icon">
<i class="fa fa-pie-chart"></i>
</div>
<div class="small-box-footer" style="height: 30px;">
&nbsp;
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box bg-secondary">
<div class="inner">
<h3>{{ number_format($totalCapacity - $stats['contracts_count']) }}</h3>
<p>空き台数</p>
</div>
<div class="icon">
<i class="fa fa-square-o"></i>
</div>
<div class="small-box-footer" style="height: 30px;">
&nbsp;
</div>
</div>
</div>
<div class="col-lg-3 col-6">
<div class="small-box bg-navy">
<div class="inner">
@php
$waitingRate = $stats['contracts_count'] > 0 ? round(($stats['waiting_count'] / $stats['contracts_count']) * 100, 1) : 0;
@endphp
<h3>{{ $waitingRate }}%</h3>
<p>予約待ち率</p>
</div>
<div class="icon">
<i class="fa fa-percent"></i>
</div>
<div class="small-box-footer" style="height: 30px;">
&nbsp;
</div>
</div>
</div>
</div>
<!-- 駐輪場一覧 -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ $city->city_name }}の駐輪場一覧</h3>
<div class="card-tools">
<a href="{{ route('parks.add') }}" class="btn btn-primary btn-sm">
<i class="fa fa-plus"></i> 新規駐輪場追加
</a>
</div>
</div>
<div class="card-body">
@if($parks->count() > 0)
<div class="table-responsive">
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>駐輪場ID</th>
<th>駐輪場名</th>
<th>住所</th>
<th>収容台数</th>
<th>状態</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@foreach($parks as $park)
<tr>
<td>{{ $park->park_id }}</td>
<td>{{ $park->park_name }}</td>
<td>{{ $park->park_address ?? '-' }}</td>
<td class="text-right">{{ number_format($park->park_capacity ?? 0) }}</td>
<td>
@if($park->park_status == 1)
<span class="badge badge-success">稼働中</span>
@else
<span class="badge badge-secondary">停止中</span>
@endif
</td>
<td>
<a href="{{ route('parks.edit', $park->park_id) }}" class="btn btn-sm btn-info">
<i class="fa fa-edit"></i> 編集
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="alert alert-info">
<i class="fa fa-info-circle"></i> この自治体にはまだ駐輪場が登録されていません。
</div>
@endif
</div>
</div>
</div>
</div>
<!-- クイックアクション -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ $city->city_name }}関連のクイックアクション</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<a href="{{ route('parks', ['city_id' => $city->city_id]) }}" class="btn btn-block btn-outline-primary">
<i class="fa fa-building"></i><br>
駐輪場管理
</a>
</div>
<div class="col-md-3">
<a href="{{ route('users') }}" class="btn btn-block btn-outline-success">
<i class="fa fa-users"></i><br>
利用者管理
</a>
</div>
<div class="col-md-3">
<a href="{{ route('regularcontracts') }}" class="btn btn-block btn-outline-warning">
<i class="fa fa-file-text"></i><br>
契約管理
</a>
</div>
<div class="col-md-3">
<a href="{{ route('city_edit', $city->city_id) }}" class="btn btn-block btn-outline-info">
<i class="fa fa-cog"></i><br>
運営元設定
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
@endsection
@push('scripts')
<script>
$(document).ready(function() {
// 必要に応じてJavaScriptを追加
});
</script>
@endpush

View File

@ -0,0 +1,381 @@
@extends('layouts.app')
@section('title', '総合ダッシュボード')
@section('content')
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1 class="m-0 text-dark">総合ダッシュボード</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="{{ route('information') }}">ホーム</a></li>
<li class="breadcrumb-item active">総合ダッシュボード</li>
</ol>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
<!-- 全体統計情報 -->
<div class="row">
<div class="col-12">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">全体統計</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-2 col-6">
<div class="small-box bg-info">
<div class="inner">
<h3>{{ $totalStats['total_cities'] }}</h3>
<p>自治体数</p>
</div>
<div class="icon">
<i class="fa fa-map-marker"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-success">
<div class="inner">
<h3>{{ $totalStats['total_parks'] }}</h3>
<p>駐輪場数</p>
</div>
<div class="icon">
<i class="fa fa-building"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-warning">
<div class="inner">
<h3>{{ number_format($totalStats['total_contracts']) }}</h3>
<p>総契約数</p>
</div>
<div class="icon">
<i class="fa fa-file-text"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-danger">
<div class="inner">
<h3>{{ number_format($totalStats['total_waiting']) }}</h3>
<p>総予約待ち</p>
</div>
<div class="icon">
<i class="fa fa-clock-o"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-purple">
<div class="inner">
<h3>{{ number_format($totalStats['total_capacity']) }}</h3>
<p>総収容台数</p>
</div>
<div class="icon">
<i class="fa fa-bicycle"></i>
</div>
</div>
</div>
<div class="col-lg-2 col-6">
<div class="small-box bg-teal">
<div class="inner">
<h3>{{ $totalStats['total_utilization_rate'] }}%</h3>
<p>全体利用率</p>
</div>
<div class="icon">
<i class="fa fa-pie-chart"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 自治体別統計表 -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">自治体別統計</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fa fa-minus"></i>
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-striped table-hover" id="cityStatsTable">
<thead class="thead-dark">
<tr>
<th>自治体名</th>
<th class="text-center">駐輪場数</th>
<th class="text-center">契約数</th>
<th class="text-center">利用者数</th>
<th class="text-center">予約待ち</th>
<th class="text-center">収容台数</th>
<th class="text-center">利用率</th>
<th class="text-center">空き台数</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody>
@foreach($cityStats as $stat)
<tr>
<td>
<strong>{{ $stat['city']->city_name }}</strong>
</td>
<td class="text-center">
{{ $stat['parks_count'] }}
</td>
<td class="text-center">
{{ number_format($stat['contracts_count']) }}
</td>
<td class="text-center">
{{ number_format($stat['users_count']) }}
</td>
<td class="text-center">
@if($stat['waiting_count'] > 0)
<span class="badge badge-warning">{{ number_format($stat['waiting_count']) }}</span>
@else
<span class="badge badge-success">0</span>
@endif
</td>
<td class="text-center">
{{ number_format($stat['capacity']) }}
</td>
<td class="text-center">
@php
$rate = $stat['utilization_rate'];
$badgeClass = $rate >= 90 ? 'danger' : ($rate >= 70 ? 'warning' : 'success');
@endphp
<span class="badge badge-{{ $badgeClass }}">{{ $rate }}%</span>
</td>
<td class="text-center">
@if($stat['available_spaces'] > 0)
<span class="text-success">{{ number_format($stat['available_spaces']) }}</span>
@else
<span class="text-danger">満車</span>
@endif
</td>
<td class="text-center">
<a href="{{ route('city_dashboard', ['city_id' => $stat['city']->city_id]) }}"
class="btn btn-sm btn-primary">
<i class="fa fa-eye"></i> 詳細
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 利用率チャート -->
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">自治体別利用率</h3>
</div>
<div class="card-body">
<canvas id="utilizationChart" width="400" height="300"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">予約待ち状況</h3>
</div>
<div class="card-body">
<canvas id="waitingChart" width="400" height="300"></canvas>
</div>
</div>
</div>
</div>
<!-- アラート表示 -->
<div class="row">
<div class="col-12">
<div class="card card-warning">
<div class="card-header">
<h3 class="card-title">⚠️ 注意が必要な自治体</h3>
</div>
<div class="card-body">
@php
$alertCities = collect($cityStats)->filter(function($stat) {
return $stat['utilization_rate'] >= 90 || $stat['waiting_count'] > 10;
});
@endphp
@if($alertCities->count() > 0)
<div class="row">
@foreach($alertCities as $stat)
<div class="col-md-4">
<div class="alert alert-warning">
<h5><i class="icon fa fa-exclamation-triangle"></i> {{ $stat['city']->city_name }}</h5>
@if($stat['utilization_rate'] >= 90)
<p>利用率が {{ $stat['utilization_rate'] }}% と高くなっています</p>
@endif
@if($stat['waiting_count'] > 10)
<p>予約待ちが {{ $stat['waiting_count'] }} います</p>
@endif
<a href="{{ route('city_dashboard', ['city_id' => $stat['city']->city_id]) }}"
class="btn btn-sm btn-warning">詳細確認</a>
</div>
</div>
@endforeach
</div>
@else
<div class="alert alert-success">
<i class="icon fa fa-check"></i> 現在、特に注意が必要な自治体はありません。
</div>
@endif
</div>
</div>
</div>
</div>
</div>
</section>
@endsection
@push('scripts')
<script src="{{ asset('plugins/chart.js/Chart.min.js') }}"></script>
<script>
$(document).ready(function() {
// データテーブル初期化
$('#cityStatsTable').DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.10.21/i18n/Japanese.json"
},
"pageLength": 10,
"order": [[ 6, "desc" ]], // 利用率で降順ソート
"columnDefs": [
{ "orderable": false, "targets": 8 } // 操作列はソート無効
]
});
// 利用率チャート(円グラフ)
var ctxUtil = document.getElementById('utilizationChart').getContext('2d');
var utilizationChart = new Chart(ctxUtil, {
type: 'pie',
data: {
labels: [
@foreach($cityStats as $stat)
'{{ $stat['city']->city_name }} ({{ $stat['utilization_rate'] }}%)',
@endforeach
],
datasets: [{
label: '利用率',
data: [
@foreach($cityStats as $stat)
{{ $stat['utilization_rate'] }},
@endforeach
],
backgroundColor: [
'rgba(255, 99, 132, 0.8)',
'rgba(54, 162, 235, 0.8)',
'rgba(255, 206, 86, 0.8)',
'rgba(75, 192, 192, 0.8)',
'rgba(153, 102, 255, 0.8)',
'rgba(255, 159, 64, 0.8)',
'rgba(199, 199, 199, 0.8)',
'rgba(83, 102, 255, 0.8)',
'rgba(255, 193, 7, 0.8)',
'rgba(220, 53, 69, 0.8)',
'rgba(108, 117, 125, 0.8)',
'rgba(40, 167, 69, 0.8)'
],
borderColor: [
'rgba(255, 99, 132, 1)',
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)',
'rgba(199, 199, 199, 1)',
'rgba(83, 102, 255, 1)',
'rgba(255, 193, 7, 1)',
'rgba(220, 53, 69, 1)',
'rgba(108, 117, 125, 1)',
'rgba(40, 167, 69, 1)'
],
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 12,
padding: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
var label = context.label.split(' (')[0]; // 自治体名のみ取得
var value = context.parsed;
var total = context.dataset.data.reduce((a, b) => a + b, 0);
var percentage = ((value / total) * 100).toFixed(1);
return label + ': ' + value + '% (全体の' + percentage + '%)';
}
}
}
}
}
});
// 予約待ちチャート
var ctxWait = document.getElementById('waitingChart').getContext('2d');
var waitingChart = new Chart(ctxWait, {
type: 'doughnut',
data: {
labels: [
@foreach($cityStats as $stat)
'{{ $stat['city']->city_name }}',
@endforeach
],
datasets: [{
label: '予約待ち人数',
data: [
@foreach($cityStats as $stat)
{{ $stat['waiting_count'] }},
@endforeach
],
backgroundColor: [
'rgba(255, 99, 132, 0.8)',
'rgba(54, 162, 235, 0.8)',
'rgba(255, 206, 86, 0.8)',
'rgba(75, 192, 192, 0.8)',
'rgba(153, 102, 255, 0.8)',
'rgba(255, 159, 64, 0.8)',
'rgba(199, 199, 199, 0.8)',
'rgba(83, 102, 255, 0.8)'
]
}]
},
options: {
responsive: true
}
});
});
</script>
@endpush

View File

@ -169,7 +169,7 @@
</div>
@endfor
{{-- キュー1〜13 --}}
{{--
<!-- 管理者権限付与 -->
<div class="form-group col-3">
<label>{{__('validation.attributes.ope_auth1')}}</label>
@ -245,7 +245,7 @@
</div>
</div>
</div>
--}}
<!-- 退職フラグ -->
<div class="form-group col-3">
<label>{{__('validation.attributes.ope_quit_flag')}}</label>
@ -278,6 +278,51 @@
</div>
<!-- /.form group - 退職日 -->
<!-- 機能(画面) -->
<div class="form-group col-3">
<label>機能(画面)</label>
</div>
<div class="form-group col-9">
<div class="input-group">
<select id="feature_id"
name="feature_id"
class="form-control form-control-lg permission-control">
<option value="">機能を選択してください</option>
@foreach($features as $feature)
<option value="{{ $feature->id }}"
{{ (string)old('feature_id', $selectedFeatureId ?? '') === (string)$feature->id ? 'selected' : '' }}>
{{ $feature->name }}
</option>
@endforeach
</select>
</div>
</div>
<!-- 操作権限 -->
<div class="form-group col-3 mt-3">
<label>操作権限</label>
</div>
<div class="form-group col-9 mt-3">
<div id="permission_box">
@foreach($permissions as $perm)
<label class="mr-4 mb-0 d-inline-flex align-items-center">
<input type="checkbox"
class="perm-checkbox permission-control mr-1"
name="permission_ids[]"
value="{{ $perm->id }}">
{{ $perm->name }}
</label>
@endforeach
</div>
<small class="text-muted d-block mt-2">
機能を選択後、現在設定済みの権限が自動で反映されます。
</small>
</div>
<!-- 保存ボタンは既存のままでOK -->
</div>
{{-- 下部ボタン --}}
@ -307,3 +352,67 @@
</div>
<script>
/**
* オペレータ種別に応じて
* 0: 管理者 機能・権限 非活性
* 1: 職員 機能・権限 活性
*/
function togglePermissionArea() {
const opeType = document.querySelector('select[name="ope_type"]').value;
const controls = document.querySelectorAll('.permission-control');
if (opeType === '0') { // 管理者
controls.forEach(el => {
el.disabled = true;
if (el.type === 'checkbox') {
el.checked = false;
}
});
} else {
controls.forEach(el => {
el.disabled = false;
});
}
}
/**
* 機能選択時:既存権限を反映
*/
async function refreshPermissionChecks() {
const featureId = document.getElementById('feature_id').value;
const opeId = {{ (int)($record->ope_id ?? 0) }};
const opeType = document.querySelector('select[name="ope_type"]').value;
// 管理者は個別権限設定しない
if (opeType === '0') return;
// 一旦すべて解除
document.querySelectorAll('.perm-checkbox').forEach(cb => cb.checked = false);
if (!featureId || !opeId) return;
const res = await fetch(`/opes/${opeId}/permissions?feature_id=${featureId}`);
const ids = await res.json();
const set = new Set(ids.map(String));
document.querySelectorAll('.perm-checkbox').forEach(cb => {
if (set.has(String(cb.value))) cb.checked = true;
});
}
/* ---------- イベント登録 ---------- */
document.addEventListener('DOMContentLoaded', () => {
togglePermissionArea();
refreshPermissionChecks();
});
document.querySelector('select[name="ope_type"]').addEventListener('change', () => {
togglePermissionArea();
refreshPermissionChecks();
});
document.getElementById('feature_id').addEventListener('change', refreshPermissionChecks);
</script>

View File

@ -32,6 +32,26 @@
</select>
</div>
</div>
<div class="row mb-3 align-items-center">
<label class="col-sm-2 col-form-label fw-bold">エリア</label>
<div class="col-sm-4">
<select name="park_id" class="form-select">
<option value="">全て</option>
@foreach($parks as $park)
<option value="{{ $park->park_id }}"
{{ (string)request('park_id') === (string)$park->park_id ? 'selected' : '' }}>
{{ $park->park_name }}
</option>
@endforeach
</select>
</div>
</div>
<div class="row mb-3 align-items-center">
<label class="col-sm-2 col-form-label fw-bold py-0">発行日</label>
<div class="col-sm-10">
@ -76,6 +96,7 @@
<div class="col-sm-12 text-end">
<button type="submit" class="btn btn-default me-2">絞り込み</button>
<a href="{{ route('seals') }}" class="btn btn-default">解除</a>
<button type="submit" class="btn btn-default me-2">CSV出力</button>
</div>
</div>
</form>

View File

@ -0,0 +1,171 @@
@extends('layouts.login')
@section('content')
<div class="login-box">
<div class="login-logo">
<a><b>So-Manager&nbsp;</b>{{ __('管理パネル') }}</a>
</div>
<!-- /.login-logo -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ __('セキュリティ認証') }}</h5>
</div>
<div class="card-body login-card-body">
<p class="login-box-msg mb-3">
{{ __('登録されたメールアドレス') }} <strong>{{ $maskedEmail }}</strong>
<br/>{{ __('に6桁の認証コードが送信されました。') }}
</p>
@if ($errors->any())
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>{{ __('エラー') }}</strong>
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
@if (session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
@if (session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
@endif
<form action="{{ route('otp.verify') }}" method="POST" novalidate>
@csrf
<div class="form-group mb-3">
<label for="code" class="form-label">
<strong>{{ __('認証コード6桁') }}</strong>
</label>
<div class="input-group mb-3">
<input
type="text"
id="code"
name="code"
class="form-control form-control-lg text-center"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
required
autofocus
value="{{ old('code') }}"
/>
<div class="input-group-append">
<span class="fa fa-key input-group-text"></span>
</div>
</div>
<small class="d-block text-muted">
{{ __('数字のみで6桁入力してください。有効期限は10分です。') }}
</small>
</div>
<div class="row mt40">
<div class="col-12 col-lg-8 offset-0 offset-lg-2">
<button type="submit" class="btn btn-lg btn-primary btn-block btn-flat">
{{ __('認証する') }}
</button>
</div>
</div>
</form>
<div class="text-center mt-3">
<form action="{{ route('otp.resend') }}" method="POST" class="d-inline">
@csrf
<button
type="submit"
class="btn btn-sm btn-outline-secondary"
id="resendBtn"
@if ($resendWaitSeconds > 0) disabled @endif
>
<span id="resendText">
@if ($resendWaitSeconds > 0)
{{ __('コード再送(') }}{{ $resendWaitSeconds }}{{ __('秒待機)') }}
@else
{{ __('コードを再送信する') }}
@endif
</span>
</button>
</form>
</div>
<div class="mt-3 text-center">
<a href="{{ route('logout') }}" class="text-muted small">
{{ __('ログアウト') }}
</a>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 入力フィールドの自動フォーカス制御
const codeInput = document.getElementById('code');
const resendBtn = document.getElementById('resendBtn');
const resendText = document.getElementById('resendText');
// 数字のみ許可
codeInput.addEventListener('keypress', function(e) {
if (!/\d/.test(e.key)) {
e.preventDefault();
}
});
// 貼り付け時に数字のみを許可
codeInput.addEventListener('paste', function(e) {
e.preventDefault();
const pastedText = (e.clipboardData || window.clipboardData).getData('text');
const numbers = pastedText.replace(/\D/g, '').slice(0, 6);
codeInput.value = numbers;
});
// 再送ボタンのカウントダウン処理
let waitSeconds = {{ $resendWaitSeconds }};
if (waitSeconds > 0) {
const countdown = setInterval(function() {
waitSeconds--;
if (waitSeconds <= 0) {
clearInterval(countdown);
resendBtn.disabled = false;
resendText.textContent = '{{ __("コードを再送信する") }}';
} else {
resendText.textContent = '{{ __("コード再送(") }}' + waitSeconds + '{{ __("秒待機)") }}';
}
}, 1000);
}
});
</script>
<style>
#code {
letter-spacing: 0.5em;
font-size: 1.5rem;
font-weight: bold;
font-family: 'Courier New', monospace;
}
#code::placeholder {
letter-spacing: 0.5em;
}
</style>
@endsection

View File

@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>セキュリティ認証</title>
<style>
body {
font-family: 'Segoe UI', 'Helvetica Neue', sans-serif, 'Arial';
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}
.header {
background-color: #007bff;
color: white;
padding: 20px;
text-align: center;
border-radius: 4px;
}
.content {
background-color: white;
padding: 20px;
margin-top: 10px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.code-box {
background-color: #f5f5f5;
border: 2px solid #007bff;
padding: 20px;
text-align: center;
margin: 20px 0;
font-size: 32px;
font-weight: bold;
letter-spacing: 5px;
font-family: 'Courier New', monospace;
}
.footer {
font-size: 12px;
color: #666;
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 10px;
margin: 10px 0;
border-radius: 4px;
color: #856404;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 セキュリティ認証</h1>
</div>
<div class="content">
<p>{{ $operatorName }} </p>
<p>いつもご利用いただきありがとうございます。</p>
<p>ログイン認証用の OTPワンタイムパスワードをお送りしました。</p>
<h2 style="text-align: center; color: #007bff;">認証コード</h2>
<div class="code-box">{{ $otpCode }}</div>
<p style="text-align: center; color: #666; margin: 10px 0;">
<strong>⏱️ 有効期限10分間</strong>
</p>
<p>このコードを入力フォームに入力して、認証を完了してください。</p>
<div class="warning">
<strong>セキュリティに関するご注意:</strong>
<ul style="margin: 5px 0; padding-left: 20px;">
<li>このコードは絶対に他の方に教えないでください</li>
<li>このメールの送信に心当たりがない場合は、無視してください</li>
<li>本システムは認証コードをメールで送信することはありません。リンククリックは不要です。</li>
</ul>
</div>
<p>ご不明な点やサポートが必要な場合は、お気軽にお問い合わせください。</p>
<p>よろしくお願いいたします。</p>
</div>
<div class="footer">
<p>このメールは自動送信です。返信はしないでください。</p>
<p>&copy; 2026 So-Manager. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@ -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] = '<span class="badge badge-primary">タスク</span>';
$queIcons[$log->operator_log_id] = '';
} elseif ($que->que_class > 99) {
$queLabels[$log->operator_log_id] = '<span class="badge badge-danger">ハード異常</span>';
$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] = '<span class="badge badge-primary">タスク</span>';
$queIcons[$log->operator_log_id] = '';
} elseif ($que->que_class > 99) {
$queLabels[$log->operator_log_id] = '<span class="badge badge-danger">ハード異常</span>';
$queIcons[$log->operator_log_id] = '';
} else {
continue;
}
$queClassNums[$log->operator_log_id] = \App\Models\OperatorQue::QueClass[$que->que_class] ?? $que->que_class;
}
@endphp
<div class="container-fluid" style="background:#f4f6f9;min-height:calc(100vh - 60px);">
<div class="row">
<!-- メイン -->
<div class="col-md-12">
<div class="row mt-4">
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header bg-white font-weight-bold">インフォメーション</div>
<div class="card-body">
@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)
<div class="card mb-3" style="box-shadow:none;border:1px solid #e0e0e0;">
<div class="card-body py-3 px-4" style="position:relative;">
<div class="d-flex align-items-center mb-2">
<div class="text-secondary" style="font-size:1.1em; margin-right:1em;">
{{ $q->created_at }}
</div>
@if($q->que_class > 99)
<span class="badge badge-danger" style="font-size:0.95em;">ハード異常</span>
@elseif($q->que_class < 100)
<span class="badge badge-primary" style="font-size:0.95em;">タスク</span>
@endif
</div>
<div>
<a href="{{ url('/admin/manual_personal_check/edit/'.$q->que_id) }}" style="font-weight:bold;color:#007bff;font-size:1.1em;text-decoration:underline;">
{{ $q->getQueClassLabel() }}
</a>
</div>
</div>
@endphp
<div class="container-fluid" style="background:#f4f6f9;min-height:calc(100vh - 60px);">
<div class="row">
<!-- メイン -->
<div class="col-md-12">
<div class="row mt-4">
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header bg-white font-weight-bold">インフォメーション</div>
<div class="card-body">
@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
{{-- 本人確認処理 --}}
<div class="card mb-3" style="box-shadow:none;border:1px solid #e0e0e0;">
<div class="card-body py-3 px-4 d-flex align-items-center ">
<a href="{{ route('personal') }}"
style="font-weight:bold;color:#007bff;font-size:1.05em;">
本人確認処理
</a>
<span class="badge badge-primary ml-auto"
style="font-size:0.95em;">{{ $fmt($cntPersonalCheck) }}</span>
</div>
@endforeach
@endif
</div>
{{-- 解約リクエスト --}}
<div class="card mb-3" style="box-shadow:none;border:1px solid #e0e0e0;">
<div class="card-body py-3 px-4 d-flex align-items-center ">
<a href="{{ route('regularcontracts') }}"
style="font-weight:bold;color:#007bff;font-size:1.05em;">
解約リクエスト
</a>
<span class="badge badge-primary ml-auto"
style="font-size:0.95em;">{{ $fmt($cntCancelRequest) }}</span>
</div>
</div>
{{-- ユーザー情報変更 --}}
<div class="card mb-0" style="box-shadow:none;border:1px solid #e0e0e0;">
<div class="card-body py-3 px-4 d-flex align-items-center ">
<a href="{{ route('users') }}"
style="font-weight:bold;color:#007bff;font-size:1.05em;">
ユーザー情報変更
</a>
<span class="badge badge-primary ml-auto"
style="font-size:0.95em;">{{ $fmt($cntUserInfoChange) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card mb-4">
<div class="card-header bg-white font-weight-bold">操作履歴</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr>
<th scope="col">操作内容</th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col">オペレーター</th>
<th scope="col">日時</th>
</tr>
</thead>
<tbody>
@php $hasData = false; @endphp
@foreach($logs as $log)
@if(isset($queLabels[$log->operator_log_id]))
@php $hasData = true; @endphp
<div class="col-lg-5">
<div class="card mb-4">
<div class="card-header bg-white font-weight-bold">操作履歴</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr>
<td>{!! $queIcons[$log->operator_log_id] !!}{!! $queLabels[$log->operator_log_id] !!}</td>
<td></td>
<td>{{ $queClassNums[$log->operator_log_id] }}</td>
<td>{{ $operatorNames[$log->operator_id] ?? $log->operator_id }}</td>
<td>{{ $log->created_at }}</td>
<th scope="col">操作内容</th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col">オペレーター</th>
<th scope="col">日時</th>
</tr>
@endif
@endforeach
@if(!$hasData)
<tr><td colspan="5" class="text-center">データがありません</td></tr>
@endif
</tbody>
</table>
</thead>
<tbody>
@php $hasData = false; @endphp
@foreach ($logs as $log)
@if (isset($queLabels[$log->operator_log_id]))
@php $hasData = true; @endphp
<tr>
<td>{!! $queIcons[$log->operator_log_id] !!}{!! $queLabels[$log->operator_log_id] !!}</td>
<td></td>
<td>{{ $queClassNums[$log->operator_log_id] }}</td>
<td>{{ $operatorNames[$log->operator_id] ?? $log->operator_id }}</td>
<td>{{ $log->created_at }}</td>
</tr>
@endif
@endforeach
@if (!$hasData)
<tr>
<td colspan="5" class="text-center">データがありません</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -46,17 +46,7 @@
<div class="wrapper">
<!-- Navbar -->
<nav class="main-header navbar navbar-expand bg-white navbar-light border-bottom">
<!-- Left navbar links -->
<ul class="navbar-nav">
<li class="nav-item d-lg-none">
<a class="nav-link" data-widget="pushmenu" href="#"><i class="fa fa-bars"></i></a>
</li>
<li class="nav-item d-none d-sm-inline-block">
<a class="nav-link" style="margin-left:20px;">
{{ __('ようこそ、:ope_name様', ['ope_name' => Auth::user()->ope_name]) }}
</a>
</li>
</ul>
<!-- SEARCH FORM -->
<!--
@ -73,7 +63,7 @@
-->
<!-- Right navbar links -->
<ul class="navbar-nav ml-auto">
<ul class="navbar-nav ml-3">
<!-- ハード異常 件数表示(ドロップダウン任意表示:ここでは件数バッジのみ簡易) -->
<li class="nav-item dropdown">
<a class="nav-link text-danger" data-toggle="dropdown" href="#">
@ -145,6 +135,14 @@
</div>
@endif
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" style="margin-left:20px;">
{{ __('ようこそ、:ope_name様', ['ope_name' => Auth::user()->ope_name]) }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout"><i class="fa fa-sign-out"></i> {{ __('ログアウト') }}</a>
</li>
@ -186,10 +184,277 @@
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu"
data-accordion="false">
@if(!$isSorin)
<!-- 非ソーリンユーザー用: 運営元メニューを親として構造変更 -->
@if($visibleCities && $visibleCities->count() > 0)
@php
// 現在のページが属する city_id を判定
// 1. ルートパラメータから city_id を取得city_dashboard など)
// 2. ない場合は、visibleCities の最初の city_id を使用非ソーリンユーザーは1つのみ
$currentCityId = request()->route('city_id') ?? ($visibleCities->first()?->city_id ?? null);
// 現在のページが都市管理ページか通常ページかを判定
$isCityRoute = request()->routeIs('city_dashboard') || str_contains(request()->url(), '/city/');
// 現在のルート名を取得(ハイライト判定用)
$currentRoute = app('router')->currentRouteName();
// 非ソーリンユーザーが閲覧可能なページのルート名
$visibleRoutes = [
'dashboard', // 総合ダッシュボード
'information', // 常時表示インフォメーション
'tagissue', // タグ発行キュー処理、履歴表示
'seals', // シール発行履歴
'periodical', // 定期利用・契約状況
'contractor', // 契約者一覧
'contractor_List', // 未更新者一覧
'update_candidate', // 更新予定者一覧
'reservation', // 予約者一覧
'personal', // 本人確認手動処理
'using_status', // 区画別利用率状況
'news', // 一時売り上げ入力 / 最新ニュース登録
'users', // 利用者マスタ
'regularcontracts', // 定期契約マスタ
'reserves', // 定期予約マスタ
'usertypes', // 利用者分類マスタ
'parks', // 駐輪場マスタ
'pricelist', // 料金一覧表
'prices', // 駐輪場所、料金マスタ
'psections', // 車種区分マスタ
'city_dashboard', // 自治体ダッシュボード
];
@endphp
<li class="nav-item has-treeview menu-open">
<a href="#" class="nav-link active">
<i class="nav-icon fa fa-map-marker"></i>
<p>
{{ __("運営元メニュー") }}
<i class="right fa fa-angle-down"></i>
</p>
</a>
<ul class="nav nav-treeview" style="display: block;">
@foreach($visibleCities as $city)
<li class="nav-item has-treeview @if($currentCityId == $city->city_id) menu-open @endif">
<a href="#" class="nav-link @if($currentCityId == $city->city_id) active @endif">
<i class="nav-icon fa fa-building-o"></i>
<span style="margin-left:10px;">{{ $city->city_name }}</span>
<i class="right fa fa-angle-down"></i>
</a>
<ul class="nav nav-treeview" style="display: @if($currentCityId == $city->city_id) block @else none @endif; margin-left: 20px;">
<!-- ホーム -->
<li class="nav-item has-treeview @if(in_array($currentRoute, ['dashboard', 'information'])) menu-open @endif">
<a href="#" class="nav-link @if(in_array($currentRoute, ['dashboard', 'information'])) active @endif">
<i class="nav-icon fa fa-home"></i>
<p>
ホーム
<i class="right fa fa-angle-down"></i>
</p>
</a>
<ul class="nav nav-treeview" style="margin-left: 20px; display: @if(in_array($currentRoute, ['dashboard', 'information'])) block @else none @endif;">
<li class="nav-item">
<a href="{{ route('dashboard') }}" class="nav-link {{ $currentRoute === 'dashboard' ? 'active' : '' }}">
<span style="margin-left:20px;">総合ダッシュボード</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('information') }}" class="nav-link {{ $currentRoute === 'information' ? 'active' : '' }}">
<span style="margin-left:20px;">常時表示インフォメーション</span>
</a>
</li>
</ul>
</li>
<!-- タグ・シール管理 -->
<li class="nav-item has-treeview @if(in_array($currentRoute, ['tagissue', 'seals'])) menu-open @endif">
<a href="#" class="nav-link @if(in_array($currentRoute, ['tagissue', 'seals'])) active @endif">
<i class="nav-icon fa fa-repeat"></i>
<span style="margin-left:10px;">タグ・シール管理</span>
<i class="right fa fa-angle-down"></i>
</a>
<ul class="nav nav-treeview" style="margin-left: 40px; display: @if(in_array($currentRoute, ['tagissue', 'seals'])) block @else none @endif;">
<li class="nav-item">
<a href="{{ route('tagissue', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'tagissue' ? 'active' : '' }}">
<span style="margin-left:20px;">タグ発行キュー処理、履歴表示</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('seals', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'seals' ? 'active' : '' }}">
<span style="margin-left:20px;">シール発行履歴</span>
</a>
</li>
</ul>
</li>
<!-- 定期駐輪管理 -->
@php
$parkingRoutes = ['periodical', 'contractor', 'contractor_List', 'update_candidate', 'reservation', 'personal', 'using_status'];
@endphp
<li class="nav-item has-treeview @if(in_array($currentRoute, $parkingRoutes)) menu-open @endif">
<a href="#" class="nav-link @if(in_array($currentRoute, $parkingRoutes)) active @endif">
<i class="nav-icon fa fa-repeat"></i>
<span style="margin-left:10px;">定期駐輪管理</span>
<i class="right fa fa-angle-down"></i>
</a>
<ul class="nav nav-treeview" style="margin-left: 40px; display: @if(in_array($currentRoute, $parkingRoutes)) block @else none @endif;">
<li class="nav-item">
<a href="{{ route('periodical', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'periodical' ? 'active' : '' }}">
<span style="margin-left:20px;">定期利用・契約状況</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('contractor', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'contractor' ? 'active' : '' }}">
<span style="margin-left:20px;">契約者一覧</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('contractor_List', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'contractor_List' ? 'active' : '' }}">
<span style="margin-left:20px;">未更新者一覧</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('update_candidate', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'update_candidate' ? 'active' : '' }}">
<span style="margin-left:20px;">更新予定者一覧</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('reservation', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'reservation' ? 'active' : '' }}">
<span style="margin-left:20px;">予約者一覧</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('personal', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'personal' ? 'active' : '' }}">
<span style="margin-left:20px;">本人確認手動処理</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('using_status', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'using_status' ? 'active' : '' }}">
<span style="margin-left:20px;">区画別利用率状況</span>
</a>
</li>
</ul>
</li>
<!-- 集計業務 -->
<li class="nav-item has-treeview @if($currentRoute === 'news') menu-open @endif">
<a href="#" class="nav-link @if($currentRoute === 'news') active @endif">
<i class="nav-icon fa fa-calculator"></i>
<span style="margin-left:10px;">集計業務</span>
<i class="right fa fa-angle-down"></i>
</a>
<ul class="nav nav-treeview" style="margin-left: 40px; display: @if($currentRoute === 'news') block @else none @endif;">
<li class="nav-item">
<a href="{{ route('news', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'news' ? 'active' : '' }}">
<span style="margin-left:20px;">一時売り上げ入力</span>
</a>
</li>
</ul>
</li>
<!-- 一般ウェブ管理 -->
<li class="nav-item has-treeview @if($currentRoute === 'news') menu-open @endif">
<a href="#" class="nav-link @if($currentRoute === 'news') active @endif">
<i class="nav-icon fa fa-dashboard"></i>
<span style="margin-left:10px;">一般ウェブ管理</span>
<i class="right fa fa-angle-down"></i>
</a>
<ul class="nav nav-treeview" style="margin-left: 40px; display: @if($currentRoute === 'news') block @else none @endif;">
<li class="nav-item">
<a href="{{ route('news', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'news' ? 'active' : '' }}">
<span style="margin-left:20px;">最新ニュース登録</span>
</a>
</li>
</ul>
</li>
<!-- 利用者マスタ -->
@php
$userRoutes = ['users', 'regularcontracts', 'reserves', 'usertypes'];
@endphp
<li class="nav-item has-treeview @if(in_array($currentRoute, $userRoutes)) menu-open @endif">
<a href="#" class="nav-link @if(in_array($currentRoute, $userRoutes)) active @endif">
<i class="nav-icon fa fa-dashboard"></i>
<span style="margin-left:10px;">利用者マスタ</span>
<i class="right fa fa-angle-down"></i>
</a>
<ul class="nav nav-treeview" style="margin-left: 40px; display: @if(in_array($currentRoute, $userRoutes)) block @else none @endif;">
<li class="nav-item">
<a href="{{ route('users', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'users' ? 'active' : '' }}">
<span style="margin-left:20px;">利用者マスタ</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('regularcontracts', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'regularcontracts' ? 'active' : '' }}">
<span style="margin-left:20px;">定期契約マスタ</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('reserves', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'reserves' ? 'active' : '' }}">
<span style="margin-left:20px;">定期予約マスタ</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('usertypes', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'usertypes' ? 'active' : '' }}">
<span style="margin-left:20px;">利用者分類マスタ</span>
</a>
</li>
</ul>
</li>
<!-- 駐輪場マスタ -->
@php
$priceRoutes = ['parks', 'pricelist', 'prices', 'psections'];
@endphp
<li class="nav-item has-treeview @if(in_array($currentRoute, $priceRoutes)) menu-open @endif">
<a href="#" class="nav-link @if(in_array($currentRoute, $priceRoutes)) active @endif">
<i class="nav-icon fa fa-th"></i>
<span style="margin-left:10px;">駐輪場マスタ</span>
<i class="right fa fa-angle-down"></i>
</a>
<ul class="nav nav-treeview" style="margin-left: 40px; display: @if(in_array($currentRoute, $priceRoutes)) block @else none @endif;">
<li class="nav-item">
<a href="{{ route('parks', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'parks' ? 'active' : '' }}">
<span style="margin-left:20px;">駐輪場マスタ</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('pricelist', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'pricelist' ? 'active' : '' }}">
<span style="margin-left:20px;">料金一覧表</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('prices', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'prices' ? 'active' : '' }}">
<span style="margin-left:20px;">駐輪場所、料金マスタ</span>
</a>
</li>
<li class="nav-item">
<a href="{{ route('psections', ['city_id' => $city->city_id]) }}" class="nav-link {{ $currentRoute === 'psections' ? 'active' : '' }}">
<span style="margin-left:20px;">車種区分マスタ</span>
</a>
</li>
</ul>
</li>
<!-- ダッシュボード -->
<li class="nav-item">
<a href="{{ route('city_dashboard', ['city_id' => $city->city_id]) }}"
class="nav-link {{ $currentRoute === 'city_dashboard' && $currentCityId == $city->city_id ? 'active' : '' }}">
<i class="nav-icon fa fa-dashboard"></i>
<span style="margin-left:10px;">ダッシュボード</span>
</a>
</li>
</ul>
</li>
@endforeach
</ul>
</li>
@endif
@else
<!-- ソーリンユーザー用: 元のメニュー構造を維持 -->
<!-- OU START -->
<!-- ホーム(親) -->
<li class="nav-item has-treeview {{ request()->routeIs('information') ? 'menu-open' : '' }}">
<a href="#" class="nav-link {{ request()->routeIs('information') ? 'active' : '' }}">
<li class="nav-item has-treeview {{ request()->routeIs('information') || request()->routeIs('dashboard') ? 'menu-open' : '' }}">
<a href="#" class="nav-link {{ request()->routeIs('information') || request()->routeIs('dashboard') ? 'active' : '' }}">
<i class="nav-icon fa fa-home"></i>
<p>
ホーム
@ -197,12 +462,17 @@
</p>
</a>
<ul class="nav nav-treeview"
style="{{ request()->routeIs('information') ? 'display:block;' : 'display:none;' }}">
style="{{ request()->routeIs('information') || request()->routeIs('dashboard') ? 'display:block;' : 'display:none;' }}">
<li class="nav-item">
<a href="{{ route('dashboard') }}" class="nav-link {{ request()->routeIs('dashboard') ? 'active' : '' }}">
<span style="margin-left:20px;">総合ダッシュボード</span>
</a>
</li>
<!-- <li class="nav-item">
<a href="#" class="nav-link">
<span style="margin-left:20px;">ハードウェア異常表示</span>
</a>
</li>
</li> -->
<li class="nav-item">
<a href="{{ route('information') }}"
@ -724,12 +994,36 @@
</ul>
</li>
<!-- kin end -->
<!-- ソーリンユーザー用: 運営元メニュー(全自治体表示) -->
@if($visibleCities && $visibleCities->count() > 0)
@php
$currentCityId = request()->route('city_id');
$isCityRoute = request()->routeIs('city_dashboard') || str_contains(request()->url(), '/city/');
@endphp
<li class="nav-item has-treeview @if($isCityRoute) menu-open @endif">
<a href="#" class="nav-link @if($isCityRoute) active @endif">
<i class="nav-icon fa fa-map-marker"></i>
<p>
{{ __("運営元メニュー") }}
<i class="right fa fa-angle-down"></i>
</p>
</a>
<ul class="nav nav-treeview" style="display: @if($isCityRoute) block @else none @endif;">
@foreach($visibleCities as $city)
<li class="nav-item">
<a href="{{ route('city_dashboard', ['city_id' => $city->city_id]) }}"
class="nav-link @if($currentCityId == $city->city_id && request()->routeIs('city_dashboard')) active @endif">
<i class="nav-icon fa fa-building-o"></i>
<span style="margin-left:10px;">{{ $city->city_name }}</span>
</a>
</li>
@endforeach
</ul>
</li>
@endif
@endif
<!-- OU END -->
</ul>
</li>
</ul>
</nav>
<!-- /.sidebar-menu -->
@ -761,6 +1055,132 @@
<!-- jQuery -->
<script src="{{ asset('plugins/jquery/jquery.min.js') }}"></script>
<script src="{{ asset('plugins/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ asset('plugins/select2/js/select2.full.min.js') }}"></script>
<script src="{{ asset('plugins/moment/moment.min.js') }}"></script>
<script src="{{ asset('plugins/inputmask/jquery.inputmask.min.js') }}"></script>
<script src="{{ asset('plugins/daterangepicker/daterangepicker.js') }}"></script>
<script src="{{ asset('plugins/bootstrap-colorpicker/js/bootstrap-colorpicker.min.js') }}"></script>
<script src="{{ asset('plugins/bootstrap-switch/js/bootstrap-switch.min.js') }}"></script>
<script src="{{ asset('plugins/bootstrap-switch/js/bootstrap-switch.min.js') }}"></script>
<script src="{{ asset('plugins/datatables/jquery.dataTables.min.js') }}"></script>
<script src="{{ asset('plugins/datatables-bs4/js/dataTables.bootstrap4.min.js') }}"></script>
<script src="{{ asset('plugins/datatables-responsive/js/dataTables.responsive.min.js') }}"></script>
<script src="{{ asset('plugins/datatables-responsive/js/responsive.bootstrap4.min.js') }}"></script>
<script src="{{ asset('plugins/datatables-buttons/js/dataTables.buttons.min.js') }}"></script>
<script src="{{ asset('plugins/datatables-buttons/js/buttons.bootstrap4.min.js') }}"></script>
<script src="{{ asset('plugins/jszip/jszip.min.js') }}"></script>
<script src="{{ asset('plugins/pdfmake/pdfmake.min.js') }}"></script>
<script src="{{ asset('plugins/pdfmake/vfs_fonts.js') }}"></script>
<script src="{{ asset('plugins/datatables-buttons/js/buttons.html5.min.js') }}"></script>
<script src="{{ asset('plugins/datatables-buttons/js/buttons.print.min.js') }}"></script>
<script src="{{ asset('plugins/datatables-buttons/js/buttons.colVis.min.js') }}"></script>
<script src="{{ asset('plugins/adminlte/js/adminlte.min.js') }}"></script>
<script src="{{ asset('plugins/chart.js/Chart.min.js') }}"></script>
<script src="{{ asset('plugins/sparklines/jquery.sparkline.min.js') }}"></script>
<script src="{{ asset('plugins/jqueryKnob/jquery.knob.min.js') }}"></script>
<script src="{{ asset('plugins/moment/moment.min.js') }}"></script>
<script src="{{ asset('plugins/fullcalendar/index.global.min.js') }}"></script>
<script src="{{ asset('plugins/tempusdominus-bootstrap-4/js/tempusdominus-bootstrap-4.min.js') }}"></script>
<script src="{{ asset('plugins/filterramework/jquery.filter_input.js') }}"></script>
<script src="{{ asset('plugins/filterramework/jquery.csv.min.js') }}"></script>
<script>
$(document).ready(function() {
$('#table-users-list').DataTable({
responsive: true,
lengthChange: false,
autoWidth: false,
buttons: ["csv", "excel", "pdf", "print"]
}).buttons().container().appendTo('#table-users-list_wrapper .col-md-6:eq(0)');
});
</script>
<script>
$(document).ready(function() {
$('#list-all').DataTable({
responsive: true,
lengthChange: false,
autoWidth: false,
buttons: ["csv", "excel", "pdf", "print"]
}).buttons().container().appendTo('#list-all_wrapper .col-md-6:eq(0)');
});
</script>
<script>
$(document).ready(function() {
$('#table-list').DataTable({
responsive: true,
lengthChange: false,
autoWidth: false,
buttons: ["csv", "excel", "pdf", "print"]
}).buttons().container().appendTo('#table-list_wrapper .col-md-6:eq(0)');
});
</script>
<script>
$(document).ready(function() {
$('#table-form-list').DataTable({
responsive: true,
lengthChange: false,
autoWidth: false,
buttons: ["csv", "excel", "pdf", "print"]
}).buttons().container().appendTo('#table-form-list_wrapper .col-md-6:eq(0)');
});
</script>
<script>
$(document).ready(function() {
$('#example1').DataTable({
responsive: true,
lengthChange: false,
autoWidth: false,
buttons: ["csv", "excel", "pdf", "print"]
}).buttons().container().appendTo('#example1_wrapper .col-md-6:eq(0)');
});
</script>
<script>
$(document).ready(function() {
$('#example2').DataTable({
responsive: true,
lengthChange: false,
autoWidth: false,
buttons: ["csv", "excel", "pdf", "print"]
}).buttons().container().appendTo('#example2_wrapper .col-md-6:eq(0)');
});
</script>
<script>
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
</script>
<script>
$(function() {
$('[data-toggle="tooltip"]').tooltip()
$('[data-toggle="popover"]').popover({
showInputs: false
})
});
</script>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}" defer></script>
<style>
.main-sidebar {
width: 280px;
}
.content-wrapper {
margin-left: 280px;
}
</style>
<!-- Bootstrap 4 -->
<script src="{{ asset('plugins/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Select2 -->
@ -857,18 +1277,6 @@
});
</script>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}" defer></script>
<style>
.main-sidebar {
width: 280px;
}
.content-wrapper {
margin-left: 280px;
}
</style>
</body>
</html>

View File

@ -42,6 +42,8 @@ use App\Http\Controllers\Admin\MailTemplateController;
use App\Http\Controllers\Admin\InvSettingController;
use App\Http\Controllers\Admin\ZoneController;
use App\Http\Controllers\Admin\PplaceController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\Auth\EmailOtpController;
/**
@ -70,50 +72,62 @@ Route::middleware('guest')->group(function () {
});
// ログアウトルート(認証済みユーザー専用)
Route::get('logout', [App\Http\Controllers\Auth\LoginController::class, 'logout'])->middleware('auth');
// ログアウトルート認証済みユーザー専用、OTP チェック対象外)
Route::get('logout', [App\Http\Controllers\Auth\LoginController::class, 'logout'])
->middleware(['auth'])
->name('logout');
// 保護されたルート(認証済みユーザー専用)
// Laravel 12変更点middleware()をコントローラーではなくルートで指定
Route::middleware('auth')->group(function () {
// ダッシュボード(ホーム画面)
Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
// OTP メール認証ルートOTP チェック対象外、ここに配置)
// ログイン直後に OTPワンタイムパスワードコード入力画面を表示・検証
Route::prefix('otp')->name('otp.')->group(function () {
Route::get('/', [EmailOtpController::class, 'show'])->name('show');
Route::post('/', [EmailOtpController::class, 'verify'])->name('verify');
Route::post('/resend', [EmailOtpController::class, 'resend'])->name('resend');
});
// Laravel 12 移行時の一時的な占位符路由
// 他の開発者が継続して開発できるように、エラーを防ぐための仮ルート定義
// 実装完了後は各機能の正式なルートに置き換える予定
// 以下のすべてのルートに OTP 認証チェックミドルウェアを適用
Route::middleware('ensure.otp.verified')->group(function () {
// ダッシュボード(ホーム画面)
Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
// 利用者マスタ管理機能(仮ルート)
Route::match(['get', 'post'], '/users', function () {
return view('admin.placeholder', ['title' => '利用者マスタ', 'feature' => 'users']);
})->name('users');
// Laravel 12 移行時の一時的な占位符路由
// 他の開発者が継続して開発できるように、エラーを防ぐための仮ルート定義
// 実装完了後は各機能の正式なルートに置き換える予定
Route::match(['get', 'post'], '/users/add', function () {
return view('admin.placeholder', ['title' => '利用者追加', 'feature' => 'users']);
})->name('user_add');
// 利用者マスタ管理機能(仮ルート)
Route::match(['get', 'post'], '/users', function () {
return view('admin.placeholder', ['title' => '利用者マスタ', 'feature' => 'users']);
})->name('users');
Route::match(['get', 'post'], '/users/edit/{seq}', function ($seq) {
return view('admin.placeholder', ['title' => '利用者編集', 'feature' => 'users', 'id' => $seq]);
})->name('user_edit')->where(['seq' => '[0-9]+']);
Route::match(['get', 'post'], '/users/add', function () {
return view('admin.placeholder', ['title' => '利用者追加', 'feature' => 'users']);
})->name('user_add');
Route::match(['get', 'post'], '/users/info/{seq}', function ($seq) {
return view('admin.placeholder', ['title' => '利用者詳細', 'feature' => 'users', 'id' => $seq]);
})->name('user_info')->where(['seq' => '[0-9]+']);
Route::match(['get', 'post'], '/users/edit/{seq}', function ($seq) {
return view('admin.placeholder', ['title' => '利用者編集', 'feature' => 'users', 'id' => $seq]);
})->name('user_edit')->where(['seq' => '[0-9]+']);
Route::match(['get', 'post'], '/users/import', function () {
return redirect()->route('users')->with('info', 'インポート機能は現在実装中です。');
})->name('users_import');
Route::match(['get', 'post'], '/users/info/{seq}', function ($seq) {
return view('admin.placeholder', ['title' => '利用者詳細', 'feature' => 'users', 'id' => $seq]);
})->name('user_info')->where(['seq' => '[0-9]+']);
Route::get('/users/export', function () {
return redirect()->route('users')->with('info', 'エクスポート機能は現在実装中です。');
})->name('users_export');
Route::match(['get', 'post'], '/users/import', function () {
return redirect()->route('users')->with('info', 'インポート機能は現在実装中です。');
})->name('users_import');
// その他の管理機能の仮ルート(必要に応じて追加)
Route::match(['get', 'post'], '/regular_contracts', function () {
return view('admin.placeholder', ['title' => '定期契約管理', 'feature' => 'regular_contracts']);
})->name('regular_contracts');
Route::get('/users/export', function () {
return redirect()->route('users')->with('info', 'エクスポート機能は現在実装中です。');
})->name('users_export');
// その他の管理機能の仮ルート(必要に応じて追加)
Route::match(['get', 'post'], '/regular_contracts', function () {
return view('admin.placeholder', ['title' => '定期契約管理', 'feature' => 'regular_contracts']);
})->name('regular_contracts');
Route::match(['get', 'post'], '/parks', function () {
return view('admin.placeholder', ['title' => '駐輪場管理', 'feature' => 'parks']);
@ -151,6 +165,12 @@ Route::middleware('auth')->group(function () {
// sou end
// ou start
// 自治体ダッシュボード
Route::get('/city/{city_id}/dashboard', [CityController::class, 'dashboard'])
->where(['city_id' => '[0-9]+'])
->name('city_dashboard')
->middleware('check.city.access');
// 市区マスタ
Route::match(['get', 'post'], '/city', [CityController::class, 'list'])->name('city');
Route::match(['get', 'post'], '/city/add', [CityController::class, 'add'])->name('city_add');
@ -265,6 +285,7 @@ Route::middleware('auth')->group(function () {
// 常時表示インフォメーション
Route::get('/information', [InformationController::class, 'list'])->name('information');
Route::get('/dashboard', [InformationController::class, 'dashboard'])->name('dashboard');
Route::post('/information/status', [InformationController::class, 'updateStatus'])->name('information.status');
// タグ発行キュー処理、履歴表示
@ -495,7 +516,13 @@ Route::middleware('auth')->group(function () {
->name('zones_delete');
//kin end
});
Route::get('/opes/{id}/permissions', [OpeController::class, 'getPermissionsByFeature'])
->where(['id' => '[0-9]+'])
->name('opes.permissions_by_feature');
}); // ensure.otp.verified ミドルウェアグループの閉じ括弧
}); // auth ミドルウェアグループの閉じ括弧
// Wellnet PUSH webhook (SHJ-4A)