SMerge 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 23s

This commit is contained in:
kin.rinzen 2026-02-04 11:40:32 +09:00
commit a118709f9b
57 changed files with 5738 additions and 805 deletions

View File

@ -0,0 +1,81 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Ope;
use Carbon\Carbon;
class CheckPasswordExpiry extends Command
{
/**
* コマンドの名前と説明
*
* @var string
*/
protected $signature = 'password:check-expiry {ope_id?}';
/**
* @var string
*/
protected $description = 'パスワード有効期限をチェック';
/**
* コマンド実行
*/
public function handle(): int
{
$opeId = $this->argument('ope_id');
if ($opeId) {
$ope = Ope::find($opeId);
if (!$ope) {
$this->error("Ope with ID {$opeId} not found");
return 1;
}
$this->checkOpe($ope);
} else {
// すべてのオペレータをチェック
$opes = Ope::all();
foreach ($opes as $ope) {
$this->checkOpe($ope);
}
}
return 0;
}
/**
* 単一オペレータの有効期限をチェック
*/
private function checkOpe(Ope $ope): void
{
$this->info("=== Ope ID: {$ope->ope_id} ({$ope->ope_name}) ===");
$this->info("ope_pass_changed_at: " . ($ope->ope_pass_changed_at ?? 'NULL'));
if (is_null($ope->ope_pass_changed_at)) {
$this->warn("❌ Password change REQUIRED (never changed)");
return;
}
try {
$changedAt = Carbon::parse($ope->ope_pass_changed_at);
$now = Carbon::now();
$monthsDiff = $now->diffInMonths($changedAt);
$this->info("Changed At: " . $changedAt->format('Y-m-d H:i:s'));
$this->info("Now: " . $now->format('Y-m-d H:i:s'));
$this->info("Months Difference: {$monthsDiff}");
if ($monthsDiff >= 3) {
$this->warn("❌ Password change REQUIRED ({$monthsDiff} months passed)");
} else {
$this->line("✅ Password is valid ({$monthsDiff} months passed, {3 - $monthsDiff} months remaining)");
}
} catch (\Exception $e) {
$this->error("Error parsing date: {$e->getMessage()}");
}
$this->line("");
}
}

View File

