Files
rogaining_srv/supervisor/html/index.html
2025-08-25 05:33:39 +09:00

2208 lines
96 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>スーパーバイザーパネル</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/exif-js/2.3.0/exif.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-50">
<!-- ログインフォーム -->
<div id="loginForm" class="fixed inset-0 bg-gray-800 bg-opacity-50 flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-lg w-96">
<h2 class="text-2xl font-bold mb-6 text-center">ログイン</h2>
<form onsubmit="return login(event)">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
ユーザー名
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="username" type="text" placeholder="ユーザー名">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">
パスワード
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
id="password" type="password" placeholder="パスワード">
</div>
<div class="flex items-center justify-center">
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
type="submit">
ログイン
</button>
</div>
</form>
</div>
</div>
<div id="mainContent" class="container mx-auto p-4" style="display: none;">
<div class="bg-white rounded-lg shadow-lg p-6 mb-6">
<h1 class="text-2xl font-bold mb-6">通過審査管理画面</h1>
<button onclick="logout()" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
ログアウト
</button>
<!-- 選択フォーム -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">イベントコード</label>
<select id="eventCode" class="w-full border border-gray-300 rounded-md px-3 py-2">
<option value="">イベントを選択</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">ゼッケン番号</label>
<select id="zekkenNumber" class="w-full border border-gray-300 rounded-md px-3 py-2">
<option value="">ゼッケン番号を選択</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">表示モード</label>
<select id="displayMode" class="w-full border border-gray-300 rounded-md px-3 py-2">
<option value="individual">個別参加者</option>
<option value="ranking">全参加者ランキング</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">写真一括アップロード</label>
<input type="file" id="bulkUpload" multiple accept="image/*" class="w-full border border-gray-300 rounded-md px-3 py-2">
</div>
</div>
<!-- 個別参加者表示 -->
<div id="individualView" style="display: none;">
<!-- チーム情報サマリー -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">チーム名</div>
<div id="teamName" class="font-semibold"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">メンバー</div>
<div id="members" class="font-semibold"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">スタート時刻</div>
<div id="startTime" class="font-semibold"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">ゴール時刻</div>
<div id="goalTime" class="font-semibold"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">ゴール時刻</div>
<div class="goal-time-container">
<span id="goalTimeDisplay" class="goal-time-display cursor-pointer"></span>
<input type="datetime-local" id="goalTimeInput" class="goal-time-input hidden border rounded px-2 py-1"
onchange="updateGoalTime(this)">
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">ゴール時計</div>
<div id="goalTime" class="font-semibold"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">判定</div>
<div id="validate" class="font-semibold text-blue-600"></div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">CP合計</div>
<div id="totalPoints" class="font-semibold"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">買物合計</div>
<div id="buyPoints" class="font-semibold"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">遅刻減点</div>
<div id="latePoints" class="font-semibold text-red-600"></div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="text-sm text-gray-500">総合計</div>
<div id="finalPoints" class="font-semibold text-blue-600"></div>
</div>
</div>
<!-- チェックインデータテーブル -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="w-8"></th> <!-- ハンバーガーアイコン用 -->
<th class="px-1 py-3 text-left text-xs font-medium text-gray-500 uppercase">走行順</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">規定写真</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">撮影写真</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">CP名称</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">通過時刻</th>
<th class="px-1 py-3 text-left text-xs font-medium text-gray-500 uppercase">買物申請</th>
<th class="px-1 py-3 text-left text-xs font-medium text-gray-500 uppercase">通過審査</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">獲得点数</th>
<th class="w-8"></th> <!-- 削除ボタン用 -->
</tr>
</thead>
<tbody id="checkinList" class="bg-white divide-y divide-gray-200">
<!-- JavaScript で動的に生成 -->
</tbody>
</table>
</div>
<!-- アクションボタン -->
<div class="mt-6 flex justify-end space-x-4">
<button onclick="showAddCPDialog()" class="px-4 py-2 bg-blue-500 text-white rounded">
新規CP追加
</button>
<button id="saveButton" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
保存
</button>
<button id="previewButton" class="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700">
プレビュー
</button>
<button id="exportButton" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
通過証明書印刷
</button>
<button id="bulkValidateButton" class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700">
一括確定
</button>
<button id="bulkInvalidateButton" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">
一括否認
</button>
</div>
</div>
<!-- 全参加者ランキング表示 -->
<div id="rankingView" style="display: none;">
<div class="mb-4">
<h2 class="text-xl font-bold">全参加者ランキング</h2>
<div class="text-sm text-gray-600">クラス別得点降順表示</div>
</div>
<!-- クラス別タブ -->
<div class="mb-4">
<div id="classTabs" class="flex space-x-2 border-b">
<!-- JavaScript で動的に生成 -->
</div>
</div>
<!-- ランキングテーブル -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">順位</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">ゼッケン</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">チーム名</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">クラス</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">確定得点</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">未確定得点</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">合計得点</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">確定率</th>
<th class="px-2 py-3 text-left text-xs font-medium text-gray-500 uppercase">アクション</th>
</tr>
</thead>
<tbody id="rankingList" class="bg-white divide-y divide-gray-200">
<!-- JavaScript で動的に生成 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// APIのベースURLを環境に応じて設定
const API_BASE_URL = '/api';
// 認証付きAPIリクエストのヘルパー関数
function getAuthHeaders() {
const token = sessionStorage.getItem('authToken');
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Token ${token}` })
};
}
let original_goal_time = '';
let selected_event_code = '';
// ユーザー認証用の定数(実際の運用ではサーバーサイドで管理すべき)
const VALID_USERNAME = 'admin';
const VALID_PASSWORD = 'password123';
// セッション管理
function checkAuth() {
const isAuthenticated = sessionStorage.getItem('isAuthenticated');
if (isAuthenticated) {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
// イベント情報をロード
loadEventCodes();
} else {
document.getElementById('loginForm').style.display = 'flex';
document.getElementById('mainContent').style.display = 'none';
}
}
// ログイン処理
async function login(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: username, // メールアドレス
password: password
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'ログインに失敗しました');
}
// SuperVisor User のみを許可する。
console.info('Login successful:', data);
sessionStorage.setItem('isAuthenticated', 'true');
sessionStorage.setItem('authToken', data.token); // トークンも保存
checkAuth();
} catch (error) {
console.error('Login error:', error);
alert('ログインに失敗しました: ' + error.message);
}
return false;
}
// ログアウト処理
function logout() {
sessionStorage.removeItem('isAuthenticated');
sessionStorage.removeItem('authToken');
checkAuth();
}
// イベントリスナーの設定
document.addEventListener('DOMContentLoaded', function() {
checkAuth();
// Sortable初期化これで、通過順序を変更できる
const checkinList = document.getElementById('checkinList');
new Sortable(checkinList, {
animation: 150,
onEnd: function(evt) {
updatePathOrders();
}
});
// 選択されたイベントコード・ゼッケン番号を取得
const eventCodeSelect = document.getElementById('eventCode');
const zekkenNumberSelect = document.getElementById('zekkenNumber');
// 表示モード切り替え
const displayModeSelect = document.getElementById('displayMode');
displayModeSelect.addEventListener('change', function(e) {
const selectedMode = e.target.value;
toggleDisplayMode(selectedMode);
});
// ゼッケン番号変更時の処理
zekkenNumberSelect.addEventListener('change', function(e) {
const eventCode = eventCodeSelect.value;
if (!eventCode) {
alert('イベントコードを選択してください');
return;
}
selected_event_code = eventCode;
const selectedZekken = e.target.value;
if (selectedZekken === 'ALL') {
// 全参加者ランキング表示
loadEventParticipantsRanking(eventCode);
} else {
// 個別参加者データ表示
loadTeamData(selectedZekken, eventCode);
}
});
// 一括写真アップロード
const bulkUploadInput = document.getElementById('bulkUpload');
if (bulkUploadInput) {
bulkUploadInput.addEventListener('change', function(e) {
const files = Array.from(e.target.files);
if (files.length > 0) {
handleBulkPhotoUpload(files);
}
});
}
});
// 表示モード切り替え関数
function toggleDisplayMode(mode) {
const individualView = document.getElementById('individualView');
const rankingView = document.getElementById('rankingView');
if (mode === 'ranking') {
individualView.style.display = 'none';
rankingView.style.display = 'block';
// ゼッケン選択をALLに変更
const zekkenSelect = document.getElementById('zekkenNumber');
if (zekkenSelect.value !== 'ALL') {
zekkenSelect.value = 'ALL';
zekkenSelect.dispatchEvent(new Event('change'));
}
} else {
individualView.style.display = 'block';
rankingView.style.display = 'none';
}
}
// 一括写真アップロード処理
async function handleBulkPhotoUpload(files) {
const eventCode = document.getElementById('eventCode').value;
if (!eventCode) {
alert('イベントコードを選択してください');
return;
}
const formData = new FormData();
formData.append('event_code', eventCode);
files.forEach((file, index) => {
formData.append('photos', file);
});
try {
const response = await fetch(`${API_BASE_URL}/bulk-upload-photos/`, {
method: 'POST',
headers: getAuthHeaders(),
body: formData
});
const result = await response.json();
if (response.ok) {
alert(`アップロード完了: 処理済み ${result.processed_count} 件, 成功 ${result.success_count}`);
// 現在表示中のデータを再読み込み
const zekkenSelect = document.getElementById('zekkenNumber');
if (zekkenSelect.value && zekkenSelect.value !== 'ALL') {
loadTeamData(zekkenSelect.value, eventCode);
}
} else {
alert('アップロードエラー: ' + result.error);
}
} catch (error) {
console.error('Bulk upload error:', error);
alert('アップロードに失敗しました');
}
}
// 全参加者ランキング読み込み
async function loadEventParticipantsRanking(eventCode) {
try {
const response = await fetch(`${API_BASE_URL}/event-participants-ranking/?event_code=${eventCode}`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
displayRanking(data);
} catch (error) {
console.error('Error loading ranking:', error);
alert('ランキングの読み込みに失敗しました');
}
}
// ランキング表示
function displayRanking(data) {
const classTabs = document.getElementById('classTabs');
const rankingList = document.getElementById('rankingList');
// クラス別タブを作成
classTabs.innerHTML = '';
const allClasses = [...new Set(data.all_participants.map(p => p.category.class_name))];
allClasses.forEach((className, index) => {
const tab = document.createElement('button');
tab.className = `px-4 py-2 border-b-2 ${index === 0 ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`;
tab.textContent = className;
tab.onclick = () => filterRankingByClass(className, data.all_participants);
classTabs.appendChild(tab);
});
// 初期表示は最初のクラス
if (allClasses.length > 0) {
filterRankingByClass(allClasses[0], data.all_participants);
}
}
// クラス別ランキングフィルター
function filterRankingByClass(className, allParticipants) {
const rankingList = document.getElementById('rankingList');
const classParticipants = allParticipants
.filter(p => p.category.class_name === className)
.sort((a, b) => b.points.total - a.points.total); // 合計得点降順
rankingList.innerHTML = '';
classParticipants.forEach((participant, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="px-2 py-4 text-sm">${index + 1}</td>
<td class="px-2 py-4 text-sm font-medium">${participant.zekken_number}</td>
<td class="px-4 py-4 text-sm">${participant.team_name}</td>
<td class="px-2 py-4 text-sm">${participant.category.class_name}</td>
<td class="px-2 py-4 text-sm font-semibold text-green-600">${participant.points.confirmed_points}</td>
<td class="px-2 py-4 text-sm text-yellow-600">${participant.points.unconfirmed_points}</td>
<td class="px-2 py-4 text-sm font-bold">${participant.points.total}</td>
<td class="px-2 py-4 text-sm">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs ${
participant.checkin_status.confirmation_rate >= 90 ? 'bg-green-100 text-green-800' :
participant.checkin_status.confirmation_rate >= 70 ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}">
${participant.checkin_status.confirmation_rate}%
</span>
</td>
<td class="px-2 py-4 text-sm">
<button onclick="viewParticipantDetails('${participant.zekken_number}')"
class="text-blue-600 hover:text-blue-800">詳細</button>
</td>
`;
rankingList.appendChild(row);
});
}
// 参加者詳細表示
function viewParticipantDetails(zekkenNumber) {
const eventCode = document.getElementById('eventCode').value;
const zekkenSelect = document.getElementById('zekkenNumber');
const displayModeSelect = document.getElementById('displayMode');
// 個別表示モードに切り替え
displayModeSelect.value = 'individual';
toggleDisplayMode('individual');
// ゼッケン番号を設定してデータ読み込み
zekkenSelect.value = zekkenNumber;
loadTeamData(zekkenNumber, eventCode);
}
// チェックイン確定/否認関数
async function confirmCheckin(checkinId, action) {
try {
const response = await fetch(`${API_BASE_URL}/confirm-checkin-validation/`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
checkin_id: checkinId,
action: action,
comment: action === 'REJECTED' ? '手動確認により却下' : '手動確認により承認'
})
});
const result = await response.json();
if (response.ok) {
// 現在表示中のデータを再読み込み
const eventCode = document.getElementById('eventCode').value;
const zekkenNumber = document.getElementById('zekkenNumber').value;
if (zekkenNumber && zekkenNumber !== 'ALL') {
loadTeamData(zekkenNumber, eventCode);
}
} else {
alert('処理エラー: ' + result.error);
}
} catch (error) {
console.error('Confirmation error:', error);
alert('処理に失敗しました');
}
}
// 一括確定/否認処理
async function bulkValidateCheckins(action) {
const eventCode = document.getElementById('eventCode').value;
const zekkenNumber = document.getElementById('zekkenNumber').value;
if (!eventCode || !zekkenNumber || zekkenNumber === 'ALL') {
alert('個別参加者を選択してください');
return;
}
const confirmMessage = action === 'APPROVED' ?
'表示中の全てのチェックインを確定しますか?' :
'表示中の全てのチェックインを否認しますか?';
if (!confirm(confirmMessage)) {
return;
}
try {
// 現在表示中のチェックインIDを取得
const checkinRows = document.querySelectorAll('#checkinList tr[data-checkin-id]');
const checkinIds = Array.from(checkinRows).map(row => row.dataset.checkinId);
for (const checkinId of checkinIds) {
await confirmCheckin(checkinId, action);
}
alert(`${checkinIds.length}件のチェックインを${action === 'APPROVED' ? '確定' : '否認'}しました`);
} catch (error) {
console.error('Bulk validation error:', error);
alert('一括処理に失敗しました');
}
}
// DOMContentLoaded内に移動すべきイベントリスナーの設定
document.addEventListener('DOMContentLoaded', function() {
// ゼッケン番号変更時の処理
const zekkenNumberSelect = document.getElementById('zekkenNumber');
const eventCodeSelect = document.getElementById('eventCode');
if (zekkenNumberSelect) {
zekkenNumberSelect.addEventListener('change', function(e) {
const eventCode = eventCodeSelect.value;
if (!eventCode) {
alert('イベントコードを選択してください');
return;
}
selected_event_code = eventCode;
loadTeamData(e.target.value, eventCode);
});
}
// イベントコード変更時の処理
if (eventCodeSelect) {
eventCodeSelect.addEventListener('change', function(e) {
loadZekkenNumbers(e.target.value);
handleEventSelect(e.target.value);
});
}
// 保存ボタンの処理
const saveButton = document.getElementById('saveButton');
if (saveButton) {
saveButton.addEventListener('click', saveChanges);
}
// Excel出力ボタンの処理
const previewButton = document.getElementById('previewButton');
const exportButton = document.getElementById('exportButton');
if (previewButton) {
previewButton.addEventListener('click', previewCertificate);
}
if (exportButton) {
exportButton.addEventListener('click', exportExcel);
}
// 一括処理ボタンの処理
const bulkValidateButton = document.getElementById('bulkValidateButton');
const bulkInvalidateButton = document.getElementById('bulkInvalidateButton');
if (bulkValidateButton) {
bulkValidateButton.addEventListener('click', () => bulkValidateCheckins('APPROVED'));
}
if (bulkInvalidateButton) {
bulkInvalidateButton.addEventListener('click', () => bulkValidateCheckins('REJECTED'));
}
//console.log('Page loaded, attempting to load events...');
// 初期データ読み込み
loadEventCodes();
});
// editGoalTime関数の修正
function editGoalTime(element) {
const container = element.closest('.goal-time-container');
if (!container) {
console.error('Goal time container not found');
return;
}
const display = container.querySelector('.goal-time-display');
const input = container.querySelector('.goal-time-input');
if (!display || !input) {
console.error('Goal time elements not found');
return;
}
if (display.textContent && display.textContent !== '-') {
try {
const date = new Date(display.textContent);
const jstDate = new Date(date.getTime() + (9 * 60 * 60 * 1000));
input.value = jstDate.toISOString().slice(0, 19);
} catch (e) {
console.error('Error parsing date:', e);
}
}
// input要素のstep属性を1秒に設定
input.setAttribute('step', '1'); // 1秒単位で入力可能に設定
display.classList.add('hidden');
input.classList.remove('hidden');
input.focus();
}
// ゴール時刻編集機能
function old_editGoalTime(element) {
const display = element.getElementById('goalTimeDisplay');
const input = element.getElementById('goalTimeInput');
if (!display || !input) {
console.error('Goal time elements not found');
return;
}
// 現在の表示時刻をinputの初期値として設定
const currentTime = display.textContent;
if (currentTime && currentTime !== '-') {
try {
const date = new Date(currentTime);
input.value = date.toISOString().slice(0, 19); // YYYY-MM-DDThh:mm:ss 形式に変換
} catch (e) {
console.error('Error parsing date:', e);
}
}
// input要素のstep属性を1秒に設定
input.setAttribute('step', '1'); // 1秒単位で入力可能に設定
display.classList.add('hidden');
input.classList.remove('hidden');
input.focus();
}
function updateGoalTime(input) {
const display = document.getElementById('goalTimeDisplay');
const validateElement = document.getElementById('validate');
setLatePoint(0);
if (!display) {
console.error('Goal time display element not found');
return;
}
if (!input) {
const validateElement = document.getElementById('validate');
display.textContent = '棄権';
if (validateElement) {
validateElement.textContent = '棄権';
}
return;
}
const newTime = new Date(input.value);
updateValidationWithGoalTime(newTime)
display.classList.remove('hidden');
input.classList.add('hidden');
}
function updateValidationWithGoalTime(goaltime) {
const display = document.getElementById('goalTimeDisplay');
if (!goaltime) {
const validateElement = document.getElementById('validate');
display.textContent = '棄権';
if (validateElement) {
validateElement.textContent = '棄権';
}
return 0;
}
try {
const newTime = new Date(goaltime);
display.textContent = newTime.toLocaleString();
let late_point = 0;
const eventCodeSelect = document.getElementById('eventCode');
const event_code = eventCodeSelect.value;
const zekkenNumberSelect = document.getElementById('zekkenNumber');
const zekkenNumber = zekkenNumberSelect.value
// イベントとチェックインデータを取得
fetch(`${API_BASE_URL}/team_info/${zekkenNumber}`,{
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(team => {
if (team.self_rogaining) {
// セルフロゲイニングの場合
fetch(`${API_BASE_URL}/checkins/${zekkenNumber}/${eventCode}/`,{
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(checkins => {
const startCheckin = checkins.find(c => c.cp_number === -1);
if (startCheckin) {
const startTime = new Date(startCheckin.create_at);
const timeDiff = (newTime - startTime) / 1000; // 秒単位の差
const maxTime = team.duration + 15*60; // 制限時間+15分
updateValidation(timeDiff, maxTime );
const overtime = ((newTime - startTime) / 1000 - team.duration/1000 + 60 ) // 60;
if( overtime>0 ){
late_point = overtime*(-50);
lateElement = document.getElementById('latePoints');
lateElement.textContent = late_point;
lateElement.classList.add('text-red-600');
}else{
lateElement = document.getElementById('latePoints');
lateElement.textContent = 0;
lateElement.classList.remove('text-red-600');
}
}
calculatePoints(late_point); // 総合ポイントの計算
})
.catch(error => handleApiError(error));
} else {
// 通常のロゲイニングの場合
const startTime = new Date(team.start_datetime);
const timeDiff = (newTime - startTime) / 1000; // 分単位の差
const maxTime = team.duration + 15*60; // 制限時間+15分
console.info('startTime=',startTime,',goalTime=',newTime,',timeDiff=',timeDiff,'duration=',team.duration,',maxTime=',maxTime);
updateValidation(timeDiff, maxTime );
// 1秒でも遅刻すると、1分につき-50点
const overtime = ((newTime - startTime) / 1000 - team.duration );
if( overtime>0 ){
//console.info('overtime=',overtime);
late_point = Math.ceil(overtime/60)*(-50);
lateElement = document.getElementById('latePoints');
lateElement.textContent = late_point;
lateElement.classList.add('text-red-600');
}else{
lateElement = document.getElementById('latePoints');
lateElement.textContent = 0;
lateElement.classList.remove('text-red-600');
}
}
calculatePoints(late_point); // 総合ポイントの計算
});
return late_point;
} catch (e) {
console.error('Error updating goal time:', e);
alert('無効な日時形式です。');
return 0;
}
}
function setLatePoint(points){
const lateElement = document.getElementById('latePoints');
lateElement.textContent = points;
}
// 判定の更新を行う補助関数
function updateValidation(timeDiff, maxTime) {
console.log('updateValidation:',timeDiff,' > ',maxTime)
const validateElement = document.getElementById('validate');
if (validateElement) {
if (timeDiff > maxTime) {
validateElement.textContent = '失格';
validateElement.classList.add('text-red-600');
validateElement.classList.remove('text-green-600');
} else {
validateElement.textContent = '完走';
validateElement.classList.add('text-green-600');
validateElement.classList.remove('text-red-600');
}
}
}
function validateGoalTime(goalTime) {
const eventEndTime = new Date(document.getElementById('eventEndTime').value);
const goalDateTime = new Date(goalTime);
const timeDiff = (goalDateTime - eventEndTime) / (1000 * 60); // 分単位の差
const validateElement = document.getElementById('validate');
if (timeDiff > 15) {
validateElement.textContent = '失格';
validateElement.classList.add('text-red-600');
} else {
validateElement.textContent = '正常';
validateElement.classList.remove('text-red-600');
}
}
async function loadEventCodes() {
console.log('loadEventCodes called');
try {
const apiUrl = `${API_BASE_URL}/new-events/`;
console.log('Fetching events from URL:', apiUrl);
const response = await fetch(apiUrl, {
headers: getAuthHeaders()
});
console.log('Events API Response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new TypeError("Response is not JSON");
}
const data = await response.json();
console.log('Events data received:', data);
const select = document.getElementById('eventCode');
// 既存のオプションをクリア
select.innerHTML = '<option value="">イベントを選択</option>';
data.forEach(event => {
const option = document.createElement('option');
option.value = event.code;
option.textContent = event.name;
select.appendChild(option);
});
console.log(`Added ${data.length} events to select`);
} catch (error) {
console.error('Error loading events:', error);
// ユーザーにエラーを表示
const select = document.getElementById('eventCode');
select.innerHTML = '<option value="">エラー: イベントの読み込みに失敗しました</option>';
}
}
async function loadTeamData(zekkenNumber,event_code) {
try {
// ALLが選択された場合は、ランキング表示モードに切り替える
if (zekkenNumber === 'ALL') {
// 表示モードを自動的にランキングに変更
const displayModeSelect = document.getElementById('displayMode');
if (displayModeSelect) {
displayModeSelect.value = 'ranking';
toggleDisplayMode('ranking');
}
// ランキングデータを読み込む
await loadEventParticipantsRanking(event_code);
return;
} else {
// 個別参加者が選択された場合は、個別表示モードに切り替える
const displayModeSelect = document.getElementById('displayMode');
if (displayModeSelect) {
displayModeSelect.value = 'individual';
toggleDisplayMode('individual');
}
}
const [teamResponse, checkinsResponse] = await Promise.all([
fetch(`${API_BASE_URL}/team_info/${zekkenNumber}/`),
fetch(`${API_BASE_URL}/checkins/${zekkenNumber}/${event_code}/`),
]);
// 各レスポンスのステータスを個別にチェック
if (!teamResponse.ok)
throw new Error(`Team info fetch failed with status ${teamResponse.status}`);
if (!checkinsResponse.ok)
throw new Error(`Checkins fetch failed with status ${checkinsResponse.status}`);
setLatePoint(0);
const teamData = await teamResponse.json();
const checkinsData = await checkinsResponse.json();
// ゴール時刻の表示を更新
updateGoalTimeDisplay(teamData.end_datetime);
original_goal_time = teamData.end_datetime;
latePoint = updateValidationWithGoalTime(teamData.end_datetime);
// イベントコードに対応するイベントを検索
//const event = eventData.find(e => e.code === document.getElementById('eventCode').value);
document.getElementById('teamName').textContent = teamData.team_name || '-';
document.getElementById('members').textContent = teamData.members || '-';
document.getElementById('startTime').textContent =
teamData.start_datetime ? new Date(teamData.start_datetime).toLocaleString() : '-';
//document.getElementById('goalTime').textContent =
// teamData.end_datetime ? new Date(teamData.end_datetime).toLocaleString() : '-';
//'(未ゴール)';
const goalTimeDisplay = document.getElementById('goalTimeDisplay');
const goalTime = teamData.end_datetime ?
new Date(teamData.end_datetime).toLocaleString() :
'未ゴール';
goalTimeDisplay.textContent = goalTime;
//updateGoalTime(teamData.end_datetime) // ゴール時刻の表示を更新
if (goalTime === '-') {
goalTimeDisplay.classList.add('cursor-pointer');
goalTimeDisplay.onclick = () => editGoalTime(goalTimeDisplay);
}
//console.info("step 0");
// ゴール時計の表示を更新
const goalTimeElement = document.getElementById('goalTime');
if (teamData.goal_photo) {
// 画像要素を作成
//console.info("step 1");
const img = document.createElement('img');
img.src = teamData.goal_photo;
img.classList.add('h-32', 'w-auto', 'object-contain', 'cursor-pointer');
img.onclick = () => showLargeImage(teamData.goal_photo);
//console.info("step 2");
// 既存の内容をクリアして画像を追加
goalTimeElement.innerHTML = '';
goalTimeElement.appendChild(img);
//console.info("Goal photo displayed: ",teamData.goal_photo);
} else {
goalTimeElement.textContent = '画像なし';
console.info("No goal photo available");
}
// チェックインリストの更新
const tbody = document.getElementById('checkinList');
tbody.innerHTML = ''; // 既存のデータをクリア
let totalPoints = 0;
let buyPoints = 0;
checkinsData.forEach((checkin, index) => {
const tr = document.createElement('tr');
tr.dataset.id = checkin.id;
tr.dataset.checkinId = checkin.id; // 新しい機能のために追加
tr.dataset.local_id = index+1;
tr.dataset.cpNumber = checkin.cp_number;
tr.dataset.buyPoint = checkin.buy_point;
tr.dataset.checkinPoint = checkin.checkin_point;
tr.dataset.path_order = index+1;
const bgColor = checkin.buy_point > 0 ? 'bg-blue-100' : '';
tr.innerHTML = `
<td class="px-1 py-3 cursor-move">
<i class="fas fa-bars text-gray-400"></i>
</td>
<td class="px-2 py-3">${tr.dataset.path_order}</td>
<td class="px-2 py-3">
${checkin.photos ?
`<img src="/media/compressed/${checkin.photos}" class="h-20 w-20 object-cover rounded" onclick="showLargeImage(this.src)">` : ''}
</td>
<td class="px-2 py-3">
${checkin.cp_number===-1 || checkin.image_address===null ? "" :
`<img src="${checkin.image_address}"
class="h-20 w-20 object-cover rounded"
onclick="showLargeImage(this.src)"
onerror="this.parentElement.innerHTML=''">`}
</td>
<td class="px-2 py-3 ${bgColor}">
<div class="font-bold">${checkin.sub_loc_id}</div>
<div class="text-sm">${checkin.location_name}</div>
</td>
<td class="px-2 py-3">${checkin.create_at || '不明'}</td>
<td class="px-1 py-3">
${checkin.buy_point > 0 ? `
<input type="checkbox"
${checkin.buy_flag ? 'checked' : ''}
class="h-4 w-4 text-green-600 rounded buy-checkbox"
onchange="updatePoints(this)">
` : ''}
</td>
<td class="px-1 py-3">
<input type="checkbox"
${checkin.validate_location ? 'checked' : ''}
class="h-4 w-4 text-blue-600 rounded validate-checkbox"
onchange="updatePoints(this)">
</td>
<td class="px-2 py-3 point-value">${calculatePointsForCheckin(checkin)}</td>
<td class="px-2 py-4">
</td>
`;
tbody.appendChild(tr);
// 合計計算
//if (checkin.points) {
// if (checkin.buy_flag) {
// buyPoints += checkin.points;
// } else {
// totalPoints += checkin.points;
// }
//}
});
updatePathOrders();
// 合計ポイントの更新
//document.getElementById('totalPoints').textContent = totalPoints;
//document.getElementById('buyPoints').textContent = buyPoints;
//document.getElementById('latePoints').textContent = teamData.late_points || 0;
//document.getElementById('finalPoints').textContent =
// totalPoints + buyPoints + (teamData.late_points || 0);
calculatePoints(latePoint); // 総合ポイントの計算
} catch (error) {
console.error('Error loading team info:', error);
// エラーメッセージをユーザーに表示
alert(`データの読み込みに失敗しました: ${error.message}`);
// UIをクリア
document.getElementById('teamName').textContent = '-';
document.getElementById('members').textContent = '-';
document.getElementById('startTime').textContent = '-';
document.getElementById('goalTime').textContent = '-';
document.getElementById('checkinList').innerHTML = '';
document.getElementById('totalPoints').textContent = '0';
document.getElementById('buyPoints').textContent = '0';
document.getElementById('latePoints').textContent = '0';
document.getElementById('finalPoints').textContent = '0';
}
}
// ゴール時刻表示を更新する関数
function updateGoalTimeDisplay(endDateTime) {
const goalTimeDisplay = document.getElementById('goalTimeDisplay');
const goalTimeInput = document.getElementById('goalTimeInput');
if (!goalTimeDisplay || !goalTimeInput) {
console.error('Goal time elements not found');
return;
}
let displayText = '-';
if (endDateTime) {
try {
const date = new Date(endDateTime);
displayText = date.toLocaleString();
// input要素の値も更新
goalTimeInput.value = date.toISOString().slice(0, 16);
} catch (e) {
console.error('Error formatting date:', e);
}
}
goalTimeDisplay.textContent = displayText;
goalTimeDisplay.onclick = () => editGoalTime(goalTimeDisplay);
}
// UI要素をリセットする関数
function resetUIElements() {
const elements = {
'goalTimeDisplay': '-',
'teamName': '-',
'members': '-',
'startTime': '-',
'totalPoints': '0',
'buyPoints': '0',
'latePoints': '0',
'finalPoints': '0'
};
for (const [id, defaultValue] of Object.entries(elements)) {
const element = document.getElementById(id);
if (element) {
element.textContent = defaultValue;
}
}
// チェックインリストをクリア
const checkinList = document.getElementById('checkinList');
if (checkinList) {
checkinList.innerHTML = '';
}
}
// チーム情報の表示...使われていない
function nouse_updateTeamInfo(teamInfo) {
document.getElementById('teamName').textContent = teamInfo.team_name;
document.getElementById('members').textContent = teamInfo.members;
document.getElementById('startTime').textContent = teamInfo.start_time;
document.getElementById('goalTime').textContent = teamInfo.goal_time;
document.getElementById('latePoints').textContent = teamInfo.late_points;
}
function deleteRow(rowIndex) {
try {
if (!confirm(`このチェックインを削除してもよろしいですか?`)) {
return;
}
const tbody = document.getElementById('checkinList');
const rows = tbody.getElementsByTagName('tr');
let target = null;
for (const row of rows) {
if( Number(row.dataset.local_id) === Number(rowIndex) ) {
target = row;
break;
}
}
// 対象の行が見つからなかった場合
if (!target) {
throw new Error('Target row not found');
}
target.remove();
updatePathOrders();
// ポイントを再計算
calculatePoints();
} catch (error) {
console.error('Error deleting row:', error);
alert('行の削除に失敗しました');
}
}
// 削除機能
async function old_deleteCheckin(id) {
if (!confirm('このチェックインを削除してもよろしいですか?')) {
return;
}
try {
const response = await fetch(`${API_BASE_URL}/checkins/${id}`, {
method: 'DELETE',
})
if (response.ok) {
const row = document.querySelector(`tr[data-id="${id}"]`);
row.remove();
calculatePoints(); // 総合ポイントを再計算
} else {
throw new Error('Delete failed');
}
} catch (error) {
console.error('Error deleting checkin:', error);
alert('削除に失敗しました');
}
}
// ポイント計算関数
function calculatePointsForCheckin(checkin) {
let points = 0;
if (checkin.validate_location) {
if(checkin.buy_flag){
points += Number(checkin.buy_point) || 0;
}else{
points += Number(checkin.checkin_point) || 0;
}
}
return points;
}
// ポイント更新関数
function old_updatePoints(checkbox) {
const tr = checkbox.closest('tr');
const pointCell = tr.querySelector('.point-value');
const cpNumber = tr.dataset.cpNumber;
fetch(`${API_BASE_URL}/location/${cpNumber}/`)
.then(response => response.json())
.then(location => {
const points = checkbox.checked ? location.checkin_point : 0;
pointCell.textContent = points;
calculateTotalPoints();
});
}
// 審査チェックボックス更新関数
function updatePoints(checkbox) {
const tr = checkbox.closest('tr');
const pointCell = tr.querySelector('.point-value');
const cpNumber = tr.dataset.cpNumber;
const buyPoint = tr.dataset.buyPoint;
const checkinPoint = tr.dataset.checkinPoint;
const validateCheckbox = tr.querySelector('.validate-checkbox');
const buyCheckbox = tr.querySelector('.buy-checkbox');
let checkin = {
validate_location: validateCheckbox ? validateCheckbox.checked : false,
buy_flag: buyCheckbox ? buyCheckbox.checked : false,
points: 0
};
if (checkin.validate_location) {
// チェックボックスがONの場合
if (checkin.buy_flag) {
checkin.points = buyPoint || 0;
} else {
checkin.points = checkinPoint || 0;
}
} else {
// チェックボックスがOFFの場合
checkin.points = 0;
}
// ポイントを表示
pointCell.textContent = checkin.points;
calculatePoints(); // 総合ポイントの計算
}
// 買い物ポイント更新関数
function old_updateBuyPoints(checkbox) {
const tr = checkbox.closest('tr');
const pointCell = tr.querySelector('.point-value');
const cpNumber = tr.dataset.cpNumber;
fetch(`${API_BASE_URL}/location/${cpNumber}/`)
.then(response => response.json())
.then(location => {
const points = checkbox.checked ? location.buy_point : 0;
pointCell.textContent = points;
calculateTotalPoints();
});
}
// 買い物チェックボックス更新関数
function updateBuyPoints(checkbox) {
const tr = checkbox.closest('tr');
const pointCell = tr.querySelector('.point-value');
const cpNumber = tr.dataset.cpNumber;
const validateCheckbox = tr.querySelector('.validate-checkbox');
let checkin = {
validate_location: validateCheckbox.checked,
buy_flag: checkbox.checked,
points: 0
};
fetch(`${API_BASE_URL}/location/${cpNumber}/`)
.then(response => response.json())
.then(location => {
if (checkin.validate_location) {
// 通過審査がONの場合
checkin.points = checkbox.checked ? location.buy_point : location.checkin_point;
} else {
// 通過審査がOFFの場合
checkin.points = 0;
}
// ポイントを表示
pointCell.textContent = checkin.points;
calculatePoints(); // 総合ポイントの計算
})
}
// 画像拡大表示用のモーダル関数
function showLargeImage(src) {
const modal = document.createElement('div');
modal.classList.add('fixed', 'inset-0', 'bg-black', 'bg-opacity-75', 'flex', 'items-center', 'justify-center', 'z-50');
const img = document.createElement('img');
img.src = src;
img.classList.add('max-w-3xl', 'max-h-[90vh]', 'object-contain');
// 画像の向きを補正
applyImageOrientation(img);
modal.appendChild(img);
modal.onclick = () => modal.remove();
document.body.appendChild(modal);
}
// 画像の向きを取得して適用する関数
function applyImageOrientation(imgElement) {
return new Promise((resolve) => {
const img = new Image();
img.onload = function() {
// 仮想キャンバスを作成
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// EXIF情報を取得
EXIF.getData(img, function() {
const orientation = EXIF.getTag(this, 'Orientation') || 1;
let width = img.width;
let height = img.height;
// 向きに応じてキャンバスのサイズを調整
if (orientation > 4 && orientation < 9) {
canvas.width = height;
canvas.height = width;
} else {
canvas.width = width;
canvas.height = height;
}
// 向きに応じて回転を適用
switch (orientation) {
case 2: ctx.transform(-1, 0, 0, 1, width, 0); break;
case 3: ctx.transform(-1, 0, 0, -1, width, height); break;
case 4: ctx.transform(1, 0, 0, -1, 0, height); break;
case 5: ctx.transform(0, 1, 1, 0, 0, 0); break;
case 6: ctx.transform(0, 1, -1, 0, height, 0); break;
case 7: ctx.transform(0, -1, -1, 0, height, width); break;
case 8: ctx.transform(0, -1, 1, 0, 0, width); break;
default: break;
}
// 画像を描画
ctx.drawImage(img, 0, 0);
// 回転済みの画像をソースとして設定
imgElement.src = canvas.toDataURL();
resolve();
});
};
img.src = imgElement.src;
});
}
// 新規CP追加のための関数
function showAddCPDialog() {
const cpInput = prompt('追加するCPをカンマ区切りで入力してくださいCP1,CP2,CP3');
if (!cpInput) return;
const cpList = cpInput.split(',').map(cp => cp.trim()).filter(cp => cp);
if (cpList.length === 0) {
alert('有効なCPを入力してください');
return;
}
// 既存のチェックインデータを取得
const existingCheckins = getCurrentCheckins(); // 現在の表示データを取得する関数
const newCheckins = [];
//console.info('existingCheckins.length =',existingCheckins.length);
cpList.forEach((cp,index) => {
cploc = findLocationByCP(cp);
//console.info('location=',cploc);
//console.info('index = ',index);
newCheckins.push({
id: cploc.id,
order: existingCheckins.length + index + 1,
photos: cploc.photos,
actual_photo: '-',
subLocId: cploc.subLocId,
cp: cploc.cp,
location_name: cploc.name,
checkinPoint: Number(cploc.checkinPoint),
buyPoint: Number(cploc.buyPoint),
points: Number(cploc.checkinPoint),
buy_flag: false,
validate_location: true
});
});
//console.info('newCheckins=',newCheckins);
// 新しいCPを表に追加
addCheckinsToTable(newCheckins);
updatePathOrders();
calculatePoints(); // 総合ポイントの計算
}
// 新規CP追加用のモーダル
function showAddCPModal() {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center';
modal.innerHTML = `
<div class="bg-white p-6 rounded-lg w-96">
<h3 class="text-lg font-bold mb-4">新規CPを追加</h3>
<div class="space-y-4" id="cpInputs">
<div class="flex gap-2">
<input type="number" class="cp-input border rounded px-2 py-1 w-full" placeholder="CP番号">
</div>
</div>
<div class="flex justify-end mt-4 space-x-2">
<button onclick="addCPInput()" class="px-4 py-2 bg-blue-500 text-white rounded">
追加
</button>
<button onclick="saveCPs()" class="px-4 py-2 bg-green-500 text-white rounded">
保存
</button>
<button onclick="closeModal()" class="px-4 py-2 bg-gray-500 text-white rounded">
キャンセル
</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
function saveCPs() {
const inputs = document.querySelectorAll('.cp-input');
const eventCode = eventCodeSelect.value;
const newCheckins = Array.from(inputs)
.map(input => ({
cp_number: input.value,
create_at: null,
validate_location: false,
buy_flag: false,
points: 0
}));
// APIを呼び出して保存
fetch(`${API_BASE_URL}/checkins/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
zekken_number: currentZekkenNumber,
checkins: newCheckins
})
})
.then(response => response.json())
.then(data => {
loadTeamData(currentZekkenNumber,eventCode);
closeModal();
});
}
function closeModal() {
document.querySelector('.fixed').remove();
}
function updatePathOrders() {
const rows = Array.from(document.getElementById('checkinList').children);
rows.forEach((row, index) => {
//console.info('row=',row);
row.children[1].textContent = index + 1;
row.dataset.path_order = index + 1;
});
}
// 総合ポイントの計算
function calculatePoints(latePoints=0) {
//console.info('calculatePoints');
const rows = Array.from(document.getElementById('checkinList').children);
let totalPoints = 0; // チェックインポイントの合計をクリア
let cpPoints = 0; // チェックインポイントの合計をクリア
let buyPoints = 0; // 買い物ポイントの合計をクリア
// 各行のチェックインポイント及び買い物ポイントを合算
rows.forEach(row => {
const buybox = row.children[6].querySelector('input[type="checkbox"]');
const checkbox = row.children[7].querySelector('input[type="checkbox"]');
const point = parseInt(row.children[8].textContent);
if (checkbox) {
totalPoints += point;
if (buybox && buybox.checked) {
buyPoints += point;
}else{
cpPoints += point;
}
}
});
// 遅刻ポイントの計算=ゴール時刻がEventのゴール時刻を超えていたら分につき-50点を加算する。
if (latePoints===0){
latePoints = parseInt(document.getElementById('latePoints').textContent) || 0;
}
// 総合得点を計算
const finalPoints = totalPoints + latePoints;
// 判定を更新。順位を表示、ゴール時刻を15分経過したら失格
document.getElementById('totalPoints').textContent = cpPoints;
document.getElementById('buyPoints').textContent = buyPoints;
document.getElementById('latePoints').textContent = latePoints;
document.getElementById('finalPoints').textContent = finalPoints;
}
function formatDateTime(dateString) {
return new Date(dateString).toLocaleString('ja-JP');
}
// 保存機能の実装
async function saveChanges() {
const zekkenNumber = document.querySelector('#zekkenNumber').value;
const eventCode = document.querySelector('#eventCode').value;
const display = document.getElementById('goalTimeDisplay');
if (!display) {
console.error('Goal time elements not found');
return;
}
let goalTime = display.textContent;
console.info('Goal time = ',goalTime);
try {
const checkins = getCurrentCheckins(); // 現在の表示データを取得
const response = await fetch(`${API_BASE_URL}/update_checkins/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
zekken_number: zekkenNumber,
event_code: eventCode,
checkins: checkins
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const success = await saveGoalTime(goalTime, zekkenNumber, eventCode);
if (success) {
alert('ゴール時間を保存しました');
}
alert('保存が完了しました');
} catch (error) {
console.error('保存中にエラーが発生しました:', error);
alert('保存中にエラーが発生しました');
}
}
// ゴール時間の更新と新規作成を処理する関数
async function saveGoalTime(goalTimeStr, zekkenNumber, eventCode) {
try {
const [teamResponse, checkinsResponse] = await Promise.all([
fetch(`${API_BASE_URL}/team_info/${zekkenNumber}/`),
]);
// 各レスポンスのステータスを個別にチェック
if (!teamResponse.ok)
throw new Error(`Team info fetch failed with status ${teamResponse.status}`);
const teamData = await teamResponse.json();
const teamName = teamData.team_name;
// 日付と時刻を結合して完全な日時文字列を作成
//const currentDate = new Date().toISOString().split('T')[0];
//const fullGoalTime = `${currentDate}T${goalTimeStr}:00`;
const formattedDateTime = goalTimeStr
.replace(/\//g, '-') // スラッシュをハイフンに変換
.replace(' ', 'T'); // スペースをTに変換
//console.log(formattedDateTime); // "2024-10-26T12:59:13"
console.info('goaltime=',formattedDateTime);
// 新規レコードを作成または既存レコードを更新
const createResponse = await fetch('/api/update-goal-time/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
//'Authorization': `Token ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
goaltime: formattedDateTime, //fullGoalTime,
team_name: teamName,
event_code: eventCode,
zekken_number: zekkenNumber
})
});
if (!createResponse.ok) {
throw new Error('Failed to create new goal record');
}
return true;
} catch (error) {
console.error('Error processing goal time:', error);
alert(`ゴール時間の処理に失敗しました: ${error.message}`);
return false;
}
}
// プレビュー機能の実装
async function previewCertificate() {
const zekkenNumber = document.querySelector('#zekkenNumber').value;
const eventCode = document.querySelector('#eventCode').value;
if (!zekkenNumber || !eventCode) {
alert('ゼッケン番号とイベントコードを選択してください');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/export_excel/${zekkenNumber}/${eventCode}/`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// PDFファイルをBlobとして取得
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 新しいウィンドウでPDFをプレビュー表示印刷ダイアログは表示しない
const previewWindow = window.open(url, '_blank');
if (!previewWindow) {
// ポップアップがブロックされた場合の代替手段
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.click();
}
// メモリリークを防ぐためにURLを解放
setTimeout(() => {
window.URL.revokeObjectURL(url);
}, 10000);
} catch (error) {
console.error('プレビュー中にエラーが発生しました:', error);
alert('プレビュー中にエラーが発生しました: ' + error.message);
}
}
// 通過証明書出力・印刷機能の実装
async function exportExcel() {
const zekkenNumber = document.querySelector('#zekkenNumber').value;
const eventCode = document.querySelector('#eventCode').value;
if (!zekkenNumber || !eventCode) {
alert('ゼッケン番号とイベントコードを選択してください');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/export_excel/${zekkenNumber}/${eventCode}/`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// PDFファイルをBlobとして取得
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 新しいウィンドウでPDFを開いて印刷
const printWindow = window.open(url, '_blank');
// PDFが読み込まれた後に印刷ダイアログを表示
printWindow.addEventListener('load', function() {
setTimeout(() => {
printWindow.print();
}, 1000);
});
// 確認メッセージ表示
alert('証明書を印刷に送信しました。印刷ダイアログが表示されます。');
// メモリリークを防ぐためにURLを解放
setTimeout(() => {
window.URL.revokeObjectURL(url);
}, 10000);
} catch (error) {
console.error('印刷中にエラーが発生しました:', error);
alert('印刷中にエラーが発生しました: ' + error.message);
}
}
// エラーハンドリングのためのユーティリティ関数
function handlePrintError(error) {
console.error('印刷中にエラーが発生しました:', error);
alert('印刷中にエラーが発生しました。PDFを新しいタブで開きます。');
}
// 通過証明書出力機能の実装
async function exportExcel_old() {
const zekkenNumber = document.querySelector('#zekkenNumber').value;
const eventCode = document.querySelector('#eventCode').value;
try {
const response = await fetch(`${API_BASE_URL}/export_excel/${zekkenNumber}/${eventCode}/`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Blobとしてレスポンスを取得
const blob = await response.blob();
// ダウンロードリンクを作成
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `通過証明書_${zekkenNumber}_${eventCode}.xlsx`;
// リンクをクリックしてダウンロードを開始
document.body.appendChild(a);
a.click();
// クリーンアップ
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('エクスポート中にエラーが発生しました:', error);
alert('エクスポート中にエラーが発生しました');
}
}
function old_exportExcel() {
const zekkenNumber = document.getElementById('zekkenNumber').value;
window.location.href = `/api/export-excel/${zekkenNumber}`;
}
// 現在の表示データを取得する補助関数
function getCurrentCheckins() {
const tbody = document.querySelector('table tbody');
const checkins = [];
// テーブルの行をループして現在のデータを収集
tbody.querySelectorAll('tr').forEach((row, index) => {
const cells = row.cells;
// チェックボックスの要素を正しく取得
const buyFlagCheckbox = cells[6].querySelector('input[type="checkbox"]');
const validationCheckbox = cells[7].querySelector('input[type="checkbox"]');
checkins.push({
id: row.dataset.id,
order: index + 1,
cp_number: row.dataset.cpNumber,
buy_flag: buyFlagCheckbox ? buyFlagCheckbox.checked : false,
validation: validationCheckbox ? validationCheckbox.checked : false,
points: parseInt(cells[8].textContent) || 0
});
});
return checkins;
}
// テーブルにチェックインを追加する補助関数
function addCheckinsToTable(checkins) {
const table = document.querySelector('table tbody');
checkins.forEach(checkin => {
const row = document.createElement('tr');
//console.info('checkin=',checkin);
row.dataset.id = 0;
row.dataset.local_id = checkin.order; // Unique
row.dataset.cpNumber = checkin.cp;
row.dataset.checkinPoint = checkin.checkinPoint;
row.dataset.buyPoint = checkin.buyPoint;
row.dataset.path_order = checkin.order; // reorderable
const bgColor = checkin.buyPoint > 0 ? 'bg-blue-100' : '';
row.innerHTML = `
<td class="px-1 py-3 cursor-move">
<i class="fas fa-bars text-gray-400"></i>
</td>
<td class="px-2 py-3">${checkin.order}</td>
<td class="px-2 py-3">
${checkin.photos ?
`<img src="/media/compressed/${checkin.photos}" class="h-20 w-20 object-cover rounded" onclick="showLargeImage(this.src)">` : ''}
</td>
<td class="px-2 py-3">
${checkin.image_address ?
`<img src="${checkin.image_address}" class="h-20 w-20 object-cover rounded" onclick="showLargeImage(this.src)">` : '無し'}
</td>
<td class="px-2 py-3 ${bgColor}">
<div class="font-bold">${checkin.subLocId}</div>
<div class="text-sm">${checkin.location_name}</div>
</td>
<td class="px-2 py-3">不明</td>
<td class="px-1 py-3">
${checkin.buyPoint > 0 ? `
<input type="checkbox"
${checkin.buy_flag ? 'checked' : ''}
class="h-4 w-4 text-green-600 rounded buy-checkbox"
onchange="updatePoints(this)">
` : ''}
</td>
<td class="px-1 py-3">
<input type="checkbox"
${checkin.validate_location ? 'checked' : ''}
class="h-4 w-4 text-blue-600 rounded validate-checkbox"
onchange="updatePoints(this)">
</td>
<td class="px-2 py-3 point-value">${checkin.checkinPoint}</td>
<td class="px-2 py-4">
<button onclick="deleteRow(${row.dataset.local_id})"
class="text-red-600 hover:text-red-800">
<i class="fas fa-trash"></i>
</button>
</td>
`;
table.appendChild(row);
});
}
// グローバルな状態管理
let loadedLocations = [];
let currentEventCode = null;
// チェックポイントデータをロードする関数
async function loadLocations(eventCode) {
try {
//console.info('loadLocations-1:',eventCode);
if (!eventCode) {
console.error('Event code is required');
return;
}
// 既に同じイベントのデータがロードされている場合はスキップ
if (currentEventCode === eventCode && loadedLocations.length > 0) {
console.log('Locations already loaded for this event');
return loadedLocations;
}
//console.info('loadLocations-2:',eventCode);
// group__containsフィルターを使用してクエリパラメータを構築
const params = new URLSearchParams({
group__contains: eventCode
});
// Location APIを使用してデータを取得
const response = await fetch(`/api/location/?${params}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
// レスポンスをJSONとして解決
const data = await response.json();
//console.info('loadLocations-3:', data);
if (!response.ok) {
console.info('loadLocations-3: Bad Response :',response);
throw new Error(`HTTP error! status: ${response.status}`);
}
//console.info('loadLocations-4:',eventCode);
// 取得したデータを処理して保存
loadedLocations = data.features.map(feature => ({
cp: feature.properties.cp,
name: feature.properties.location_name,
subLocId: feature.properties.sub_loc_id,
checkinPoint: feature.properties.checkin_point,
buyPoint: feature.properties.buy_point,
photos: feature.properties.photos,
group: feature.properties.group,
coordinates: feature.geometry.coordinates[0],
latitude: feature.geometry.coordinates[0][1],
longitude: feature.geometry.coordinates[0][0]
})).filter(location => location.group && location.group.includes(eventCode));
currentEventCode = eventCode;
//console.info(`Loaded ${loadedLocations.length} locations for event ${eventCode}`);
//console.info('loadedLocation[0]=',loadedLocations[0]);
return loadedLocations;
} catch (error) {
console.error('Error loading locations:', error);
throw error;
}
}
// イベント選択時のハンドラー
async function handleEventSelect(eventCode) {
//console.info('handleEventSelect : ',eventCode);
try {
//document.getElementById('loading').style.display = 'block';
await loadLocations(eventCode);
//document.getElementById('loading').style.display = 'none';
} catch (error) {
console.error('Error handling event selection:', error);
//document.getElementById('loading').style.display = 'none';
alert('チェックポイントの読み込みに失敗しました。');
}
}
// ロードされたロケーションデータを取得する関数
function getLoadedLocations() {
return loadedLocations;
}
// 表示モード切り替え関数
function toggleDisplayMode(mode) {
const individualView = document.getElementById('individualView');
const rankingView = document.getElementById('rankingView');
if (mode === 'ranking') {
individualView.style.display = 'none';
rankingView.style.display = 'block';
} else {
individualView.style.display = 'block';
rankingView.style.display = 'none';
}
}
// 全参加者ランキング読み込み
async function loadParticipantsRanking(eventCode) {
try {
const response = await fetch(`${API_BASE_URL}/event-participants-ranking/?event_code=${eventCode}`, {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to load ranking data');
}
const data = await response.json();
displayParticipantsRanking(data);
} catch (error) {
console.error('Error loading participants ranking:', error);
alert('ランキングデータの読み込みに失敗しました');
}
}
// ランキングデータ表示
function displayParticipantsRanking(data) {
const classTabs = document.getElementById('classTabs');
const rankingList = document.getElementById('rankingList');
// クラス別タブの生成
classTabs.innerHTML = '';
let firstClass = true;
Object.keys(data.classes_ranking).forEach(className => {
const tab = document.createElement('button');
tab.className = `px-4 py-2 border-b-2 ${firstClass ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`;
tab.textContent = className;
tab.onclick = () => displayClassRanking(data.classes_ranking[className], className);
classTabs.appendChild(tab);
if (firstClass) {
displayClassRanking(data.classes_ranking[className], className);
firstClass = false;
}
});
}
// クラス別ランキング表示
function displayClassRanking(participants, className) {
const rankingList = document.getElementById('rankingList');
rankingList.innerHTML = '';
participants.forEach(participant => {
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50';
row.innerHTML = `
<td class="px-2 py-3">${participant.rank}</td>
<td class="px-2 py-3">${participant.zekken_number}</td>
<td class="px-4 py-3">${participant.team_name}</td>
<td class="px-2 py-3">${participant.category.class_name}</td>
<td class="px-2 py-3 text-green-600 font-semibold">${participant.points.confirmed_points}</td>
<td class="px-2 py-3 text-yellow-600">${participant.points.unconfirmed_points}</td>
<td class="px-2 py-3 text-blue-600 font-bold">${participant.points.total}</td>
<td class="px-2 py-3">
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-600 h-2 rounded-full" style="width: ${participant.checkin_status.confirmation_rate}%"></div>
</div>
<span class="text-xs text-gray-500">${participant.checkin_status.confirmation_rate}%</span>
</td>
<td class="px-2 py-3">
<button onclick="viewParticipantDetails('${participant.zekken_number}')"
class="px-2 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600">
詳細
</button>
</td>
`;
rankingList.appendChild(row);
});
}
// 参加者詳細表示
function viewParticipantDetails(zekkenNumber) {
document.getElementById('zekkenNumber').value = zekkenNumber;
document.getElementById('displayMode').value = 'individual';
toggleDisplayMode('individual');
const eventCode = document.getElementById('eventCode').value;
loadTeamData(zekkenNumber, eventCode);
}
// 写真一括アップロード処理
async function handleBulkUpload(files) {
const eventCode = document.getElementById('eventCode').value;
const zekkenNumber = document.getElementById('zekkenNumber').value;
if (!eventCode || !zekkenNumber || zekkenNumber === 'ALL') {
alert('イベントコードと個別のゼッケン番号を選択してください');
return;
}
const formData = new FormData();
formData.append('event_code', eventCode);
formData.append('zekken_number', zekkenNumber);
for (let i = 0; i < files.length; i++) {
formData.append('images', files[i]);
}
try {
const response = await fetch(`${API_BASE_URL}/bulk-upload-photos/`, {
method: 'POST',
headers: {
'Authorization': `Token ${sessionStorage.getItem('authToken')}`
},
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
alert(`アップロード完了: ${result.summary.created_checkins}件のチェックインが作成されました`);
// データを再読み込み
loadTeamData(zekkenNumber, eventCode);
} catch (error) {
console.error('Error uploading photos:', error);
alert('写真のアップロードに失敗しました');
}
}
// 一括確定・否認処理
async function bulkUpdateValidation(validationStatus) {
const checkinList = document.getElementById('checkinList');
const checkedBoxes = checkinList.querySelectorAll('input[type="checkbox"][data-checkin-id]:checked');
if (checkedBoxes.length === 0) {
alert('対象のチェックインを選択してください');
return;
}
const checkinIds = Array.from(checkedBoxes).map(cb => parseInt(cb.dataset.checkinId));
try {
const response = await fetch(`${API_BASE_URL}/confirm-checkin-validation/`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
checkin_ids: checkinIds,
validation_status: validationStatus
})
});
if (!response.ok) {
throw new Error('Validation update failed');
}
const result = await response.json();
alert(`${result.updated_checkins.length}件の通過審査を更新しました`);
// データを再読み込み
const eventCode = document.getElementById('eventCode').value;
const zekkenNumber = document.getElementById('zekkenNumber').value;
loadTeamData(zekkenNumber, eventCode);
} catch (error) {
console.error('Error updating validation:', error);
alert('通過審査の更新に失敗しました');
}
}
// ゼッケン番号読み込みALLオプション付き
function loadZekkenNumbers(eventCode) {
const zekkenSelect = document.getElementById('zekkenNumber');
zekkenSelect.innerHTML = '<option value="">ゼッケン番号を選択</option>';
// eventCodeがnullまたは空文字列でも処理を続行
console.log('Loading zekken numbers for eventCode:', eventCode);
fetch(`${API_BASE_URL}/event-zekken-list/`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
event_code: eventCode
})
})
.then(response => response.json())
.then(data => {
console.log('Zekken list response:', data);
if (data.status === 'success') {
data.zekken_options.forEach(option => {
const optionElement = document.createElement('option');
optionElement.value = option.value;
optionElement.textContent = option.label;
zekkenSelect.appendChild(optionElement);
});
} else {
console.error('Error in zekken list response:', data);
}
})
.catch(error => {
console.error('Error loading zekken numbers:', error);
});
}
// 指定されたCP番号のロケーションを取得する関数
function findLocationByCP(cpNumber) {
if (!loadedLocations.length) {
console.warn('Locations not loaded yet');
return null;
}
// cpプロパティを数値として比較
const found = loadedLocations.find(loc => Number(loc.cp) === Number(cpNumber));
if (!found) {
console.warn(`Location with CP number ${cpNumber} not found`);
return null;
}
//console.info(`Found location with CP ${cpNumber}:`, found);
return found;
}
// ページ読み込み時の初期化
document.addEventListener('DOMContentLoaded', function() {
checkAuth();
});
</script>
</body>
</html>