@ -20,23 +20,20 @@ final class CityController extends Controller
$sortType = (string) $request->input('sort_type', 'asc'); $sortType = (string) $request->input('sort_type', 'asc');
$page = (int) $request->get('page', 1); $page = (int) $request->get('page', 1);
// ソート許可(安全 + 規約) $query = City::query();
$sortable = ['city_id', 'city_name', 'print_layout', 'city_remarks', 'created_at', 'updated_at'];
if (!in_array($sort, $sortable, true)) { if ($request->filled('city_name')) {
$sort = 'city_id'; $query->where('city_name', 'like', '%' . $request->input('city_name') . '%');
} }
$sortType = strtolower($sortType); // 排序处理
if (!in_array($sortType, ['asc', 'desc'], true)) { if (!empty($sort)) {
$sortType = 'asc'; $query->orderBy($sort, $sortType);
} }
$list = $service->paginateList( $list = $query->paginate(20);
$request->input('city_name'),
$sort,
$sortType
);
// 页码越界处理
if ($list->total() > 0 && $page > $list->lastPage()) { if ($list->total() > 0 && $page > $list->lastPage()) {
return redirect()->route('cities.index', [ return redirect()->route('cities.index', [
'sort' => $sort, 'sort' => $sort,
@ -54,56 +51,127 @@ final class CityController extends Controller
public function create(): View public function create(): View
{ {
return view('admin.cities.create', [ $inputs = [
'record' => new City(), 'city_name' => '',
'print_layout' => '',
'city_user' => '',
'city_remarks' => '',
];
if ($request->isMethod('POST')) {
$rules = [
'city_name' => ['required', 'string', 'max:10', 'regex:/^[^ -~。-゚]+$/u'],
'print_layout' => ['required', 'string', 'max:10', 'regex:/^[^ -~。-゚]+$/u'],
'city_user' => ['required', 'string', 'max:10', 'regex:/^[^ -~。-゚]+$/u'],
'city_remarks' => ['nullable', 'string', 'max:20'],
];
$messages = [
'city_name.required' => '市区名は必須です。',
'city_name.regex' => '市区名は全角で入力してください。',
'print_layout.required' => '印字レイアウトファイルは必須です。',
'print_layout.regex' => '印字レイアウトファイルは全角で入力してください。',
'city_user.required' => '顧客M入力不要フィールドIDは必須です。',
'city_user.regex' => '顧客M入力不要フィールドIDは全角で入力してください。',
'city_remarks.max' => '備考は20文字以内で入力してください。',
];
$validator = Validator::make($request->all(), $rules, $messages);
$inputs = array_merge($inputs, $request->all());
if (!$validator->fails()) {
$maxId = DB::table('city')->max('city_id');
$newCityId = $maxId ? $maxId + 1 : 1;
$city = new City();
$city->city_id = $newCityId;
$city->fill($request->only([
'city_name',
'print_layout',
'city_user',
'city_remarks',
]));
if ($city->save()) {
$request->session()->flash('success', __('登録に成功しました'));
return redirect()->route('city');
} else {
$request->session()->flash('error', __('登録に失敗しました'));
}
} else {
$inputs['errorMsg'] = $validator->errors()->all();
}
}
return view('admin.CityMaster.add', $inputs);
}
public function edit(Request $request, $pk, $view = '')
{
$city = City::find($pk);
if (!$city) {
abort(404);
}
if ($request->isMethod('POST')) {
$rules = [
'city_name' => ['required', 'string', 'max:10', 'regex:/^[^ -~。-゚]+$/u'],
'print_layout' => ['required', 'string', 'max:10', 'regex:/^[^ -~。-゚]+$/u'],
'city_user' => ['required', 'string', 'max:10', 'regex:/^[^ -~。-゚]+$/u'],
'city_remarks' => ['nullable', 'string', 'max:20'],
];
$messages = [
'city_name.required' => '市区名は必須です。',
'city_name.regex' => '市区名は全角で入力してください。',
'print_layout.required' => '印字レイアウトファイルは必須です。',
'print_layout.regex' => '印字レイアウトファイルは全角で入力してください。',
'city_user.required' => '顧客M入力不要フィールドIDは必須です。',
'city_user.regex' => '顧客M入力不要フィールドIDは全角で入力してください。',
'city_remarks.max' => '備考は20文字以内で入力してください。',
];
$validator = Validator::make($request->all(), $rules, $messages);
if (!$validator->fails()) {
$city->fill($request->only([
'city_name',
'print_layout',
'city_user',
'city_remarks',
]));
if ($city->save()) {
$request->session()->flash('success', __('更新に成功しました'));
return redirect()->route('city');
} else {
$request->session()->flash('error', __('更新に失敗しました'));
}
} else {
return view('admin.CityMaster.edit', [
'city' => $city,
'errorMsg' => $validator->errors()->all(),
]);
}
}
return view($view ?: 'admin.CityMaster.edit', [
'city' => $city,
]); ]);
} }
public function store(CityRequest $request, CityService $service): RedirectResponse public function info(Request $request, $pk)
{ {
$service->create($request->validated()); return $this->edit($request, $pk, 'CityMaster.info');
return redirect()
->route('cities.index')
->with('success', __('登録に成功しました'));
} }
public function edit(int $id): View public function delete(Request $request)
{ {
$city = City::findOrFail($id); $arr_pk = $request->get('pk');
if (!$arr_pk) {
return view('admin.cities.edit', [ return redirect()->route('city')->with('error', __('削除する市区を選択してください。'));
'record' => $city, }
]); if (City::destroy($arr_pk)) {
return redirect()->route('city')->with('success', __("削除が完了しました。"));
} else {
return redirect()->route('city')->with('error', __('削除に失敗しました。'));
} }
public function update(CityRequest $request, int $id, CityService $service): RedirectResponse
{
$city = City::findOrFail($id);
$service->update($city, $request->validated());
return redirect()
->route('cities.index')
->with('success', __('更新に成功しました'));
}
public function destroy(Request $request): RedirectResponse
{
$ids = $request->input('pk');
// pk が単体でも配列でも受けられるようにする(編集画面/一覧画面両対応)
if ($ids === null || $ids === '' || $ids === []) {
return redirect()
->route('cities.index')
->with('error', __('削除する市区を選択してください。'));
}
$ids = is_array($ids) ? $ids : [$ids];
$deleted = City::destroy($ids);
return $deleted
? redirect()->route('cities.index')->with('success', __('削除が完了しました。'))
: redirect()->route('cities.index')->with('error', __('削除に失敗しました。'));
} }
} }

View File

@ -56,6 +56,189 @@ class InformationController extends Controller
return view('admin.information.list', compact('jobs','period','type','status')); return view('admin.information.list', compact('jobs','period','type','status'));
} }
// ダッシュボード表示
public function dashboard(Request $request)
{
// ダッシュボード統計情報を集計
// park_number テーブルから総容量を計算
// park_standard標準 + park_number割当+ park_limit制限値の合算
$totalCapacity = DB::table('park_number')
->selectRaw('
COALESCE(SUM(park_standard), 0) as std_sum,
COALESCE(SUM(park_number), 0) as num_sum,
COALESCE(SUM(park_limit), 0) as limit_sum
')
->first();
$totalCapacityValue = ($totalCapacity->std_sum ?? 0) +
($totalCapacity->num_sum ?? 0) +
($totalCapacity->limit_sum ?? 0);
// 予約待ち人数reserve テーブルから集計)
// 条件:有効(valid_flag=1) かつ契約化されていない(contract_id IS NULL)
// キャンセル除外reserve_cancel_flag が NULL または 0、かつ reserve_cancelday が NULL
$reserveQuery = DB::table('reserve')
->where('valid_flag', 1)
->whereNull('contract_id');
// キャンセルフラグの有無をチェック(列が存在するかどうか)
try {
$testResult = DB::table('reserve')
->select(DB::raw('1'))
->whereNotNull('reserve_cancel_flag')
->limit(1)
->first();
// 列が存在する場合、キャンセル除外条件を追加
$reserveQuery = $reserveQuery
->where(function ($q) {
$q->whereNull('reserve_cancel_flag')
->orWhere('reserve_cancel_flag', 0);
})
->whereNull('reserve_cancelday');
} catch (\Exception $e) {
// キャンセルフラグが未運用の場合は基本条件のみで計算
}
$totalWaiting = $reserveQuery->count();
// 使用中台数park_number の park_number が使用台数)
$totalUsed = DB::table('park_number')
->sum('park_number') ?? 0;
// 空き台数 = 総容量 - 使用中台数
$totalVacant = max(0, $totalCapacityValue - $totalUsed);
// 利用率計算(小数点以下切捨て)
$utilizationRate = $totalCapacityValue > 0
? (int) floor(($totalUsed / $totalCapacityValue) * 100)
: 0;
// 予約待ち率超過時のみ、超過なしは0%
// 超過判定:待機人数 > 空き台数
$totalWaitingRate = 0;
if ($totalCapacityValue > 0 && $totalWaiting > 0 && $totalWaiting > $totalVacant) {
// 超過分 / 総容量 * 100分母チェック付き
$totalWaitingRate = (int) floor((($totalWaiting - $totalVacant) / $totalCapacityValue) * 100);
}
$totalStats = [
'total_cities' => DB::table('city')->count(),
'total_parks' => DB::table('park')->count(),
'total_contracts' => DB::table('regular_contract')->count(),
'total_users' => DB::table('user')->count(),
'total_devices' => DB::table('device')->count(),
'today_queues' => DB::table('operator_que')
->whereDate('created_at', today())
->count(),
'total_waiting' => $totalWaiting,
'total_capacity' => $totalCapacityValue,
'total_utilization_rate' => $utilizationRate,
'total_vacant_number' => $totalVacant,
'total_waiting_rate' => $totalWaitingRate,
];
// 自治体別統計情報を作成
$cityStats = [];
$cities = DB::table('city')->get();
foreach ($cities as $city) {
// その自治体に属する駐輪場 ID を取得
$parkIds = DB::table('park')
->where('city_id', $city->city_id)
->pluck('park_id')
->toArray();
// ① 駐輪場数
$parksCount = count($parkIds);
// ② 総収容台数park_number テーブルの park_standard を合算)
$capacity = 0;
if (!empty($parkIds)) {
$capacityResult = DB::table('park_number')
->whereIn('park_id', $parkIds)
->sum('park_standard');
$capacity = $capacityResult ?? 0;
}
// ③ 契約台数contract_cancel_flag = 0 かつ有効期間内)
$contractsCount = 0;
if (!empty($parkIds)) {
$contractsCount = DB::table('regular_contract')
->whereIn('park_id', $parkIds)
->where('contract_cancel_flag', 0)
->where(function ($q) {
// 有効期間内:開始日 <= 今日 かつ 終了日 >= 今日
$q->where('contract_periods', '<=', now())
->where('contract_periode', '>=', now());
})
->count();
}
// ④ 利用率計算(小数点以下切捨て)
$utilizationRate = $capacity > 0
? (int) floor(($contractsCount / $capacity) * 100)
: 0;
// ⑤ 空き台数
$availableSpaces = max(0, $capacity - $contractsCount);
// ⑥ 予約待ち人数reserve テーブルで contract_id IS NULL かつ valid_flag = 1
$waitingCount = 0;
if (!empty($parkIds)) {
$waitingQuery = DB::table('reserve')
->whereIn('park_id', $parkIds)
->where('valid_flag', 1)
->whereNull('contract_id');
// キャンセルフラグの有無をチェック
try {
DB::table('reserve')
->select(DB::raw('1'))
->whereNotNull('reserve_cancel_flag')
->limit(1)
->first();
// 列が存在する場合、キャンセル除外条件を追加
$waitingQuery = $waitingQuery
->where(function ($q) {
$q->whereNull('reserve_cancel_flag')
->orWhere('reserve_cancel_flag', 0);
})
->whereNull('reserve_cancelday');
} catch (\Exception $e) {
// キャンセルフラグが未運用の場合は基本条件のみで計算
}
$waitingCount = $waitingQuery->count();
}
// ⑦ 利用者数(ユニークユーザー数)
$usersCount = 0;
if (!empty($parkIds)) {
$usersCount = DB::table('regular_contract')
->whereIn('park_id', $parkIds)
->distinct()
->count('user_id');
}
// 配列に追加
$cityStats[] = [
'city' => $city,
'parks_count' => $parksCount,
'contracts_count' => $contractsCount,
'users_count' => $usersCount,
'waiting_count' => $waitingCount,
'capacity' => $capacity,
'utilization_rate' => $utilizationRate,
'available_spaces' => $availableSpaces,
];
}
return view('admin.information.dashboard', compact('totalStats', 'cityStats'));
}
// ステータス一括更新(着手=2 / 対応完了=3 // ステータス一括更新(着手=2 / 対応完了=3
public function updateStatus(Request $request) public function updateStatus(Request $request)
{ {

View File

@ -1,4 +1,5 @@
<?php <?php
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -7,6 +8,9 @@ use App\Models\Ope;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Models\Feature;
use App\Models\Permission;
use App\Models\OpePermission;
class OpeController extends Controller class OpeController extends Controller
{ {
@ -27,7 +31,6 @@ class OpeController extends Controller
$sort = $inputs['sort']; $sort = $inputs['sort'];
$sort_type = $inputs['sort_type']; $sort_type = $inputs['sort_type'];
return view('admin.opes.list', compact('list', 'sort', 'sort_type')); return view('admin.opes.list', compact('list', 'sort', 'sort_type'));
} }
@ -36,12 +39,21 @@ class OpeController extends Controller
*/ */
public function add(Request $request) public function add(Request $request)
{ {
// ※機能(画面)一覧を取得(プルダウン用)
$features = Feature::query()
->orderBy('id')
->get(['id', 'name']);
// ※操作権限一覧を取得(チェックボックス用)
$permissions = Permission::query()
->orderBy('id')
->get(['id', 'code', 'name']);
if ($request->isMethod('get')) { if ($request->isMethod('get')) {
return view('admin.opes.add', [ return view('admin.opes.add', [
'isEdit' => false, 'isEdit' => false,
'record' => new Ope(), 'record' => new Ope(),
'ope_id' => null, 'ope_id' => null,
'ope_name' => '', 'ope_name' => '',
'ope_type' => '', 'ope_type' => '',
@ -55,6 +67,11 @@ class OpeController extends Controller
'ope_sendalart_que13' => 0, 'ope_sendalart_que13' => 0,
'ope_auth1' => '', 'ope_auth2' => '', 'ope_auth3' => '', 'ope_auth4' => '', 'ope_auth1' => '', 'ope_auth2' => '', 'ope_auth3' => '', 'ope_auth4' => '',
'ope_quit_flag' => 0, 'ope_quitday' => '', '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', '登録しました。'); return redirect()->route('opes')->with('success', '登録しました。');
} }
/** /**
* 編集GET 画面 / POST 更新) * 編集GET 画面 / POST 更新)
* ※権限(自治体×機能×操作)も同画面で設定する
*/ */
public function edit($id, Request $request) public function edit($id, Request $request)
{ {
$ope = Ope::getByPk($id); $ope = Ope::getByPk($id);
if (!$ope) abort(404); 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')) { if ($request->isMethod('get')) {
return view('admin.opes.edit', [ return view('admin.opes.edit', [
'isEdit' => true, 'isEdit' => true,
'record' => $ope, '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(); $data = $request->all();
// --- バリデーション --- // --- バリデーション ---
$rules = [ $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_name' => 'required|string|max:255',
'ope_type' => 'required|string|max:50', 'ope_type' => 'required|string|max:50',
'ope_phone' => 'nullable|string|max:50', 'ope_phone' => 'nullable|string|max:50',
'ope_mail' => [ 'ope_mail' => [
'required', 'required',
function ($attribute, $value, $fail) { function ($attribute, $value, $fail) {
// , でも ; でもOKにする
$emails = array_map('trim', explode(';', str_replace(',', ';', $value))); $emails = array_map('trim', explode(';', str_replace(',', ';', $value)));
foreach ($emails as $email) { foreach ($emails as $email) {
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_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); $request->validate($rules);
@ -157,11 +211,35 @@ class OpeController extends Controller
return redirect()->route('opes')->with('success', '更新しました。'); 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 複数) * 削除(単体 or 複数)
*/ */
public function delete(Request $request) public function delete(Request $request)
{ {
$ids = []; $ids = [];

View File

@ -0,0 +1,204 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Models\Park;
use App\Models\ParkingRegulation;
class ParkingRegulationsController extends Controller
{
/**
* 一覧表示
*/
public function list(Request $request)
{
// park_id 検証
$request->validate([
'park_id' => 'required|integer|exists:park,park_id',
], [
'park_id.required' => '駐輪場IDは必須です。',
'park_id.integer' => '駐輪場IDは整数である必要があります。',
'park_id.exists' => '指定された駐輪場が見つかりません。',
]);
$parkId = (int) $request->input('park_id');
// 駐輪場情報取得
$park = Park::where('park_id', $parkId)->firstOrFail();
// parking_regulations を取得し、psection / ptype 名を JOIN して表示
$data = DB::table('parking_regulations')
->leftJoin('psection', 'parking_regulations.psection_id', '=', 'psection.psection_id')
->leftJoin('ptype', 'parking_regulations.ptype_id', '=', 'ptype.ptype_id')
->where('parking_regulations.park_id', $parkId)
->select(
'parking_regulations.parking_regulations_seq',
'parking_regulations.park_id',
'parking_regulations.psection_id',
'parking_regulations.ptype_id',
'parking_regulations.regulations_text',
'psection.psection_subject as psection_subject',
'ptype.ptype_subject as ptype_subject'
)
->orderBy('parking_regulations.psection_id')
->orderBy('parking_regulations.ptype_id')
->paginate(50);
return view('admin.parking_regulations.list', [
'park' => $park,
'parkId' => $parkId,
'regulations' => $data,
]);
}
/**
* 新規作成フォーム表示/登録
*/
public function add(Request $request)
{
$parkId = $request->input('park_id');
// 駐輪場存在確認
if (!$parkId) {
return redirect()->back()->withErrors(['park_id' => '駐輪場IDが指定されていません。']);
}
$park = Park::where('park_id', $parkId)->firstOrFail();
// マスタの選択肢取得
$psections = DB::table('psection')->orderBy('psection_id')->get();
$ptypes = DB::table('ptype')->orderBy('ptype_id')->get();
if ($request->isMethod('post')) {
// 登録処理
$validated = $request->validate([
'park_id' => 'required|integer|exists:park,park_id',
'psection_id' => 'required|integer',
'ptype_id' => 'required|integer',
'regulations_text' => 'nullable|string',
], [
'park_id.required' => '駐輪場IDは必須です。',
'psection_id.required' => '車種区分は必須です。',
'ptype_id.required' => '駐輪分類は必須です。',
]);
// 重複チェック
$exists = DB::table('parking_regulations')
->where('park_id', $validated['park_id'])
->where('psection_id', $validated['psection_id'])
->where('ptype_id', $validated['ptype_id'])
->exists();
if ($exists) {
return back()->withErrors(['duplicate' => '同じ組み合わせの規定が既に存在します。'])->withInput();
}
ParkingRegulation::create([
'park_id' => $validated['park_id'],
'psection_id' => $validated['psection_id'],
'ptype_id' => $validated['ptype_id'],
'regulations_text' => $validated['regulations_text'] ?? null,
]);
return redirect()->route('parking_regulations_list', ['park_id' => $validated['park_id']])->with('success', '登録しました。');
}
return view('admin.parking_regulations.add', [
'park' => $park,
'parkId' => $parkId,
'psections' => $psections,
'ptypes' => $ptypes,
]);
}
/**
* 編集フォーム表示
*/
public function edit($seq, Request $request)
{
$record = DB::table('parking_regulations')->where('parking_regulations_seq', $seq)->first();
if (!$record) {
return redirect()->back()->withErrors(['not_found' => '指定の規定が見つかりません。']);
}
$park = Park::where('park_id', $record->park_id)->firstOrFail();
$psections = DB::table('psection')->orderBy('psection_id')->get();
$ptypes = DB::table('ptype')->orderBy('ptype_id')->get();
return view('admin.parking_regulations.edit', [
'park' => $park,
'record' => $record,
'psections' => $psections,
'ptypes' => $ptypes,
]);
}
/**
* 更新処理
*/
public function update($seq, Request $request)
{
$validated = $request->validate([
'psection_id' => 'required|integer',
'ptype_id' => 'required|integer',
'regulations_text' => 'nullable|string',
], [
'psection_id.required' => '車種区分は必須です。',
'ptype_id.required' => '駐輪分類は必須です。',
]);
// 対象レコード取得
$record = DB::table('parking_regulations')->where('parking_regulations_seq', $seq)->first();
if (!$record) {
return back()->withErrors(['not_found' => '指定の規定が見つかりません。']);
}
// 重複チェック(自分自身は除外)
$exists = DB::table('parking_regulations')
->where('park_id', $record->park_id)
->where('psection_id', $validated['psection_id'])
->where('ptype_id', $validated['ptype_id'])
->where('parking_regulations_seq', '<>', $seq)
->exists();
if ($exists) {
return back()->withErrors(['duplicate' => '同じ組み合わせの規定が既に存在します。'])->withInput();
}
DB::table('parking_regulations')->where('parking_regulations_seq', $seq)->update([
'psection_id' => $validated['psection_id'],
'ptype_id' => $validated['ptype_id'],
'regulations_text' => $validated['regulations_text'] ?? null,
'updated_at' => now(),
]);
return redirect()->route('parking_regulations_list', ['park_id' => $record->park_id])->with('success', '更新しました。');
}
/**
* 削除処理
*/
public function delete(Request $request)
{
$validated = $request->validate([
'parking_regulations_seq' => 'required|integer',
], [
'parking_regulations_seq.required' => '削除対象が指定されていません。',
]);
$seq = (int) $validated['parking_regulations_seq'];
$record = DB::table('parking_regulations')->where('parking_regulations_seq', $seq)->first();
if (!$record) {
return back()->withErrors(['not_found' => '指定の規定が見つかりません。']);
}
DB::table('parking_regulations')->where('parking_regulations_seq', $seq)->delete();
return redirect()->route('parking_regulations_list', ['park_id' => $record->park_id])->with('success', '削除しました。');
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Park;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ReductionConfirmMasterController extends Controller
{
/**
* 減免確認マスタ画面を表示
*
* @param Request $request park_id をクエリパラメータで受け取る
* @return \Illuminate\View\View
*/
public function list(Request $request)
{
// park_id の検証
$request->validate([
'park_id' => 'required|integer|exists:park,park_id',
], [
'park_id.required' => '駐輪場IDは必須です。',
'park_id.integer' => '駐輪場IDは整数である必要があります。',
'park_id.exists' => '指定された駐輪場が見つかりません。',
]);
$parkId = (int) $request->input('park_id');
// 駐輪場情報を取得
$park = Park::where('park_id', $parkId)->firstOrFail();
// reduction_confirm を主テーブルとして、usertype と JOIN して一覧を取得
// WHERE park_id = ? で対象駐輪場のレコードのみ取得
$reductionData = DB::table('reduction_confirm')
->leftJoin('usertype', 'reduction_confirm.user_categoryid', '=', 'usertype.user_categoryid')
->where('reduction_confirm.park_id', $parkId)
->orderBy('reduction_confirm.user_categoryid', 'asc')
->select(
'reduction_confirm.park_id',
'reduction_confirm.user_categoryid',
'reduction_confirm.reduction_confirm_type',
'usertype.usertype_subject1',
'usertype.usertype_subject2',
'usertype.usertype_subject3'
)
->paginate(50);
return view('admin.reduction_confirm.list', [
'park' => $park,
'parkId' => $parkId,
'reductionData' => $reductionData,
]);
}
/**
* 減免確認情報を一括更新
*
* @param Request $request
* @return \Illuminate\Http\RedirectResponse
*/
public function store(Request $request)
{
// バリデーション
$validated = $request->validate([
'park_id' => 'required|integer|exists:park,park_id',
'row_user_categoryid' => 'array',
'row_user_categoryid.*' => 'integer',
'reduction_confirm_type' => 'array',
'reduction_confirm_type.*' => 'in:0,1,2',
], [
'park_id.required' => '駐輪場IDは必須です。',
'park_id.integer' => '駐輪場IDは整数である必要があります。',
'park_id.exists' => '指定された駐輪場が見つかりません。',
'reduction_confirm_type.*.in' => '減免確認種別は0, 1, 2 のいずれかである必要があります。',
]);
$parkId = (int) $validated['park_id'];
// ログイン中のオペレータID取得
$opeId = auth()->user()->ope_id ?? null;
// POST された配列は index ベースで来るため、row_user_categoryid のインデックスに合わせてマッピングする
$rowUserCategory = $request->input('row_user_categoryid', []);
$types = $request->input('reduction_confirm_type', []);
try {
DB::transaction(function () use ($parkId, $rowUserCategory, $types, $opeId) {
foreach ($rowUserCategory as $idx => $userCategoryId) {
if (!isset($types[$idx])) {
continue;
}
$type = (int) $types[$idx];
DB::table('reduction_confirm')
->where('park_id', $parkId)
->where('user_categoryid', (int) $userCategoryId)
->update([
'reduction_confirm_type' => $type,
'updated_at' => now(),
'ope_id' => $opeId,
]);
}
});
return redirect()->route('reduction_confirm_list', ['park_id' => $parkId])
->with('success', '減免確認マスタを更新しました。');
} catch (\Exception $e) {
\Log::error('ReductionConfirm update failed', [
'park_id' => $parkId,
'error' => $e->getMessage(),
]);
return back()->withErrors(['error' => '更新に失敗しました。管理者にお問い合わせください。'])
->withInput();
}
}
}

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,8 @@ use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class ForgotPasswordController extends Controller class ForgotPasswordController extends Controller
{ {
@ -37,24 +39,73 @@ class ForgotPasswordController extends Controller
return back()->withErrors(['email' => '該当するユーザーが見つかりません。']); return back()->withErrors(['email' => '該当するユーザーが見つかりません。']);
} }
// 5分間隔のメール送信制限チェック最新のトークンを対象
$lastToken = DB::table('password_reset_tokens')
->where('ope_mail', $user->ope_mail)
->orderByDesc('created_at')
->first();
if ($lastToken) {
// タイムゾーンを明示的に指定デフォルトはUTCで解析される可能性がある
$lastCreatedAt = Carbon::parse($lastToken->created_at, config('app.timezone'));
$now = now();
// 経過秒数で判定
$diffSeconds = $lastCreatedAt->diffInSeconds(now(), false);
$limitSeconds = 5 * 60; // 5分
if ($diffSeconds < $limitSeconds) {
$remainSeconds = $limitSeconds - $diffSeconds;
// 残り秒を「分」に変換端数は切り上げ1秒残りでも1分と表示
$waitMinutes = (int) ceil($remainSeconds / 60);
return back()->withErrors([
'email' => "パスワード再設定メールは5分以上の間隔を置いて送信してください。{$waitMinutes}分後に再度お試しください。"
]);
}
}
// トークン生成 // トークン生成
$token = Str::random(60); $token = Str::random(60);
// SHA256ハッシュで保存セキュリティ向上
$tokenHash = hash('sha256', $token);
// トークン保存(既存レコードがあれば更新) // トークン保存(既存レコードがあれば更新)
DB::table('password_reset_tokens')->updateOrInsert( DB::table('password_reset_tokens')->updateOrInsert(
['ope_mail' => $user->ope_mail], ['ope_mail' => $user->ope_mail],
[ [
'token' => $token, 'token' => $tokenHash,
'created_at' => now(), 'created_at' => now(),
] ]
); );
// メール送信 // メール送信
try {
$resetUrl = url('/reset-password?token=' . $token . '&email=' . urlencode($user->ope_mail)); $resetUrl = url('/reset-password?token=' . $token . '&email=' . urlencode($user->ope_mail));
Mail::raw("下記URLからパスワード再設定を行ってください。\n\n{$resetUrl}", function ($message) use ($user) {
$body = $user->ope_name . "\n\n" .
"So-Managerをご利用いただき、ありがとうございます。\n\n" .
"本メールは、パスワード再設定のご依頼を受けてお送りしております。\n\n" .
"以下のURLをクリックし、新しいパスワードを設定してください。\n\n" .
$resetUrl . "\n\n" .
"※このURLの有効期限は、24時間です。\n" .
"※有効期限を過ぎた場合は、再度パスワード再設定手続きを行ってください。\n" .
"※本メールにお心当たりがない場合は、本メールを破棄してください。\n\n" .
"_________________________________\n" .
"So-Manager サポートセンター\n" .
"E-mail : support@so-manager.com\n" .
"URL : https://www.so-manager.com/\n" .
"_________________________________";
Mail::raw($body, function ($message) use ($user) {
$message->to($user->ope_mail) $message->to($user->ope_mail)
->subject('パスワード再設定のご案内'); ->from(config('mail.from.address'), config('mail.from.name'))
->subject('【【So-Manager】パスワード再設定のご案内】');
}); });
} catch (\Throwable $e) {
Log::error('ForgotPassword mail send failed', [
'to' => $user->ope_mail,
'error' => $e->getMessage(),
]);
return back()->withErrors(['email' => 'メール送信に失敗しました。サーバログを確認してください。']);
}
return back()->with('status', 'パスワード再設定メールを送信しました。'); return back()->with('status', 'パスワード再設定メールを送信しました。');
} }

View File

@ -3,8 +3,12 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Mail\EmailOtpMail;
use App\Services\EmailOtpService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -135,6 +139,10 @@ class LoginController extends Controller
/** /**
* ログイン成功時のレスポンス * ログイン成功時のレスポンス
* *
* OTP認証チェック
* - 24時間以内に OTP 認証済みの場合:/home にリダイレクト
* - 未認証の場合OTP メール送信 /otp にリダイレクト
*
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
*/ */
@ -147,9 +155,47 @@ class LoginController extends Controller
// ここで保持する値も login_id入力名は ope_id のまま) // ここで保持する値も login_id入力名は ope_id のまま)
$request->session()->put('login_ope_id', $request->input('ope_id')); $request->session()->put('login_ope_id', $request->input('ope_id'));
// OTP認証チェック
$otpService = app(EmailOtpService::class);
$user = Auth::user();
// 24時間以内に OTP 認証済みの場合
if ($otpService->isOtpRecent($user)) {
return redirect()->intended($this->redirectTo); 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

@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\ChangePasswordRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon;
class PasswordChangeController extends Controller
{
/**
* コントローラーのコンストラクタ
*
* ログイン状態のユーザーのみアクセス可能
*/
public function __construct()
{
// Laravel 12: ミドルウェアは routes/web.php で処理
}
/**
* パスワード変更フォーム表示
*
* GET /password/change
*
* @return \Illuminate\View\View
*/
public function showChangeForm()
{
// 現在のユーザー情報を取得
$ope = Auth::user();
// ビューにパスワード変更が必須かどうかを判定するデータを渡す
$isRequired = $this->isPasswordChangeRequired($ope);
return view('auth.password-change', [
'isRequired' => $isRequired,
]);
}
/**
* パスワード変更成功画面を表示
*
* GET /password/change/success
*
* @return \Illuminate\View\View
*/
public function showSuccessPage()
{
return view('auth.password-change-success');
}
/**
* パスワード変更処理
*
* POST /password/change
*
* バリデーション:
* - 当前パスワード必填、8-64文字、ハッシュ値一致確認
* - 新パスワード必填、8-64文字、英数字+記号のみ、当前と異なる
* - 新パスワード確認:必填、新パスワードと一致
*
* @param \App\Http\Requests\ChangePasswordRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function updatePassword(ChangePasswordRequest $request)
{
// 現在のユーザーを取得
$ope = Auth::user();
// ステップ1当前パスワードの認証ハッシュ値の確認
if (!Hash::check($request->current_password, $ope->ope_pass)) {
// バリデーションエラーとして当前パスワード が正しくないことを返す
throw ValidationException::withMessages([
'current_password' => '当前パスワードが正しくありません。',
]);
}
// ステップ2新パスワードが当前パスワードと同一でないか確認
// FormRequest側でも not_in ルールで確認しているが、ハッシュ値での二重チェック
if (Hash::check($request->password, $ope->ope_pass)) {
throw ValidationException::withMessages([
'password' => '新パスワードは当前パスワードと異なる必要があります。',
]);
}
// ステップ3データベース更新
// パスワードをハッシュ化して更新
$ope->ope_pass = Hash::make($request->password);
// パスワード変更時刻を現在時刻に更新
$ope->ope_pass_changed_at = Carbon::now();
// updated_at も自動更新される
$ope->save();
// イベント発火:パスワード変更イベント
event(new PasswordReset($ope));
// 成功画面へリダイレクト
return redirect()->route('password.change.success');
}
/**
* パスワード変更が必須かどうかを判定
*
* 初回ログイン時ope_pass_changed_at NULL)または
* 最後変更から3ヶ月以上経過している場合、TRUE を返す
*
* @param \App\Models\Ope $ope
* @return bool
*/
private function isPasswordChangeRequired($ope): bool
{
// パスワード変更日時が未設定(初回ログイン等)
if (is_null($ope->ope_pass_changed_at)) {
return true;
}
// パスワード変更から経過日数を計算
$changedAt = Carbon::parse($ope->ope_pass_changed_at);
$now = Carbon::now();
// 3ヶ月以上経過している場合
if ($now->diffInMonths($changedAt) >= 3) {
return true;
}
return false;
}
}

View File

@ -14,6 +14,33 @@ class ResetPasswordController extends Controller
{ {
$token = $request->query('token'); $token = $request->query('token');
$email = $request->query('email'); $email = $request->query('email');
// トークンのハッシュ化
$tokenHash = hash('sha256', $token);
// トークン・メール・24時間以内の有効性をチェック
$record = DB::table('password_reset_tokens')
->where('ope_mail', $email)
->where('token', $tokenHash)
->first();
if (!$record) {
return redirect()->route('forgot_password')
->withErrors(['email' => 'URLの有効期限24時間が切れました。再度お手続きを行ってください。']);
}
// 24時間チェック
$createdAt = \Carbon\Carbon::parse($record->created_at);
if ($createdAt->addHours(24)->isPast()) {
// 期限切れトークンを削除
DB::table('password_reset_tokens')
->where('ope_mail', $email)
->delete();
return redirect()->route('forgot_password')
->withErrors(['email' => 'URLの有効期限24時間が切れました。再度お手続きを行ってください。']);
}
return view('auth.reset-password', compact('token', 'email')); return view('auth.reset-password', compact('token', 'email'));
} }
@ -25,14 +52,28 @@ class ResetPasswordController extends Controller
'password' => 'required|confirmed|min:8', 'password' => 'required|confirmed|min:8',
]); ]);
// トークンチェック // トークンのハッシュ化
$tokenHash = hash('sha256', $request->token);
// トークン・メール・24時間以内の有効性をチェック
$record = DB::table('password_reset_tokens') $record = DB::table('password_reset_tokens')
->where('ope_mail', $request->email) ->where('ope_mail', $request->email)
->where('token', $request->token) ->where('token', $tokenHash)
->first(); ->first();
if (!$record) { if (!$record) {
return back()->withErrors(['email' => '無効なトークンまたはメールアドレスです。']); return back()->withErrors(['email' => 'URLの有効期限24時間が切れました。再度お手続きを行ってください。']);
}
// 24時間チェック
$createdAt = \Carbon\Carbon::parse($record->created_at);
if ($createdAt->addHours(24)->isPast()) {
// 期限切れトークンを削除
DB::table('password_reset_tokens')
->where('ope_mail', $request->email)
->delete();
return back()->withErrors(['email' => 'URLの有効期限24時間が切れました。再度お手続きを行ってください。']);
} }
// パスワード更新 // パスワード更新
@ -42,11 +83,14 @@ class ResetPasswordController extends Controller
} }
$user->password = Hash::make($request->password); $user->password = Hash::make($request->password);
$user->updated_at = now(); $user->updated_at = now();
// パスワード再設定時もope_pass_changed_atを更新
$user->ope_pass_changed_at = now();
$user->save(); $user->save();
// トークン削除 // トークン削除
DB::table('password_reset_tokens')->where('ope_mail', $request->email)->delete(); DB::table('password_reset_tokens')->where('ope_mail', $request->email)->delete();
return redirect()->route('login')->with('status', 'パスワードを再設定しました。'); // パスワード再設定成功画面へリダイレクト
return redirect()->route('password.change.success');
} }
} }

View File

@ -3,6 +3,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\City;
use App\Services\MenuAccessService;
class HomeController extends Controller class HomeController extends Controller
{ {
@ -22,10 +24,15 @@ class HomeController extends Controller
* アプリケーションのダッシュボードを表示 * アプリケーションのダッシュボードを表示
* 認証後のホーム画面 * 認証後のホーム画面
* *
* @param MenuAccessService $menuAccessService メニューアクセス制御サービス
* @return \Illuminate\Http\Response * @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,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckCityAccess
{
/**
* 自治体へのアクセス権限を確認するミドルウェア
*
* 将来的に以下の権限判定を追加予定:
* - ユーザーが指定自治体にアクセス権があるか確認
* - 権限がない場合は 403 Forbidden を返す
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @return \Symfony\Component\HttpFoundation\Response
*/
public function handle(Request $request, Closure $next): Response
{
// 現在の処理:権限判定なしで通す
// TODO: 将来的に以下の権限判定ロジックを追加
// $city_id = $request->route('city_id');
// $user = auth()->user();
// if (!$user->canAccessCity($city_id)) {
// return abort(403, '指定された自治体へのアクセス権がありません。');
// }
return $next($request);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
use Symfony\Component\HttpFoundation\Response;
/**
* 定期パスワード変更チェックミドルウェア
*
* ログインしているオペレータのパスワード最後変更時刻をチェック
* 3ヶ月以上経過している場合、パスワード変更画面へ強制リダイレクト
*/
class CheckPasswordChangeRequired
{
/**
* リクエストを処理
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @return \Symfony\Component\HttpFoundation\Response
*/
public function handle(Request $request, Closure $next): Response
{
// ログインしていない場合はスキップ
if (!Auth::check()) {
return $next($request);
}
// 既にパスワード変更ページにいる場合はスキップ
if ($request->routeIs('password.change.show', 'password.change.update')) {
return $next($request);
}
// 現在のユーザーを取得
$ope = Auth::user();
// パスワード変更が必須か判定
if ($this->isPasswordChangeRequired($ope)) {
return redirect()->route('password.change.show');
}
return $next($request);
}
/**
* パスワード変更が必須かどうかを判定
*
* 初回ログイン時ope_pass_changed_at NULL)または
* 最後変更から3ヶ月以上経過している場合、TRUE を返す
*
* @param \App\Models\Ope $ope
* @return bool
*/
private function isPasswordChangeRequired($ope): bool
{
// パスワード変更日時が未設定(初回ログイン等)
if (is_null($ope->ope_pass_changed_at)) {
\Log::info('Password change required: ope_pass_changed_at is null', [
'ope_id' => $ope->ope_id,
]);
return true;
}
// パスワード変更から経過日数を計算
// ope_pass_changed_at は複数のフォーマットに対応
try {
$changedAt = Carbon::parse($ope->ope_pass_changed_at);
} catch (\Exception $e) {
// パース失敗時は強制変更
\Log::warning('Failed to parse ope_pass_changed_at', [
'ope_id' => $ope->ope_id,
'value' => $ope->ope_pass_changed_at,
'error' => $e->getMessage(),
]);
return true;
}
$now = Carbon::now();
// 3ヶ月以上経過しているか判定
// diffInMonths は絶対値ではなく符号付きなので、abs() で絶対値を取得
$monthsDiff = abs($now->diffInMonths($changedAt));
\Log::info('Password change check', [
'ope_id' => $ope->ope_id,
'changed_at' => $changedAt->format('Y-m-d H:i:s'),
'now' => $now->format('Y-m-d H:i:s'),
'months_diff' => $monthsDiff,
'is_required' => $monthsDiff >= 3,
]);
if ($monthsDiff >= 3) {
return true;
}
return false;
}
}

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

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class ChangePasswordRequest extends FormRequest
{
/**
* リクエストを処理することを認可するか判定
*
* @return bool
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* 入力値の検証ルール
*
* クライアント側必填3項目、長度 8-64、新密码仅半角英数字+记号、新/确认一致
* サーバー側当前密码认证hash check、新密码不能等于旧密码CustomRulesで実装
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
// 当前パスワード必填、8-64文字
'current_password' => [
'required',
'string',
'min:8',
'max:64',
],
// 新パスワード必填、8-64文字、英数字+記号のみ
'password' => [
'required',
'string',
'min:8',
'max:64',
'regex:/^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};:\'",.<>?\/\\|`~]+$/', // 半角英数字+記号のみ
],
// 新パスワード確認:必填、新パスワードと一致
'password_confirmation' => [
'required',
'string',
'same:password',
],
// hidden フィールド(フォーム側で出力)
'updated_at' => 'nullable|date_format:Y-m-d H:i:s',
'ope_pass_changed_at' => 'nullable|date_format:Y-m-d H:i:s',
];
}
/**
* 属性の表示名
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'current_password' => '当前パスワード',
'password' => '新パスワード',
'password_confirmation' => '新パスワード確認',
];
}
/**
* 検証エラーメッセージのカスタマイズ
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'current_password.required' => '当前パスワードを入力してください。',
'current_password.min' => '当前パスワードは8文字以上です。',
'current_password.max' => '当前パスワードは64文字以下です。',
'password.required' => '新パスワードを入力してください。',
'password.min' => '新パスワードは8文字以上です。',
'password.max' => '新パスワードは64文字以下です。',
'password.regex' => '新パスワードは英数字と記号のみ使用できます。',
'password_confirmation.required' => '新パスワード確認を入力してください。',
'password_confirmation.same' => '新パスワードと新パスワード確認は一致する必要があります。',
];
}
}

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', 'print_layout',
'city_user', 'city_user',
'city_remarks', 'city_remarks',
'management_id',
'created_at', 'created_at',
'updated_at', 'updated_at',
]; ];
@ -32,5 +33,34 @@ class City extends Model
->toArray(); ->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 = [ const OPE_TYPE = [
'管理者', '管理者',
'マネージャー', '職員',
'オペレーター',
'エリアマネージャー',
]; ];
protected $table = 'ope'; // データベーステーブル名(旧システムと同じ) protected $table = 'ope'; // データベーステーブル名(旧システムと同じ)
@ -29,7 +27,7 @@ class Ope extends Authenticatable
/** /**
* 一括代入可能な属性 * 一括代入可能な属性
* Laravel 5.7から引き継いだフィールド構成 * Laravel 5.7から引き継いだフィールド構成 + OTP認証フィールド
*/ */
protected $fillable = [ protected $fillable = [
'//TODO オペレータID not found in database specs', '//TODO オペレータID not found in database specs',
@ -58,6 +56,13 @@ class Ope extends Authenticatable
'ope_auth4', 'ope_auth4',
'ope_quit_flag', 'ope_quit_flag',
'ope_quitday', 'ope_quitday',
// OTP認証関連フィールドメール二段階認証
'email_otp_code_hash',
'email_otp_expires_at',
'email_otp_last_sent_at',
'email_otp_verified_at',
// パスワード変更関連フィールド(定期パスワード変更)
'ope_pass_changed_at',
]; ];
/** /**
@ -178,4 +183,12 @@ class Ope extends Authenticatable
{ {
return self::pluck('ope_name', 'ope_id'); 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();
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ParkingRegulation extends Model
{
// テーブル名
protected $table = 'parking_regulations';
// プライマリキー
protected $primaryKey = 'parking_regulations_seq';
// 自動インクリメントが有効
public $incrementing = true;
// タイムスタンプ自動管理
public $timestamps = true;
// マスアサイン可能カラム
protected $fillable = [
'park_id',
'psection_id',
'ptype_id',
'regulations_text',
];
// 必要ならリレーションを追加Park / Psection / Ptype がある場合)
}

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;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
class ReductionMaster extends Model
{
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
protected $table = 'reduction_confirm';
protected $primaryKey = null; // 複合キーを使用
public $incrementing = false;
protected $fillable = [
'park_id',
'user_categoryid',
'reduction_check_type',
'operator_id',
'created_at',
'updated_at',
];
/**
* 複合キー (park_id, user_categoryid) で既存レコードを検索または作成
*/
public static function findOrCreateByKeys($parkId, $userCategoryId)
{
return self::where('park_id', $parkId)
->where('user_categoryid', $userCategoryId)
->first();
}
/**
* レコード保存時に operator_id を自動設定
*/
public static function boot()
{
parent::boot();
self::saving(function (ReductionMaster $model) {
if (!isset($model->operator_id) || $model->operator_id === null) {
$model->operator_id = Auth::user()->ope_id ?? null;
}
});
}
}

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\ShjTenService;
use App\Services\ShjSixService; use App\Services\ShjSixService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Services\MenuAccessService;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -109,10 +110,15 @@ class AppServiceProvider extends ServiceProvider
->limit(5) ->limit(5)
->get(); ->get();
$menuAccessService = app(MenuAccessService::class);
$isSorin = $menuAccessService->isSorin();
$visibleCities = $menuAccessService->visibleCities();
$view->with(compact( $view->with(compact(
'taskCount','taskLatest', 'taskCount','taskLatest',
'hardCount','hardLatest', '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,16 @@ return Application::configure(basePath: dirname(__DIR__))
'/shj4a', // SHJ-4A本番用エンドポイント '/shj4a', // SHJ-4A本番用エンドポイント
'/webhook/wellnet', // 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,
'check.password.change.required' => \App\Http\Middleware\CheckPasswordChangeRequired::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->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'),
],
];

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,283 @@
@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">
@php
// park_number テーブルから指定駐輪場群の容量を集計
$parkIds = $parks->pluck('park_id')->toArray();
$parkNumberCapacity = 0;
if (!empty($parkIds)) {
$parkNumberData = \Illuminate\Support\Facades\DB::table('park_number')
->whereIn('park_id', $parkIds)
->selectRaw('COALESCE(SUM(park_standard), 0) + COALESCE(SUM(park_number), 0) + COALESCE(SUM(park_limit), 0) as total')
->first();
$parkNumberCapacity = $parkNumberData->total ?? 0;
}
@endphp
<h3>{{ number_format($parkNumberCapacity) }}</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 = $parkNumberCapacity;
$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(max(0, $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
// 待機超過分 / 総容量で予約待ち率を計算超過なしは0%
// 分母チェック付き:総容量 > 0 の場合のみ計算
$totalVacant = max(0, $totalCapacity - $stats['contracts_count']);
$waitingRate = 0;
if ($totalCapacity > 0 && $stats['waiting_count'] > 0 && $stats['waiting_count'] > $totalVacant) {
$waitingRate = (int) floor((($stats['waiting_count'] - $totalVacant) / $totalCapacity) * 100);
}
@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> </div>
@endfor @endfor
{{-- キュー1〜13 --}} {{-- キュー1〜13 --}}
{{--
<!-- 管理者権限付与 --> <!-- 管理者権限付与 -->
<div class="form-group col-3"> <div class="form-group col-3">
<label>{{__('validation.attributes.ope_auth1')}}</label> <label>{{__('validation.attributes.ope_auth1')}}</label>
@ -245,7 +245,7 @@
</div> </div>
</div> </div>
</div> </div>
--}}
<!-- 退職フラグ --> <!-- 退職フラグ -->
<div class="form-group col-3"> <div class="form-group col-3">
<label>{{__('validation.attributes.ope_quit_flag')}}</label> <label>{{__('validation.attributes.ope_quit_flag')}}</label>
@ -278,6 +278,51 @@
</div> </div>
<!-- /.form group - 退職日 --> <!-- /.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> </div>
{{-- 下部ボタン --}} {{-- 下部ボタン --}}
@ -307,3 +352,67 @@
</div> </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

@ -0,0 +1,88 @@
@php
// $psections: 車種区分コレクション
// $ptypes: 駐輪分類コレクション
// $parkId: 駐輪場ID
// $record: 編集用レコードnull = 新規)
$selectedPsection = old('psection_id', $record->psection_id ?? null);
$selectedPtype = old('ptype_id', $record->ptype_id ?? null);
$regulationsText = old('regulations_text', $record->regulations_text ?? '');
@endphp
<input type="hidden" name="park_id" value="{{ $parkId }}">
<input type="hidden" name="park_id" value="{{ $parkId }}">
{{-- 駐輪場名(編集不可) --}}
<div class="mb-3 row">
<label class="col-md-2 col-form-label font-weight-bold">駐輪場名</label>
<div class="col-md-10">
<input type="text"
class="form-control"
value="{{ $park->park_name }}"
readonly>
</div>
</div>
{{-- 車種区分 --}}
<div class="mb-3 row">
<label class="col-md-2 col-form-label font-weight-bold">車種区分 <span class="text-danger">*</span></label>
<div class="col-md-10">
<select name="psection_id" class="form-control" required>
<option value="">選択してください</option>
@foreach($psections as $ps)
<option value="{{ $ps->psection_id }}"
@selected($selectedPsection == $ps->psection_id)>
{{ $ps->psection_subject }}
</option>
@endforeach
</select>
</div>
</div>
{{-- 駐輪分類 --}}
<div class="mb-3 row">
<label class="col-md-2 col-form-label font-weight-bold">駐輪分類 <span class="text-danger">*</span></label>
<div class="col-md-10">
<select name="ptype_id" class="form-control" required>
<option value="">選択してください</option>
@foreach($ptypes as $pt)
<option value="{{ $pt->ptype_id }}"
@selected($selectedPtype == $pt->ptype_id)>
{{ $pt->ptype_subject }}
</option>
@endforeach
</select>
</div>
</div>
{{-- 駐輪規定 --}}
<div class="mb-3 row">
<label class="col-md-2 col-form-label font-weight-bold">
駐輪規定 <span class="text-danger">*</span>
</label>
<div class="col-md-10">
<textarea name="regulations_text"
class="form-control js-summernote"
required>{!! $regulationsText !!}</textarea>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
$('.js-summernote').summernote({
height: 420,
minHeight: 420,
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'italic', 'clear']],
['fontname', ['fontname']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture']],
['view', ['fullscreen', 'codeview', 'help']]
]
});
});
</script>

View File

@ -0,0 +1,43 @@
@extends('layouts.app')
@section('title', '駐輪規定 新規登録')
@section('content')
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-lg-6">
<h1 class="m-0 text-dark">新規</h1>
</div>
<div class="col-lg-6">
<ol class="breadcrumb float-sm-right text-sm">
<li class="breadcrumb-item"><a href="{{ route('home') }}">ホーム</a></li>
<li class="breadcrumb-item"><a href="{{ route('parking_regulations_list', ['park_id' => $parkId]) }}">駐輪規定マスタ</a></li>
<li class="breadcrumb-item active">新規登録</li>
</ol>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
@if ($errors->any())
<div class="alert alert-danger">@foreach($errors->all() as $e) <div>{{ $e }}</div> @endforeach</div>
@endif
<div class="card">
<div class="card-body">
<form method="POST" action="{{ route('parking_regulations_add') }}">
@csrf
@include('admin.parking_regulations._form', ['psections' => $psections, 'ptypes' => $ptypes, 'parkId' => $parkId, 'record' => null])
<div class="text-end">
<button type="button" class="btn btn-primary register">登録</button>
<a href="{{ route('parking_regulations_list', ['park_id' => $parkId]) }}" class="btn btn-default">戻る</a>
</div>
</form>
</div>
</div>
</div>
</section>
@endsection

View File

@ -0,0 +1,53 @@
@extends('layouts.app')
@section('title', '駐輪規定 編集')
@section('content')
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-lg-6">
<h1 class="m-0 text-dark">編集</h1>
</div>
<div class="col-lg-6">
<ol class="breadcrumb float-sm-right text-sm">
<li class="breadcrumb-item"><a href="{{ route('home') }}">ホーム</a></li>
<li class="breadcrumb-item"><a href="{{ route('parking_regulations_list', ['park_id' => $park->park_id]) }}">駐輪規定マスタ</a></li>
<li class="breadcrumb-item active">編集</li>
</ol>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
@if ($errors->any())
<div class="alert alert-danger">@foreach($errors->all() as $e) <div>{{ $e }}</div> @endforeach</div>
@endif
<div class="card">
<div class="card-body">
<!-- 登録フォーム -->
<form id="form_edit" method="POST" action="{{ route('parking_regulations_update', ['seq' => $record->parking_regulations_seq]) }}">
@csrf
@include('admin.parking_regulations._form', ['psections' => $psections, 'ptypes' => $ptypes, 'parkId' => $park->park_id, 'record' => $record])
<div class="text-end">
<button type="button" id="register_edit" class="btn btn-success">登録</button>
</form>
<!-- 削除フォーム -->
<form id="form_delete" method="POST" action="{{ route('parking_regulations_delete') }}" style="display:inline">
@csrf
<input type="hidden" name="parking_regulations_seq" value="{{ $record->parking_regulations_seq }}">
<button type="button" id="delete_edit" class="btn btn-danger">削除</button>
</form>
<a href="{{ route('parking_regulations_list', ['park_id' => $park->park_id]) }}" class="btn btn-secondary">戻る</a>
</div>
</div>
</div>
</div>
</section>
@endsection

View File

@ -0,0 +1,109 @@
@extends('layouts.app')
@section('title', '駐輪規定マスタ')
@section('content')
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-lg-6">
<h1 class="m-0 text-dark">駐輪規定マスタ</h1>
</div>
<div class="col-lg-6">
<ol class="breadcrumb float-sm-right text-sm">
<li class="breadcrumb-item"><a href="{{ route('home') }}">ホーム</a></li>
<li class="breadcrumb-item active">駐輪規定マスタ</li>
</ol>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
@if ($errors->any())
<div class="alert alert-danger">
@foreach ($errors->all() as $e)
<div>{{ $e }}</div>
@endforeach
</div>
@endif
@if (session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
<div class="card">
<div class="card-header">
<h3 class="card-title">駐輪場情報</h3>
</div>
<div class="card-body">
<div class="d-flex align-items-center">
<div style="width:180px;" class="font-weight-bold">
駐輪場名
</div>
<div class="ml-3">
{{ $park->park_name ?? '' }}
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<a href="{{ route('parking_regulations_add', ['park_id' => $parkId]) }}" class="btn btn-primary mr-2">新規</a>
{{-- 件数 + ページネーション --}}
<div class="d-flex justify-content-between align-items-center mb-3 px-3 pt-3">
<div class="text-right ml-auto">
<div>
{{ $regulations->total() }} 件中
{{ $regulations->firstItem() }}{{ $regulations->lastItem() }} 件を表示
</div>
<div>
{{ $regulations->appends(request()->query())->links('pagination') }}
</div>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead class="thead-light">
<tr>
<th style="width:100px">操作</th>
<th style="width:15%">車種区分</th>
<th style="width:15%">駐輪分類</th>
<th>駐輪規定</th>
</tr>
</thead>
<tbody style="background:#fff;">
@forelse($regulations as $r)
<tr>
<td class="text-center align-middle">
<a href="{{ route('parking_regulations_edit', ['seq' => $r->parking_regulations_seq]) }}" class="btn btn-sm btn-outline-primary">編集</a>
<form method="POST" action="{{ route('parking_regulations_delete') }}" style="display:inline">
@csrf
<input type="hidden" name="parking_regulations_seq" value="{{ $r->parking_regulations_seq }}">
</form>
</td>
<td>{{ $r->psection_subject ?? $r->psection_id }}</td>
<td>{{ $r->ptype_subject ?? $r->ptype_id }}</td>
<td class="regulations-html">{!! $r->regulations_text !!}</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-muted">規定がありません。</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-2">
{{ $regulations->appends(request()->query())->links('pagination') }}
</div>
</div>
</section>
@endsection

View File

@ -2,7 +2,7 @@
@section('title', '編集') @section('title', '編集')
@section('content') @section('content')
<!-- Content Header --> {{-- パンくず --}}
<div class="content-header"> <div class="content-header">
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-2"> <div class="row mb-2">
@ -11,12 +11,8 @@
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<ol class="breadcrumb float-sm-right text-sm"> <ol class="breadcrumb float-sm-right text-sm">
<li class="breadcrumb-item"> <li class="breadcrumb-item"><a href="{{ route('home') }}">ホーム</a></li>
<a href="{{ route('home') }}">ホーム</a> <li class="breadcrumb-item"><a href="{{ route('parks') }}">駐輪場マスタ</a></li>
</li>
<li class="breadcrumb-item">
<a href="{{ route('parks.index') }}">駐輪場マスタ</a>
</li>
<li class="breadcrumb-item active">編集</li> <li class="breadcrumb-item active">編集</li>
</ol> </ol>
</div> </div>
@ -24,37 +20,58 @@
</div> </div>
</div> </div>
<!-- Main content --> {{-- 編集フォーム --}}
<section class="content"> <div class="card shadow">
<div class="container-fluid"> <form id="park-edit-form" method="POST" action="{{ route('parks.update', $park->park_id) }}" enctype="multipart/form-data">
<div class="row">
<div class="col-lg-12">
<div class="card">
{{-- 編集フォーム --}}
<form id="form_edit"
method="POST"
action="{{ route('parks.update', ['id' => $record->park_id]) }}?back={{ urlencode(request()->get('back', request()->fullUrl())) }}"
enctype="multipart/form-data">
@csrf @csrf
@method('PUT')
@include('admin.parks._form', [ <div class="card-header">
'record' => $record, {{-- ボタンエリア(上部) --}}
'isEdit' => true <div class="mt-2">
]) <button type="button" class="btn btn-default mt-2 btn-submit">登録</button>
<a href="javascript:void(0)" class="btn btn-default mt-2">減免確認編集</a>
<a href="javascript:void(0)" class="btn btn-default mt-2">駐輪状況編集</a>
<button type="button" class="btn btn-default mt-2">削除</button>
</div>
</div>
<div class="card-body">
{{-- 入力フォーム --}}
@include('admin.parks._form')
{{-- フッター(下部ボタン) --}}
<div class="form-footer mt-4 text-end">
<button type="button" class="btn btn-default btn-submit">登録</button>
<a href="{{ route('parks') }}" class="btn btn-default">戻る</a>
</div>
</div>
</form> </form>
{{-- 削除フォームhidden --}}
<form id="form_delete" method="POST" action="{{ route('parks.destroy') }}" class="d-none">
@csrf
<input type="hidden" name="pk[]" value="{{ $record->park_id }}">
</form>
</div>
</div>
</div> </div>
</div> <!-- jQuery -->
</section> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- jQuery Confirm -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jquery-confirm@3.3.4/css/jquery-confirm.min.css">
<script src="https://cdn.jsdelivr.net/npm/jquery-confirm@3.3.4/js/jquery-confirm.min.js"></script>
<script>
$(function () {
$('.btn-submit').on('click', function () {
$.confirm({
title: '登録確認',
content: 'この内容で登録してよろしいですか?',
buttons: {
はい: {
btnClass: 'btn-primary',
action: function () {
$('#park-edit-form').submit();
}
},
いいえ: function () {}
}
});
});
});
</script>
@endsection @endsection

View File

@ -0,0 +1,68 @@
{{-- 減免確認マスタテーブル行(各利用者分類) --}}
@php
// reduction_confirm テーブルから取得したレコード
$userCategoryId = $row->user_categoryid;
$checkType = $row->reduction_confirm_type;
// use numeric index passed from parent to avoid name collisions when duplicate user_categoryid exist
$idx = $index ?? ($userCategoryId);
$oldCheckType = old("reduction_confirm_type.$idx", $checkType);
// 学生分類かどうかを判定
$isStudent = ($row->usertype_subject1 ?? '') === '学生';
$rowClass = $isStudent ? 'table-secondary' : '';
$isDisabled = $isStudent;
@endphp
<tr class="{{ $rowClass }}">
<td class="text-center">{{ $userCategoryId }}</td>
<td>{{ $row->usertype_subject1 ?? '-' }}</td>
<td>{{ $row->usertype_subject2 ?? '-' }}</td>
<td>{{ $row->usertype_subject3 ?? '-' }}</td>
<td>
{{-- hidden mapping to know which user_categoryid this input row targets --}}
<input type="hidden" name="row_user_categoryid[{{ $idx }}]" value="{{ $userCategoryId }}">
<div class="d-flex justify-content-center gap-3">
{{-- 確認しない0 --}}
<div class="custom-control custom-radio">
<input type="radio"
id="check_type_{{ $idx }}_0"
name="reduction_confirm_type[{{ $idx }}]"
value="0"
class="custom-control-input"
@checked($oldCheckType == 0)
@disabled($isDisabled)>
<label class="custom-control-label" for="check_type_{{ $idx }}_0">
確認しない
</label>
</div>
{{-- 年1回1 --}}
<div class="custom-control custom-radio">
<input type="radio"
id="check_type_{{ $idx }}_1"
name="reduction_confirm_type[{{ $idx }}]"
value="1"
class="custom-control-input"
@checked($oldCheckType == 1)
@disabled($isDisabled)>
<label class="custom-control-label" for="check_type_{{ $idx }}_1">
年1回
</label>
</div>
{{-- 毎更新時2 --}}
<div class="custom-control custom-radio">
<input type="radio"
id="check_type_{{ $idx }}_2"
name="reduction_confirm_type[{{ $idx }}]"
value="2"
class="custom-control-input"
@checked($oldCheckType == 2)
@disabled($isDisabled)>
<label class="custom-control-label" for="check_type_{{ $idx }}_2">
毎更新時
</label>
</div>
</div>
</td>
</tr>

View File

@ -0,0 +1,151 @@
@extends('layouts.app')
@section('title', '減免確認マスタ')
@section('content')
<!-- Content Header -->
<div class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-lg-6">
<h1 class="m-0 text-dark">減免確認マスタ</h1>
</div>
<div class="col-lg-6">
<ol class="breadcrumb float-sm-right text-sm">
<li class="breadcrumb-item"><a href="{{ route('home') }}">ホーム</a></li>
<li class="breadcrumb-item active">減免確認マスタ</li>
</ol>
</div>
</div>
</div>
</div>
<section class="content">
<div class="container-fluid">
{{-- エラーメッセージ表示 --}}
@if ($errors->any())
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="alert-heading">エラーが発生しました</h4>
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
{{-- 成功メッセージ表示 --}}
@if (session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
{{ session('success') }}
</div>
@endif
<!-- 駐輪場情報カード -->
<div class="col-lg-12 px-0">
<div class="card">
<div class="card-header">
<h3 class="card-title">駐輪場情報</h3>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-2">
<label class="font-weight-bold mb-0">駐輪場名</label>
</div>
<div class="col-md-10">
<div class="park-name-display">
{{ $park->park_name ?? '' }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 減免確認マスタ テーブル -->
<div class="col-lg-12 px-0 ">
<form action="{{ route('reduction_confirm_store') }}" method="POST" id="reduction-form">
<div class="d-flex justify-content-between align-items-center mb-2">
{{-- 左側:登録ボタン --}}
<button type="submit" class="btn btn-primary btn-submit">登録</button>
{{-- 右側:件数 + ページネーション --}}
<div class="text-right">
<div>
{{ $reductionData->total() }} 件中
{{ $reductionData->firstItem() }}{{ $reductionData->lastItem() }} 件を表示
</div>
<div>
{{ $reductionData->appends(keepUserListQuery())->links('pagination') }}
</div>
</div>
</div>
</div>
@csrf
<input type="hidden" name="park_id" value="{{ $parkId }}">
<div class="table-responsive">
<table class="table table-bordered">
<thead class="thead-light">
<tr>
<th style="width: 5%; text-align: center;">利用者分類</th>
<th style="width: 10%;">分類名1</th>
<th style="width: 10%;">分類名2</th>
<th style="width: 10%;">分類名3</th>
<th style="width: 20%; text-align: center;">減免確認種別</th>
</tr>
</thead>
<tbody>
@forelse ($reductionData as $row)
@include('admin.reduction_confirm._form', ['row' => $row, 'index' => $loop->index])
@empty
<tr>
<td colspan="5" class="text-center text-muted">
利用者分類がありません。
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</form>
</div>
</section>
<style>
.gap-3 {
gap: 1rem !important;
}
.custom-control {
display: flex;
align-items: center;
}
.custom-control-label {
margin-bottom: 0;
margin-left: 0.25rem;
}
.park-name-display {
background-color: #e9ecef;
border-radius: 6px;
padding: 10px 14px;
font-size: 16px;
color: #6c757d;
width: fit-content;
min-width: 320px;
}
.sample03-wrapper {
background-color: white;
}
</style>
@endsection

View File

@ -32,6 +32,26 @@
</select> </select>
</div> </div>
</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"> <div class="row mb-3 align-items-center">
<label class="col-sm-2 col-form-label fw-bold py-0">発行日</label> <label class="col-sm-2 col-form-label fw-bold py-0">発行日</label>
<div class="col-sm-10"> <div class="col-sm-10">
@ -76,6 +96,7 @@
<div class="col-sm-12 text-end"> <div class="col-sm-12 text-end">
<button type="submit" class="btn btn-default me-2">絞り込み</button> <button type="submit" class="btn btn-default me-2">絞り込み</button>
<a href="{{ route('seals') }}" class="btn btn-default">解除</a> <a href="{{ route('seals') }}" class="btn btn-default">解除</a>
<button type="submit" class="btn btn-default me-2">CSV出力</button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -30,14 +30,14 @@
</div> </div>
</div> </div>
{{-- 会員ID --}} {{-- 会員ID
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">会員ID <span class="text-danger">*</span></label> <label class="col-md-2 col-form-label">会員ID <span class="text-danger">*</span></label>
<div class="col-md-10"> <div class="col-md-10">
<input type="text" name="member_id" class="form-control form-control-sm" <input type="text" name="member_id" class="form-control form-control-sm"
value="{{ $value('member_id') }}" placeholder="会員ID"> value="{{ $value('member_id') }}" placeholder="会員ID">
</div> </div>
</div> </div>--}}
{{-- パスワード --}} {{-- パスワード --}}
<div class="form-group row"> <div class="form-group row">
@ -164,16 +164,30 @@
</div> </div>
</div> </div>
<div class="form-group row"> {{--<div class="form-group row">
<label class="col-md-2 col-form-label">居場所通知用QRID</label> <label class="col-md-2 col-form-label">居場所通知用QRID</label>
<div class="col-md-10"> <div class="col-md-10">
<input type="text" name="user_place_qrid" value="{{ old('user_place_qrid') }}" class="form-control"> <input type="text" name="user_place_qrid" value="{{ old('user_place_qrid') }}" class="form-control">
</div> </div>
</div>--}}
{{-- 利用者分類 --}}
<div class="form-group row">
<label class="col-md-2 col-form-label">分類名1(一般、学生)</label>
<div class="col-md-10">
<input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control">
</div>
</div> </div>
{{-- 利用者属性 --}}
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">利用者分類ID</label> <label class="col-md-2 col-form-label">分類名2(区民、非区民)</label>
<div class="col-md-10">
<input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">分類名3(減免種別)</label>
<div class="col-md-10"> <div class="col-md-10">
<input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control"> <input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control">
</div> </div>
@ -197,6 +211,74 @@
</div> </div>
</div> </div>
{{-- 性別 --}}
<div class="form-group row">
<label class="col-md-2 col-form-label">性別</label>
<div class="col-md-10">
<div class="form-check form-check-inline">
<input class="form-check-input"
type="radio"
name="user_gender"
id="gender_male"
value="1"
{{ $value('user_gender') === '1' ? 'checked' : '' }}>
<label class="form-check-label" for="gender_male"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input"
type="radio"
name="user_gender"
id="gender_female"
value="2"
{{ $value('user_gender') === '2' ? 'checked' : '' }}>
<label class="form-check-label" for="gender_female"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input"
type="radio"
name="user_gender"
id="gender_unknown"
value="0"
{{ $value('user_gender') === '0' || $value('user_gender') === null ? 'checked' : '' }}>
<label class="form-check-label" for="gender_unknown">未入力</label>
</div>
</div>
</div>
{{-- 生年月日 --}}
<div class="form-group row">
<label class="col-md-2 col-form-label">生年月日</label>
<div class="col-md-10">
<input type="date" name="user_birthdate" class="form-control form-control-sm"
value="{{ $value('user_birthdate') }}">
</div>
</div>
{{-- 年齢 --}}
<div class="form-group row">
<label class="col-md-2 col-form-label">年齢</label>
<div class="col-md-10">
@php
$age = null;
if ($value('user_birthdate')) {
$age = \Carbon\Carbon::parse($value('user_birthdate'))
->age;
}
@endphp
<input type="text"
class="form-control form-control-sm"
value="{{ $age !== null ? $age : '' }}"
readonly>
</div>
</div>
{{-- 自宅電話番号 --}} {{-- 自宅電話番号 --}}
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">自宅電話番号</label> <label class="col-md-2 col-form-label">自宅電話番号</label>
@ -298,16 +380,20 @@
<label class="col-md-2 col-form-label">区民</label> <label class="col-md-2 col-form-label">区民</label>
<div class="col-md-10"> <div class="col-md-10">
<select name="ward_residents" class="form-control form-control-sm"> <select name="ward_residents" class="form-control form-control-sm">
<option value="1" {{ $value('ward_residents') == 1 ? 'selected' : '' }}> <option value="1"
区民 {{ old('ward_residents', $value('ward_residents')) == 1 ? 'selected' : '' }}>
居民
</option> </option>
<option value="0" {{ $value('ward_residents') == 0 ? 'selected' : '' }}>
非区民 <option value="0"
{{ old('ward_residents', $value('ward_residents')) == 0 ? 'selected' : '' }}>
非居民
</option> </option>
</select> </select>
</div> </div>
</div> </div>
{{-- 勤務先 --}} {{-- 勤務先 --}}
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">勤務先</label> <label class="col-md-2 col-form-label">勤務先</label>
@ -335,6 +421,15 @@
</div> </div>
</div> </div>
{{-- 本人確認書類 --}}
<div class="form-group row">
<label class="col-md-2 col-form-label">本人確認書類</label>
<div class="col-md-10">
<input type="text" name="user_idcard" class="form-control form-control-sm"
value="{{ $value('user_idcard') }}">
</div>
</div>
{{-- 本人確認チェック済5状態 --}} {{-- 本人確認チェック済5状態 --}}
@php @php
$options = [ $options = [
@ -503,6 +598,69 @@
</div> </div>
</div> </div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者氏名</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者フリガナ</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者生年月日</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者住所</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者電話番号</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者予備電話番号</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者メールアドレス</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">通知方法</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">個人情報同意フラグ</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="mt-3"> <div class="mt-3">
<button type="button" class="btn btn-success" id="register_edit">登録</button> <button type="button" class="btn btn-success" id="register_edit">登録</button>

View File

@ -45,13 +45,13 @@
<div class="card-body"> <div class="card-body">
{{-- 1列レイアウト(ラベル左 / 入力右) --}} {{-- 1列レイアウト(ラベル左 / 入力右) --}}
<div class="form-group row"> {{--<div class="form-group row">
<label class="col-md-2 col-form-label">会員ID <span class="text-danger">*</span></label> <label class="col-md-2 col-form-label">会員ID <span class="text-danger">*</span></label>
<div class="col-md-10"> <div class="col-md-10">
<input type="text" name="user_id" value="{{ old('user_id') }}" class="form-control" <input type="text" name="user_id" value="{{ old('user_id') }}" class="form-control"
placeholder="会員ID"> placeholder="会員ID">
</div> </div>
</div> </div>--}}
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">パスワード</label> <label class="col-md-2 col-form-label">パスワード</label>
@ -199,16 +199,30 @@
</div> </div>
</div> </div>
<div class="form-group row"> {{-- <div class="form-group row">
<label class="col-md-2 col-form-label">居場所通知用QRID</label> <label class="col-md-2 col-form-label">居場所通知用QRID</label>
<div class="col-md-10"> <div class="col-md-10">
<input type="text" name="user_place_qrid" value="{{ old('user_place_qrid') }}" class="form-control"> <input type="text" name="user_place_qrid" value="{{ old('user_place_qrid') }}" class="form-control">
</div> </div>
</div> </div>--}}
{{-- 利用者属性 --}} {{-- 利用者属性 --}}
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">利用者分類ID</label> <label class="col-md-2 col-form-label">分類名1(一般、学生)</label>
<div class="col-md-10">
<input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">分類名2(区民、非区民)</label>
<div class="col-md-10">
<input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">分類名3(減免種別)</label>
<div class="col-md-10"> <div class="col-md-10">
<input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control"> <input type="text" name="user_categoryid" value="{{ old('user_categoryid') }}" class="form-control">
</div> </div>
@ -349,18 +363,9 @@
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">区民</label> <label class="col-md-2 col-form-label">区民</label>
<div class="col-md-10"> <div class="col-md-10">
<select name="ward_residents" class="form-control"> <input type="text" name="ward_residents" value="{{ old('ward_residents') }}" class="form-control">
<option value="">-- 选择 --</option>
<option value="1" {{ old('ward_residents') == '1' ? 'selected' : '' }}>
区民
</option>
<option value="0" {{ old('ward_residents') == '0' ? 'selected' : '' }}>
非区民
</option>
</select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-md-2 col-form-label">勤務先</label> <label class="col-md-2 col-form-label">勤務先</label>
<div class="col-md-10"> <div class="col-md-10">
@ -532,6 +537,70 @@
<textarea name="user_remarks" rows="4" class="form-control">{{ old('user_remarks') }}</textarea> <textarea name="user_remarks" rows="4" class="form-control">{{ old('user_remarks') }}</textarea>
</div> </div>
</div> </div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者氏名</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者フリガナ</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者生年月日</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者住所</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者電話番号</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者予備電話番号</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">親権者メールアドレス</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">通知方法</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<div class="form-group row">
<label class="col-md-2 col-form-label">個人情報同意フラグ</label>
<div class="col-md-10">
<input type="text" name="user_chk_opeid" value="{{ old('user_chk_opeid') }}" class="form-control">
</div>
</div>
<button type="submit" class="btn btn-success register">登録</button> <button type="submit" class="btn btn-success register">登録</button>
<a href="{{ route('users', keepUserListQuery()) }}"class="btn btn-secondary">戻る</a> <a href="{{ route('users', keepUserListQuery()) }}"class="btn btn-secondary">戻る</a>
</div> </div>

View File

@ -303,7 +303,16 @@
<th style="width:150px;">本人確認チェック済</th> <th style="width:150px;">本人確認チェック済</th>
<th style="width:160px;">本人確認日時</th> <th style="width:160px;">本人確認日時</th>
<th style="width:110px;">退会フラグ</th> <th style="width:110px;">退会フラグ</th>
<th style="width:140px;">退会日</th> <th style="width:110px;">退会日</th>
<th style="width:140px;">親権者氏名</th>
<th style="width:140px;">親権者フリガナ</th>
<th style="width:140px;">親権者生年月日</th>
<th style="width:140px;">親権者住所</th>
<th style="width:140px;">親権者電話番号</th>
<th style="width:140px;">親権者予備電話番号</th>
<th style="width:140px;">親権者メールアドレス</th>
<th style="width:140px;">通知方法</th>
<th style="width:140px;">個人情報同意フラグ</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -355,6 +364,15 @@
<td>{{ $item->user_quit_flag ? 'はい' : 'いいえ' }}</td> <td>{{ $item->user_quit_flag ? 'はい' : 'いいえ' }}</td>
<td>{{ $item->user_quitday ? \Illuminate\Support\Str::limit($item->user_quitday, 10, '') : '' }} <td>{{ $item->user_quitday ? \Illuminate\Support\Str::limit($item->user_quitday, 10, '') : '' }}
</td> </td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
<td>{{ '' }}</td>
</tr> </tr>
@empty @empty
<tr> <tr>

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,159 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>パスワード変更完了 - So-Manager</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css">
<style>
body {
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.success-container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
max-width: 600px;
width: 100%;
padding: 40px;
text-align: center;
}
.success-icon {
width: 80px;
height: 80px;
margin: 0 auto 30px;
background-color: #28a745;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
}
.success-title {
font-size: 28px;
font-weight: 700;
color: #333;
margin-bottom: 20px;
}
.success-message {
font-size: 16px;
color: #666;
line-height: 1.6;
margin-bottom: 30px;
}
.countdown {
font-size: 18px;
font-weight: 600;
color: #007bff;
margin-bottom: 30px;
}
.countdown-timer {
display: inline-block;
background-color: #e7f3ff;
border: 2px solid #007bff;
border-radius: 6px;
padding: 10px 20px;
min-width: 80px;
}
.return-login-btn {
padding: 14px 30px;
background-color: #f5f5f5;
border: 2px solid #333;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
color: #333;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.return-login-btn:hover {
background-color: #e8e8e8;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
color: #333;
text-decoration: none;
}
.return-login-btn:active {
transform: translateY(0);
}
.info-text {
font-size: 14px;
color: #999;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="success-container">
{{-- 成功アイコン --}}
<div class="success-icon"></div>
{{-- 成功タイトル --}}
<div class="success-title">パスワード変更完了</div>
{{-- 成功メッセージ --}}
<div class="success-message">
パスワードが正常に変更されました。<br>
システムは安全に保護されています。
</div>
{{-- カウントダウン --}}
<div class="countdown">
<span id="countdown-text">自動的にログイン画面に戻ります:</span><br>
<div class="countdown-timer">
<span id="countdown-timer">10</span>
</div>
</div>
{{-- 手動でログイン画面に戻るボタン --}}
<a href="{{ route('logout') }}" class="return-login-btn">
ログイン画面へ
</a>
{{-- 補足テキスト --}}
<div class="info-text">
自動的にログイン画面に遷移しない場合は、上記ボタンをクリックしてください。
</div>
</div>
<script>
// 10秒後に自動的にログアウトしてログイン画面へリダイレクト
let countdownSeconds = 10;
const countdownElement = document.getElementById('countdown-timer');
const countdownTextElement = document.getElementById('countdown-text');
const countdownInterval = setInterval(() => {
countdownSeconds--;
countdownElement.textContent = countdownSeconds;
if (countdownSeconds <= 0) {
clearInterval(countdownInterval);
// ログアウト処理を実行してからログイン画面へ遷移
window.location.href = "{{ route('logout') }}";
}
}, 1000);
</script>
</body>
</html>

View File

@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>パスワード変更 - So-Manager</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css">
<style>
body {
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.password-change-container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
max-width: 600px;
width: 100%;
padding: 40px;
}
.panel-title {
text-align: center;
font-size: 28px;
font-weight: 700;
color: #333;
margin-bottom: 30px;
border-bottom: 3px solid #007bff;
padding-bottom: 15px;
}
.alert-warning-custom {
background-color: #fff3cd;
border: 2px solid #dc3545;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
color: #333;
line-height: 1.8;
}
.alert-warning-custom .text-danger {
color: #dc3545;
font-weight: 600;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
display: block;
font-size: 15px;
}
.form-group input[type="password"] {
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 15px;
transition: all 0.3s ease;
width: 100%;
}
.form-group input[type="password"]:focus {
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
outline: none;
}
.form-group input.is-invalid {
border-color: #dc3545;
}
.form-group input.is-invalid:focus {
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.25);
}
.invalid-feedback {
color: #dc3545;
font-size: 13px;
margin-top: 5px;
display: block;
}
.requirements {
background-color: #f8f9fa;
border-left: 4px solid #dc3545;
padding: 15px;
margin-bottom: 25px;
border-radius: 4px;
font-size: 14px;
color: #555;
}
.requirements ul {
margin: 0;
padding-left: 20px;
}
.requirements li {
margin-bottom: 8px;
}
.submit-btn {
width: 100%;
padding: 14px;
background-color: #f5f5f5;
border: 2px solid #333;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
color: #333;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
}
.submit-btn:hover {
background-color: #e8e8e8;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.submit-btn:active {
transform: translateY(0);
}
.error-message {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 6px;
padding: 12px 15px;
margin-bottom: 20px;
color: #721c24;
}
.error-message ul {
margin: 0;
padding-left: 20px;
}
.error-message li {
margin-bottom: 5px;
}
.password-change-container {
max-width: 720px;
/* 或者 800 */
}
</style>
</head>
<body>
<div class="password-change-container">
{{-- パネルタイトル --}}
<div class="panel-title">So-Manager 管理パネル</div>
{{-- バリデーションエラー表示 --}}
@if ($errors->any())
<div class="error-message">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
{{-- 警告メッセージ --}}
<div class="alert-warning-custom">
<p>
セキュリティを維持するため、
<span class="text-danger">
パスワードの有効期限が切れました3ヶ月<br>
</span>
<span class="text-danger">
新しいパスワードへの変更をお願いいたします。<br>
</span>
安全にご利用いただくため、定期的なパスワード更新が必要です。<br>
変更後、引き続きシステムをご利用いただけます。
</p>
</div>
{{-- パスワード変更フォーム --}}
<form action="{{ route('password.change.update') }}" method="POST">
@csrf
{{-- 当前パスワード --}}
<div class="form-group">
<label for="current_password">現在のパスワード:</label>
<input type="password" class="@error('current_password') is-invalid @enderror" id="current_password"
name="current_password" required minlength="8" maxlength="64">
@error('current_password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
{{-- 新パスワード --}}
<div class="form-group">
<label for="password">新しいパスワード:</label>
<input type="password" class="@error('password') is-invalid @enderror" id="password" name="password"
required minlength="8" maxlength="64" pattern="[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};:'\",.<>?/\\|`~]+">
@error('password')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
{{-- 新パスワード確認 --}}
<div class="form-group">
<label for="password_confirmation">新しいパスワード(確認):</label>
<input type="password" class="@error('password_confirmation') is-invalid @enderror"
id="password_confirmation" name="password_confirmation" required minlength="8" maxlength="64"
pattern="[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};:'\",.<>?/\\|`~]+">
@error('password_confirmation')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
{{-- 要件説明 --}}
<div class="requirements">
<strong>※新しいパスワードは、以下の条件を満たす必要があります。</strong>
<ul>
<li>8文字以上64文字以内</li>
<li>半角英数字および記号のみ使用可能</li>
<li>現在のパスワードと同一のものは使用できません</li>
</ul>
</div>
{{-- Hidden フィールド(設計書に記載) --}}
<input type="hidden" name="updated_at" value="{{ now()->format('Y-m-d H:i:s') }}">
<input type="hidden" name="ope_pass_changed_at" value="{{ now()->format('Y-m-d H:i:s') }}">
{{-- 送信ボタン & キャンセルボタン --}}
<div class="button-group">
<button type="submit" class="submit-btn">
パスワードを変更する
</button>
<a href="{{ route('logout') }}" class="cancel-btn">
ログイン画面へ
</a>
</div>
</form>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
</body>
</html>

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

@ -11,11 +11,15 @@
$queClassNums = []; $queClassNums = [];
foreach ($logs as $log) { foreach ($logs as $log) {
// オペレータ名取得 // オペレータ名取得
$operatorNames[$log->operator_id] = $operatorNames[$log->operator_id] ?? (\App\Models\Ope::find($log->operator_id)->ope_name ?? $log->operator_id); $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取得 // operator_queからque_class, que_status取得
$que = null; $que = null;
if (!empty($log->user_id) && !empty($log->contract_id)) { 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(); $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])) { if (!$que || !in_array($que->que_status, [3, 4])) {
@ -30,7 +34,8 @@
} else { } else {
continue; continue;
} }
$queClassNums[$log->operator_log_id] = \App\Models\OperatorQue::QueClass[$que->que_class] ?? $que->que_class; $queClassNums[$log->operator_log_id] =
\App\Models\OperatorQue::QueClass[$que->que_class] ?? $que->que_class;
} }
@endphp @endphp
<div class="container-fluid" style="background:#f4f6f9;min-height:calc(100vh - 60px);"> <div class="container-fluid" style="background:#f4f6f9;min-height:calc(100vh - 60px);">
@ -43,34 +48,69 @@
<div class="card-header bg-white font-weight-bold">インフォメーション</div> <div class="card-header bg-white font-weight-bold">インフォメーション</div>
<div class="card-body"> <div class="card-body">
@php @php
$infoQue = \App\Models\OperatorQue::whereIn('que_status', [1,2]) // ▼ 各メニューの件数を取得que_status は運用に合わせて調整)
->orderByDesc('created_at') // 例:未処理/処理中を 1,2 として集計
->limit(5) $statusTargets = [1, 2];
->get();
// ▼ 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 @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 mb-3" style="box-shadow:none;border:1px solid #e0e0e0;">
<div class="card-body py-3 px-4" style="position:relative;"> <div class="card-body py-3 px-4 d-flex align-items-center ">
<div class="d-flex align-items-center mb-2"> <a href="{{ route('personal') }}"
<div class="text-secondary" style="font-size:1.1em; margin-right:1em;"> style="font-weight:bold;color:#007bff;font-size:1.05em;">
{{ $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> </a>
<span class="badge badge-primary ml-auto"
style="font-size:0.95em;">{{ $fmt($cntPersonalCheck) }}</span>
</div> </div>
</div> </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>
@endforeach
@endif
</div> </div>
</div> </div>
</div> </div>
@ -103,7 +143,9 @@
@endif @endif
@endforeach @endforeach
@if (!$hasData) @if (!$hasData)
<tr><td colspan="5" class="text-center">データがありません</td></tr> <tr>
<td colspan="5" class="text-center">データがありません</td>
</tr>
@endif @endif
</tbody> </tbody>
</table> </table>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
@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">このページは未実装です</h1>
</div>
</div>
</div>
</div>
<div class="content">
<div class="container-fluid">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">準備中です</h4>
<p>このページは現在準備中です。</p>
<hr>
<p class="mb-0">リクエストコード: {{ request()->query('code', 'N/A') }}</p>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -42,6 +42,10 @@ use App\Http\Controllers\Admin\MailTemplateController;
use App\Http\Controllers\Admin\InvSettingController; use App\Http\Controllers\Admin\InvSettingController;
use App\Http\Controllers\Admin\ZoneController; use App\Http\Controllers\Admin\ZoneController;
use App\Http\Controllers\Admin\PplaceController; use App\Http\Controllers\Admin\PplaceController;
use App\Http\Controllers\Admin\ReductionConfirmMasterController;
use App\Http\Controllers\Admin\ParkingRegulationsController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\Auth\EmailOtpController;
/** /**
@ -70,16 +74,39 @@ Route::middleware('guest')->group(function () {
}); });
// ログアウトルート(認証済みユーザー専用) // ログアウトルート認証済みユーザー専用、OTP チェック対象外)
Route::get('logout', [App\Http\Controllers\Auth\LoginController::class, 'logout'])->middleware('auth'); Route::get('logout', [App\Http\Controllers\Auth\LoginController::class, 'logout'])
->middleware(['auth'])
->name('logout');
// 保護されたルート(認証済みユーザー専用) // 保護されたルート(認証済みユーザー専用)
// Laravel 12変更点middleware()をコントローラーではなくルートで指定 // Laravel 12変更点middleware()をコントローラーではなくルートで指定
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
// 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');
});
// パスワード変更ルートOTP チェック対象外、有効期限チェック対象)
// 定期的なパスワード変更が必須3ヶ月ごと
Route::prefix('password')->name('password.')->group(function () {
Route::get('/change', [App\Http\Controllers\Auth\PasswordChangeController::class, 'showChangeForm'])->name('change.show');
Route::post('/change', [App\Http\Controllers\Auth\PasswordChangeController::class, 'updatePassword'])->name('change.update');
Route::get('/success', [App\Http\Controllers\Auth\PasswordChangeController::class, 'showSuccessPage'])->name('change.success');
})->middleware('check.password.change.required');
// 以下のすべてのルートに OTP 認証チェックミドルウェアを適用
Route::middleware('ensure.otp.verified')->group(function () {
// ダッシュボード(ホーム画面) // ダッシュボード(ホーム画面)
Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home'); // パスワード有効期限チェック対象3ヶ月超過時は強制リダイレクト
Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])
->name('home')
->middleware('check.password.change.required');
// Laravel 12 移行時の一時的な占位符路由 // Laravel 12 移行時の一時的な占位符路由
// 他の開発者が継続して開発できるように、エラーを防ぐための仮ルート定義 // 他の開発者が継続して開発できるように、エラーを防ぐための仮ルート定義
@ -150,35 +177,13 @@ Route::middleware('auth')->group(function () {
Route::get('/pplace/export', [PplaceController::class, 'export'])->name('pplaces_export'); Route::get('/pplace/export', [PplaceController::class, 'export'])->name('pplaces_export');
// sou end // sou end
// 市区マスタcities.* // ou start
Route::prefix('cities')->group(function () { // 市区マスタ
Route::match(['get', 'post'], '/city', [CityController::class, 'list'])->name('city');
// 一覧(画面) Route::match(['get', 'post'], '/city/add', [CityController::class, 'add'])->name('city_add');
Route::get('/', [CityController::class, 'index']) Route::match(['get', 'post'], '/city/edit/{id}', [CityController::class, 'edit'])->where(['id' => '[0-9]+'])->name('city_edit');
->name('cities.index'); Route::match(['get', 'post'], '/city/info/{id}', [CityController::class, 'info'])->where(['id' => '[0-9]+'])->name('city_info');
Route::match(['get', 'post'], '/city/delete', [CityController::class, 'delete'])->name('city_delete');
// 新規(画面)
Route::get('/create', [CityController::class, 'create'])
->name('cities.create');
// 新規(登録)
Route::post('/', [CityController::class, 'store'])
->name('cities.store');
// 編集(画面)
Route::get('/{id}/edit', [CityController::class, 'edit'])
->whereNumber('id')
->name('cities.edit');
// 編集(更新)
Route::post('/{id}', [CityController::class, 'update'])
->whereNumber('id')
->name('cities.update');
// 削除(複数削除 pk[]
Route::post('/delete', [CityController::class, 'destroy'])
->name('cities.destroy');
});
// 駐輪場マスタparks.* // 駐輪場マスタparks.*
Route::prefix('parks')->group(function () { Route::prefix('parks')->group(function () {
@ -319,46 +324,14 @@ Route::middleware('auth')->group(function () {
}); });
// 利用者分類マスタusertypes.* // 利用者分類マスタ
Route::prefix('usertypes')->group(function () { Route::match(['get', 'post'], '/usertypes', [UsertypeController::class, 'list'])->name('usertypes');
Route::match(['get', 'post'], '/usertypes/add', [UsertypeController::class, 'add'])->name('usertype_add');
// 一覧(画面) Route::match(['get', 'post'], '/usertypes/edit/{id}', [UsertypeController::class, 'edit'])->name('usertype_edit')->where(['id' => '[0-9]+']);
Route::get('/', [UsertypeController::class, 'index']) Route::match(['get', 'post'], '/usertypes/info/{id}', [UsertypeController::class, 'info'])->name('usertype_info')->where(['id' => '[0-9]+']);
->name('usertypes.index'); Route::match(['get', 'post'], '/usertypes/delete', [UsertypeController::class, 'delete'])->name('usertypes_delete');
Route::match(['get', 'post'], '/usertypes/import', [UsertypeController::class, 'import'])->name('usertypes_import');
// 新規(画面) Route::match(['get', 'post'], '/usertypes/export', [UsertypeController::class, 'export'])->name('usertypes_export');
Route::get('/create', [UsertypeController::class, 'create'])
->name('usertypes.create');
// 新規(登録)
Route::post('/', [UsertypeController::class, 'store'])
->name('usertypes.store');
// 編集(画面)
Route::get('/{id}/edit', [UsertypeController::class, 'edit'])
->whereNumber('id')
->name('usertypes.edit');
// 編集(更新)
Route::post('/{id}', [UsertypeController::class, 'update'])
->whereNumber('id')
->name('usertypes.update');
// 削除(複数削除 pk[]
Route::post('/delete', [UsertypeController::class, 'destroy'])
->name('usertypes.destroy');
// インポート
Route::post('/import', [UsertypeController::class, 'import'])
->name('usertypes.import');
// エクスポート
Route::post('/export', [UsertypeController::class, 'export'])
->name('usertypes.export');
});
@ -380,6 +353,7 @@ Route::middleware('auth')->group(function () {
// 常時表示インフォメーション // 常時表示インフォメーション
Route::get('/information', [InformationController::class, 'list'])->name('information'); 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'); Route::post('/information/status', [InformationController::class, 'updateStatus'])->name('information.status');
// タグ発行キュー処理、履歴表示 // タグ発行キュー処理、履歴表示
@ -610,7 +584,54 @@ Route::middleware('auth')->group(function () {
->name('zones_delete'); ->name('zones_delete');
//kin end //kin end
});
Route::get('/opes/{id}/permissions', [OpeController::class, 'getPermissionsByFeature'])
->where(['id' => '[0-9]+'])
->name('opes.permissions_by_feature');
// 仮ルート(未実装機能用)
Route::get('/placeholder', function () {
return view('placeholder');
})->name('placeholder');
Route::get('/refund', function () {
return view('placeholder');
})->name('refund'); // SWA-13 返金処理
Route::get('/tag-reissue', function () {
return view('placeholder');
})->name('tag.reissue'); // SWA-05 タグ再発行
Route::get('/seal-reissue', function () {
return view('placeholder');
})->name('seal.reissue'); // SWA-06 シール再発行
Route::get('/sales-report', function () {
return view('placeholder');
})->name('sales.report'); // SWA-17 売上年報/月報/日報
Route::get('/sales-detail', function () {
return view('placeholder');
})->name('sales.detail'); // SWA-18 売上詳細
Route::get('/ptype', function () {
return view('placeholder');
})->name('ptype'); // SWA-29 駐輪分類マスタ
Route::get('/jurisdiction-parking', function () {
return view('placeholder');
})->name('jurisdiction.parking'); // SWA-37 駐輪規定読込記録
Route::get('/lottery-master', function () {
return view('placeholder');
})->name('lottery.master'); // SWA-44 駐輪場抽選応募マスタ
Route::get('/lottery-setting', function () {
return view('placeholder');
})->name('lottery.setting'); // SWA-45 抽選申込設定マスタ
}); // ensure.otp.verified ミドルウェアグループの閉じ括弧
}); // auth ミドルウェアグループの閉じ括弧
// Wellnet PUSH webhook (SHJ-4A) // Wellnet PUSH webhook (SHJ-4A)