ミカタ塾
学習管理システム
👨‍🎓 生徒ログイン
IDまたはパスワードが違います
パスワードを忘れた場合は先生にお問い合わせください
© 地域教育工房 ミカタ塾
ミカタ塾
// ========= Supabase初期化 ========= const SURL = 'https://ralhipldnnnddfmooypq.supabase.co'; const SKEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJhbGhpcGxkbm5uZGRmbW9veXBxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODAwMTg3MjIsImV4cCI6MjA5NTU5NDcyMn0.g7kBXhKoANaJjlaP2Wcj7CSsW1hQstlL1cRqzeZPt1M'; const sb = supabase.createClient(SURL, SKEY); let loginMode = 'student'; let currentUser = null; let currentTab = 'work'; let teacherTab = 'list'; let students = []; let myPhases = []; let selStudentId = null; let selStudentPhases = []; // 先生の生徒詳細画面用 let detailStudentId = null; let detailStudent = null; let detailPhases = []; let detailDailyRecords = {}; // date -> [{task, rec}] let detailCalYear = new Date().getFullYear(); let detailCalMonth = new Date().getMonth(); let detailSelDate = null; const TODAY = new Date(); const MAT = { 'aim@診断テスト': {bg:'#E6F1FB', color:'#185FA5', type:'flow'}, 'aim@定テ対策': {bg:'#E6F1FB', color:'#185FA5', type:'rounds'}, 'atama+': {bg:'#FEE9E7', color:'#C0392B', type:'units'}, 'eboard': {bg:'#E1F5EE', color:'#0F6E56', type:'units'}, '学校ワーク': {bg:'#F0EDFF', color:'#534AB7', type:'rounds'}, 'プリント': {bg:'#FAEEDA', color:'#854F0B', type:'units'}, 'その他': {bg:'#F5F5F5', color:'#666', type:'units'}, }; const PHASE_COLORS = ['#1D9E75','#185FA5','#D85A30','#7F77DD','#BA7517']; const GRP = [ {label:'英語・数学', subjects:['英語','数学'], color:'#185FA5', bg:'#E6F1FB', tc:'#185FA5'}, {label:'理科・社会', subjects:['理科','社会'], color:'#1D9E75', bg:'#E1F5EE', tc:'#0F6E56'}, ]; // 複数ページ範囲対応ヘルパー function getPageRanges(task) { if (task.page_ranges && Array.isArray(task.page_ranges) && task.page_ranges.length>0) { return task.page_ranges.filter(r=>r.from!=null && r.to!=null); } return []; } function getTotalPages(task) { return getPageRanges(task).reduce((sum,r)=>{ if(r.from!=null && r.to!=null && r.to>=r.from) return sum+(r.to-r.from+1); return sum; },0); } function getActualPages(round) { if (!round) return 0; const ranges = (round.actual_ranges && Array.isArray(round.actual_ranges)) ? round.actual_ranges : []; return ranges.reduce((sum,r)=>{ if(r && r.from!=null && r.to!=null && r.to>=r.from) return sum+(r.to-r.from+1); return sum; },0); } function pageRangesLabel(task) { const ranges = getPageRanges(task); if (ranges.length===0) return ''; return ranges.map(r=>'p.'+r.from+'〜'+r.to).join('、'); } function dayDiff(ds){ return Math.round((new Date(ds)-TODAY)/(1000*60*60*24)); } function diffBadge(ds){ if(!ds) return ''; const d=dayDiff(ds); const cls=d<0?'br':d<=7?'ba':'bt'; return ''+(d<0?('期限'+Math.abs(d)+'日超過'):d===0?'今日まで':('残り'+d+'日'))+''; } // ========= ログイン ========= function setMode(mode) { loginMode = mode; document.getElementById('tab-student').classList.toggle('active', mode==='student'); document.getElementById('tab-teacher').classList.toggle('active', mode==='teacher'); document.getElementById('login-title').textContent = mode==='student' ? '👨‍🎓 生徒ログイン' : '👨‍🏫 先生ログイン'; document.getElementById('inp-id').placeholder = mode==='student' ? 'IDを入力' : '先生IDを入力'; const btn = document.getElementById('login-btn'); btn.className = 'btn-login ' + (mode==='student' ? 'btn-student' : 'btn-teacher'); document.getElementById('login-err').classList.remove('show'); } function togglePw() { const i = document.getElementById('inp-pw'); i.type = i.type === 'password' ? 'text' : 'password'; } async function doLogin() { const id = (document.getElementById('inp-id').value || '').trim(); const pw = document.getElementById('inp-pw').value || ''; const btn = document.getElementById('login-btn'); const status = document.getElementById('login-status'); const err = document.getElementById('login-err'); if (!id || !pw) { status.textContent = 'IDとパスワードを入力してください'; return; } btn.textContent = 'ログイン中...'; btn.disabled = true; status.textContent = 'Supabaseに接続中...'; err.classList.remove('show'); try { status.textContent = 'ユーザー情報を取得中...'; const result = await sb.from('users').select('*').eq('login_id', id).eq('role', loginMode).limit(1); if (result.error) { status.textContent = 'DBエラー: ' + result.error.message; btn.textContent='ログイン'; btn.disabled=false; return; } if (!result.data || result.data.length === 0) { err.classList.add('show'); status.textContent=''; btn.textContent='ログイン'; btn.disabled=false; return; } const user = result.data[0]; if (user.password_hash !== pw) { err.classList.add('show'); status.textContent=''; btn.textContent='ログイン'; btn.disabled=false; return; } currentUser = user; status.textContent = 'データを読み込み中...'; await loadAndShow(); } catch(e) { status.textContent = 'エラー: ' + e.message; btn.textContent='ログイン'; btn.disabled=false; } } // ========= aim@フロー関数 ========= function initFlowState() { return { test: [[{t1:'not',t2:'not'},{t1:'not',t2:'not'}],[{t1:'not',t2:'not'},{t1:'not',t2:'not'}]], weak: [[{t1:'not',t2:'not'},{t1:'not',t2:'not'}],[{t1:'not',t2:'not'},{t1:'not',t2:'not'}]], clr: [[{t1:'not',t2:'not'},{t1:'not',t2:'not'}],[{t1:'not',t2:'not'},{t1:'not',t2:'not'}]], conf: [[{pattern:null,done:false,history:[]},{pattern:null,done:false,history:[]}],[{pattern:null,done:false,history:[]},{pattern:null,done:false,history:[]}]], }; } function flowPct(fs) { if(!fs || !fs.clr || !Array.isArray(fs.clr)) return 0; let c=0,t=0; GRP.forEach((g,gi)=>g.subjects.forEach((_,si)=>{ t+=2; if(fs.clr[gi][si].t1==='yes')c++; if(fs.clr[gi][si].t2==='yes')c++; })); return t>0?Math.round(c/t*100):0; } function grpUnlocked(fs,gi){ if(gi===0)return true; return GRP[0].subjects.every((_,si)=>subjDone(fs,0,si)); } function subjDone(fs,gi,si){ const c1=fs.clr[gi][si].t1,c2=fs.clr[gi][si].t2; if(c1==='not'||c2==='not')return false; if(c1==='yes'&&c2==='yes')return true; return fs.conf[gi][si].done; } function flowUnlocked(fs,gi,step){ if(!grpUnlocked(fs,gi))return false; const g=GRP[gi]; if(step===0)return true; if(step===1)return g.subjects.every((_,si)=>fs.test[gi][si].t1==='done'); if(step===2)return g.subjects.every((_,si)=>fs.weak[gi][si].t1==='done'); if(step===3)return g.subjects.every((_,si)=>fs.clr[gi][si].t1!=='not'); if(step===4)return g.subjects.every((_,si)=>fs.test[gi][si].t2==='done'); if(step===5)return g.subjects.every((_,si)=>fs.weak[gi][si].t2==='done'); if(step===6)return g.subjects.every((_,si)=>fs.clr[gi][si].t1!=='not'&&fs.clr[gi][si].t2!=='not')&&g.subjects.some((_,si)=>!(fs.clr[gi][si].t1==='yes'&&fs.clr[gi][si].t2==='yes')); return false; } function flowBadge(fs,gi,step){ const g=GRP[gi]; if(!flowUnlocked(fs,gi,step))return{l:'ロック中',bg:'#F3F4F6',c:'#6B7280'}; const dT=(tk)=>g.subjects.every((_,si)=>fs.test[gi][si][tk]==='done'); const dW=(tk)=>g.subjects.every((_,si)=>fs.weak[gi][si][tk]==='done'); const jd=(tk)=>g.subjects.every((_,si)=>fs.clr[gi][si][tk]!=='not'); if(step===0)return dT('t1')?{l:'受験完了',bg:'#E1F5EE',c:'#0F6E56'}:{l:'取り組み中',bg:'#E6F1FB',c:'#185FA5'}; if(step===1)return dW('t1')?{l:'完了',bg:'#E1F5EE',c:'#0F6E56'}:{l:'取り組み中',bg:'#E6F1FB',c:'#185FA5'}; if(step===2){if(!jd('t1'))return{l:'判定中',bg:'#E6F1FB',c:'#185FA5'};return g.subjects.every((_,si)=>fs.clr[gi][si].t1==='yes')?{l:'全員クリア',bg:'#E1F5EE',c:'#0F6E56'}:{l:'未クリアあり',bg:'#FAEEDA',c:'#854F0B'};} if(step===3)return dT('t2')?{l:'受験完了',bg:'#E1F5EE',c:'#0F6E56'}:{l:'取り組み中',bg:'#E6F1FB',c:'#185FA5'}; if(step===4)return dW('t2')?{l:'完了',bg:'#E1F5EE',c:'#0F6E56'}:{l:'取り組み中',bg:'#E6F1FB',c:'#185FA5'}; if(step===5){if(!jd('t2'))return{l:'判定中',bg:'#E6F1FB',c:'#185FA5'};return g.subjects.every((_,si)=>fs.clr[gi][si].t2==='yes')?{l:'全員クリア',bg:'#E1F5EE',c:'#0F6E56'}:{l:'未クリアあり',bg:'#FAEEDA',c:'#854F0B'};} if(step===6)return g.subjects.every((_,si)=>subjDone(fs,gi,si))?{l:'完了',bg:'#E1F5EE',c:'#0F6E56'}:{l:'確認フェーズ中',bg:'#F0EDFF',c:'#534AB7'}; return{l:'',bg:'',c:''}; } function flowHint(fs,gi){ const g=GRP[gi]; if(!grpUnlocked(fs,gi))return gi===1?'英語・数学を全教科クリアすると解放されます':''; if(g.subjects.some((_,si)=>fs.test[gi][si].t1==='not'))return '全員が第1回力試しテストを受験しましょう'; if(g.subjects.some((_,si)=>fs.weak[gi][si].t1!=='done'))return '第1回テスト後の苦手リストに取り組みましょう(必ず実施)'; if(g.subjects.some((_,si)=>fs.clr[gi][si].t1==='not'))return '苦手リスト完了後、第1回のクリア判定を入力してください'; if(g.subjects.some((_,si)=>fs.test[gi][si].t2==='not'))return '全員が第2回力試しテストを受験しましょう'; if(g.subjects.some((_,si)=>fs.weak[gi][si].t2!=='done'))return '第2回テスト後の苦手リストに取り組みましょう(必ず実施)'; if(g.subjects.some((_,si)=>fs.clr[gi][si].t2==='not'))return '第2回のクリア判定を入力してください'; if(g.subjects.every((_,si)=>subjDone(fs,gi,si)))return gi===0?'✅ 英語・数学クリア!理科・社会が解放されました':'✅ 全教科クリア!お疲れ様でした'; return '確認フェーズに進んでください(先生の指示を確認)'; } function initConf(fs,gi,si){ const c1=fs.clr[gi][si].t1,c2=fs.clr[gi][si].t2; if(c1==='yes'&&c2==='yes'){fs.conf[gi][si]={pattern:null,done:true,history:[]};return;} if(c2==='yes'){fs.conf[gi][si]={pattern:'①',done:true,history:[]};return;} let pat,fl; if(c1==='yes'&&c2==='no'){pat='②';fl='第2回 確認テスト';} else if(c1==='no'&&c2==='yes'){pat='③';fl='第1回 確認テスト';} else{pat='④';fl='第1回 確認テスト';} fs.conf[gi][si]={pattern:pat,done:false,history:[{type:pat==='②'?'conf2':'conf1',label:fl,test:'not',weak:'not',result:'not'}]}; } function addConfStep(fs,gi,si,idx,outcome){ const c=fs.conf[gi][si];const pat=c.pattern;const cur=c.history[idx]; if(outcome==='cleared'){ if(pat==='②'){if(cur.type==='conf2'||cur.type==='trial2'){c.done=true;return;}} if(pat==='③'){if(cur.type==='conf1'||cur.type==='trial1'){c.done=true;return;}} if(pat==='④'){ if(cur.type==='conf1'){c.history.push({type:'conf2',label:'第2回 確認テスト',test:'not',weak:'not',result:'not'});return;} if(cur.type==='conf2'||cur.type==='trial2'){c.done=true;return;} if(cur.type==='trial1'){c.history.push({type:'conf2',label:'第2回 確認テスト(③へ)',test:'not',weak:'not',result:'not'});return;} } c.done=true; } else { if(pat==='②'){ if(cur.type==='conf2')c.history.push({type:'trial2',label:'第2回 力試しテスト',test:'not',weak:'not',result:'not'}); else c.history.push({type:'conf2',label:'第2回 確認テスト(①に戻る)',test:'not',weak:'not',result:'not'}); } if(pat==='③'){ if(cur.type==='conf1')c.history.push({type:'trial1',label:'第1回 力試しテスト',test:'not',weak:'not',result:'not'}); else c.history.push({type:'conf1',label:'第1回 確認テスト(②に戻る)',test:'not',weak:'not',result:'not'}); } if(pat==='④'){ if(cur.type==='conf1')c.history.push({type:'trial1',label:'第1回 力試しテスト(④に戻る)',test:'not',weak:'not',result:'not'}); else if(cur.type==='conf2')c.history.push({type:'trial2',label:'第2回 力試しテスト',test:'not',weak:'not',result:'not'}); else if(cur.type==='trial2')c.history.push({type:'conf2',label:'第2回 確認テスト(③に戻る)',test:'not',weak:'not',result:'not'}); else c.history.push({type:'conf1',label:'第1回 確認テスト(④に戻る)',test:'not',weak:'not',result:'not'}); } } } async function saveFlowState(pi,ti) { const task = myPhases[pi].tasks[ti]; renderContent(); await sb.from('instruction_tasks').update({flow_state: task.flow_state}).eq('id', task.id); } function renderFlow(pi,ti,task) { const fs = task.flow_state; let h=''; GRP.forEach((g,gi)=>{ if(gi>0){ const ul=grpUnlocked(fs,gi); h+='
↓ '+g.label+'('+(ul?'解放済':'英数クリア後')+')
'; } const steps=[ {n:1,label:'第1回 力試しテスト',body:fbTest(pi,ti,fs,gi,'t1')}, {n:2,label:'第1回 苦手リストに取り組む(必ず実施)',body:fbWeak(pi,ti,fs,gi,'t1')}, {n:3,label:'第1回 クリア判定',body:fbClear(pi,ti,fs,gi,'t1')}, {n:4,label:'第2回 力試しテスト',body:fbTest(pi,ti,fs,gi,'t2')}, {n:5,label:'第2回 苦手リストに取り組む(必ず実施)',body:fbWeak(pi,ti,fs,gi,'t2')}, {n:6,label:'第2回 クリア判定',body:fbClear(pi,ti,fs,gi,'t2')}, {n:7,label:'確認フェーズ(未クリア教科のみ)',body:fbConf(pi,ti,fs,gi)}, ]; steps.forEach((s,si)=>{ const lk=!flowUnlocked(fs,gi,si);const b=flowBadge(fs,gi,si); h+='
' +'
' +'
'+s.n+'
' +''+s.label+'' +''+b.l+'' +'
' +(lk?'':('
'+s.body+'
')) +'
'; }); // グループごとの進捗を計算(そのグループのみ) var gPct = 0; if (grpUnlocked(fs, gi)) { var gC=0, gT=0; g.subjects.forEach(function(_,si){ gT+=2; if(fs.clr[gi][si].t1==='yes')gC++; if(fs.clr[gi][si].t2==='yes')gC++; }); gPct = gT>0 ? Math.round(gC/gT*100) : 0; } const p=gPct; h+='
'+g.label+' クリア進捗'+p+'%
'; const hint=flowHint(fs,gi); if(hint)h+='
→ '+hint+'
'; }); return h; } function fbTest(pi,ti,fs,gi,tk){ return GRP[gi].subjects.map(function(subj,si){ var v=fs.test[gi][si][tk]; var s1="myPhases["+pi+"].tasks["+ti+"].flow_state.test["+gi+"]["+si+"]['"+tk+"']='done';saveFlowState("+pi+","+ti+")"; var s2="myPhases["+pi+"].tasks["+ti+"].flow_state.test["+gi+"]["+si+"]['"+tk+"']='not';saveFlowState("+pi+","+ti+")"; return '
'+subj+'
' +'' +(v==='done'?(''):'') +'
'; }).join(''); } function fbWeak(pi,ti,fs,gi,tk){ return '
テスト後は正答率に関わらず必ず取り組んでください
'+GRP[gi].subjects.map(function(subj,si){ var v=fs.weak[gi][si][tk]; var s1="myPhases["+pi+"].tasks["+ti+"].flow_state.weak["+gi+"]["+si+"]['"+tk+"']='wip';saveFlowState("+pi+","+ti+")"; var s2="myPhases["+pi+"].tasks["+ti+"].flow_state.weak["+gi+"]["+si+"]['"+tk+"']='done';saveFlowState("+pi+","+ti+")"; var s3="myPhases["+pi+"].tasks["+ti+"].flow_state.weak["+gi+"]["+si+"]['"+tk+"']='not';saveFlowState("+pi+","+ti+")"; return '
'+subj+'
' +'' +'' +(v!=='not'?(''):'') +'
'; }).join(''); } function fbClear(pi,ti,fs,gi,tk){ return '
苦手リスト完了後、目標正答率('+GRP[gi].level+')をクリアできたか記録してください
'+GRP[gi].subjects.map(function(subj,si){ var v=fs.clr[gi][si][tk]; var s1="setClear("+pi+","+ti+","+gi+","+si+",'"+tk+"','yes')"; var s2="setClear("+pi+","+ti+","+gi+","+si+",'"+tk+"','no')"; var s3="resetClear("+pi+","+ti+","+gi+","+si+",'"+tk+"')"; return '
'+subj+'
' +'' +'' +(v!=='not'?(''):'') +'
'; }).join(''); } function setClear(pi,ti,gi,si,tk,val){ var fs=myPhases[pi].tasks[ti].flow_state; fs.clr[gi][si][tk]=val; initConf(fs,gi,si); saveFlowState(pi,ti); } function resetClear(pi,ti,gi,si,tk){ var fs=myPhases[pi].tasks[ti].flow_state; fs.clr[gi][si][tk]='not'; fs.conf[gi][si]={pattern:null,done:false,history:[]}; saveFlowState(pi,ti); } function fbConf(pi,ti,fs,gi){ var g=GRP[gi]; var h='
力試しテストで未クリアの教科のみ確認フェーズに進みます
'; g.subjects.forEach(function(subj,si){ var c=fs.conf[gi][si];var c1=fs.clr[gi][si].t1,c2=fs.clr[gi][si].t2; if((c1==='yes'&&c2==='yes')||c.done){h+='
'+subj+'✅ 完了
';return;} var pL={'②':'② 第1回クリア・第2回未クリア','③':'③ 第1回未クリア・第2回クリア','④':'④ 両方未クリア'}; var pC={'②':'#854F0B','③':'#534AB7','④':'#A32D2D'}; var pBg={'②':'#FAEEDA','③':'#F0EDFF','④':'#FCEBEB'}; h+='
'+subj+':'+(pL[c.pattern]||'')+'
'+fbConfHistory(pi,ti,gi,si,c)+'
'; }); return h; } function fbConfHistory(pi,ti,gi,si,c){ if(!c.history||!c.history.length)return '
先生の指示を確認してください
'; return c.history.map(function(h,idx){ var prev=idx===0||c.history[idx-1].result!=='not'; var lp=c.history.filter(function(x,i){return i<=idx&&x.type===h.type;}).length; var s1="confSetField("+pi+","+ti+","+gi+","+si+","+idx+",'test','done')"; var s2="confSetField("+pi+","+ti+","+gi+","+si+","+idx+",'weak','wip')"; var s3="confSetField("+pi+","+ti+","+gi+","+si+","+idx+",'weak','done')"; var s4="confResult("+pi+","+ti+","+gi+","+si+","+idx+",'yes')"; var s5="confResult("+pi+","+ti+","+gi+","+si+","+idx+",'no')"; return '
' +'
'+(idx+1)+'
' +'
' +'
'+h.label+(lp>1?(' '+lp+'回目'):'')+'
' +'
' +'' +(h.test==='done'?(''):'') +((h.weak==='done'&&h.result==='not')?(''):'') +(h.result==='yes'?'✅ クリア':'') +(h.result==='no'?'❌ 未クリア → 次へ':'') +'
'; }).join(''); } function confSetField(pi,ti,gi,si,idx,field,val){ myPhases[pi].tasks[ti].flow_state.conf[gi][si].history[idx][field]=val; saveFlowState(pi,ti); } function confResult(pi,ti,gi,si,idx,result){ var fs=myPhases[pi].tasks[ti].flow_state; fs.conf[gi][si].history[idx].result=result; addConfStep(fs,gi,si,idx,result==='yes'?'cleared':'failed'); saveFlowState(pi,ti); } // ========= 生徒画面:指示書 ========= function taskPct(task) { const mt = (MAT[task.material] && MAT[task.material].type) || 'units'; if (mt === 'flow') return flowPct(task.flow_state); if (mt === 'rounds') { const rs = task.rounds || []; if (!rs.length) return 0; const totalPages = getTotalPages(task); if (totalPages>0) { let target = rs.find(r=>r.status==='wip'); if (!target) target = rs.slice().reverse().find(r=>r.status!=='not') || rs[0]; const actP = getActualPages(target); return Math.min(100, Math.round(actP/totalPages*100)); } const done = rs.filter(r=>r.status==='done').length; return Math.round(done/rs.length*100); } return task.total_units > 0 ? Math.min(100, Math.round((task.done_units||0)/task.total_units*100)) : 0; } function renderInstrStudent() { if (!myPhases || myPhases.length === 0) { return '
📋
指示書がありません
先生から指示書が届くとここに表示されます
'; } const allTasks = myPhases.flatMap(p=>p.tasks||[]); const avg = allTasks.length ? Math.round(allTasks.reduce((a,t)=>a+taskPct(t),0)/allTasks.length) : 0; let h = '
' +'
学習メニュー
' +'
'+avg+'%
' +'
'; myPhases.forEach((phase,pi)=>{ const tasks = phase.tasks||[]; const pp = tasks.length ? Math.round(tasks.reduce((a,t)=>a+taskPct(t),0)/tasks.length) : 0; h += '
' +'
' +'
' +''+phase.phase_name+'' +'
' +''+pp+'%' +''+(phase.open?'▲':'▼')+'' +'
'; if (phase.open) { h += '
'; tasks.forEach((task,ti)=>{ h += renderStudentTask(pi, ti, task, phase); }); h += '
'; } h += '
'; }); return h; } function renderStudentTask(pi, ti, task, phase) { const ms = MAT[task.material] || {bg:'#eee',color:'#333'}; const mt = (MAT[task.material] && MAT[task.material].type) || 'units'; const pct = taskPct(task); let body = ''; if (mt === 'flow') { body = '
'+pct+'%
' +'' +(task.flowOpen ? ('
📋 aim@ 診断テスト 取り組みフロー
'+renderFlow(pi,ti,task)+'
') : ''); } else if (mt === 'rounds') { const rs = task.rounds || []; const ranges = getPageRanges(task); const totalPages = getTotalPages(task); let curLabel = '1周目 未着手'; const ai = rs.findIndex(r=>r.status==='wip'); if (ai>=0) curLabel = (ai+1)+'周目 進行中'; else if (rs.length && rs.every(r=>r.status==='done')) curLabel = '全周完了'; const curCls = curLabel.includes('完了')?'bt':curLabel.includes('進行')?'bb':'bg'; body = '
'; if (ranges.length>0) body += '
範囲:'+pageRangesLabel(task)+'(全'+totalPages+'ページ)
'; body += '
周回管理'+curLabel+'
' +rs.map((r,ri)=>{ const isLocked = ri>0 && rs[ri-1].status==='not'; const rc = r.status==='done'?'#1D9E75':r.status==='wip'?'#185FA5':'#9CA3AF'; const actP = getActualPages(r); const calcPct = totalPages>0 ? Math.min(100,Math.round(actP/totalPages*100)) : (r.completion_rate||0); // actual_rangesから最小from〜最大toを計算して単一範囲として表示 var rawRanges = (r.actual_ranges && Array.isArray(r.actual_ranges) && r.actual_ranges.length>0) ? r.actual_ranges : []; var dispFrom = null, dispTo = null; rawRanges.forEach(function(rr){ if(rr.from!=null){ if(dispFrom===null||rr.fromdispTo) dispTo=rr.to; } }); // actual_from/actual_to も考慮(DBに直接保存されている場合) if(r.actual_from!=null){ if(dispFrom===null||r.actual_fromdispTo) dispTo=r.actual_to; } const actualRanges = (dispFrom!=null||dispTo!=null) ? [{from:dispFrom,to:dispTo}] : [{from:null,to:null}]; let inner = '
' +'
' +''+(ri+1)+'周目' +'
' +''+calcPct+'%' +'
' +'' +'' +'' +'
'; if (r.status!=='not') { inner += '
' +'
実績ページ'+(actP>0?' ('+actP+'p)':'')+'
' +actualRanges.map((ar,ai2)=>{ const planR = ranges[ai2] ? 'p.'+ranges[ai2].from+'〜'+ranges[ai2].to : ''; return '
' +(planR?''+planR+'':'') +'' +'' +'' +(actualRanges.length>1?'':'') +'
'; }).join('') +'' +'
'; } inner += '
'; return inner; }).join('') +'
' +rs.map((r,ri)=>''+(ri+1)+'周目 '+(r.status==='done'?'✅完了':r.status==='wip'?'🔶進行中':'⬜未着手')+'').join('') +'
'; } else { body = '
' +'完了単元数' +'' +'/ '+(task.total_units||0)+'単元' +'
' +''+pct+'%' +'
'; } return '
' +'
' +''+task.material+'' +''+task.subject+'' +diffBadge(task.deadline) +'
' +'
'+(task.range_text||'')+'
' +(task.note?'
📌 '+task.note+'
':'') +body+'
'; } function toggleFlow(pi,ti) { myPhases[pi].tasks[ti].flowOpen = !myPhases[pi].tasks[ti].flowOpen; renderContent(); } async function updateDoneUnits(pi,ti,val) { const task = myPhases[pi].tasks[ti]; task.done_units = parseFloat(val)||0; renderContent(); await sb.from('instruction_tasks').update({done_units: task.done_units}).eq('id', task.id); } async function setRoundStatus(pi,ti,ri,status) { const task = myPhases[pi].tasks[ti]; const round = task.rounds[ri]; round.status = status; renderContent(); await sb.from('exam_prep_rounds').update({status}).eq('id', round.id); } async function updateRoundField(pi,ti,ri,field,val) { const task = myPhases[pi].tasks[ti]; const round = task.rounds[ri]; const v = field==='completion_rate' ? parseInt(val) : (val===''?null:parseInt(val)); round[field] = v; renderContent(); const upd = {}; upd[field]=v; await sb.from('exam_prep_rounds').update(upd).eq('id', round.id); } function ensureActualRanges(round) { if (!round.actual_ranges || !Array.isArray(round.actual_ranges) || round.actual_ranges.length===0) { round.actual_ranges = [{from:null, to:null}]; } return round.actual_ranges; } async function updateActualRange(pi,ti,ri,rangeIndex,field,val) { const task = myPhases[pi].tasks[ti]; const round = task.rounds[ri]; const ranges = ensureActualRanges(round); const v = val===''?null:parseInt(val); ranges[rangeIndex][field] = v; round.actual_ranges = ranges; renderContent(); await sb.from('exam_prep_rounds').update({actual_ranges: ranges}).eq('id', round.id); } async function addActualRange(pi,ti,ri) { const task = myPhases[pi].tasks[ti]; const round = task.rounds[ri]; const ranges = ensureActualRanges(round); ranges.push({from:null, to:null}); round.actual_ranges = ranges; renderContent(); await sb.from('exam_prep_rounds').update({actual_ranges: ranges}).eq('id', round.id); } async function removeActualRange(pi,ti,ri,rangeIndex) { const task = myPhases[pi].tasks[ti]; const round = task.rounds[ri]; const ranges = ensureActualRanges(round); ranges.splice(rangeIndex,1); if (ranges.length===0) ranges.push({from:null,to:null}); round.actual_ranges = ranges; renderContent(); await sb.from('exam_prep_rounds').update({actual_ranges: ranges}).eq('id', round.id); } // ========= データ読み込み ========= async function loadMyInstructions() { const {data:phases} = await sb.from('instruction_phases').select('*').eq('user_id', currentUser.id).order('phase_order'); const phaseList = phases || []; for (const p of phaseList) { const {data:tasks} = await sb.from('instruction_tasks').select('*').eq('phase_id', p.id).order('task_order'); p.tasks = tasks || []; p.open = true; for (const t of p.tasks) { if (!t.page_ranges || !Array.isArray(t.page_ranges)) t.page_ranges = []; if (!t.flow_state || typeof t.flow_state !== 'object' || Object.keys(t.flow_state).length === 0) { t.flow_state = initFlowState(); } if (MAT[t.material] && MAT[t.material].type === 'rounds') { const {data:rounds} = await sb.from('exam_prep_rounds').select('*').eq('task_id', t.id).order('round_number'); t.rounds = (rounds || []).map(r => { if (!r.actual_ranges || !Array.isArray(r.actual_ranges)) r.actual_ranges = []; return r; }); } } } // 空フェーズは除外 myPhases = phaseList.filter(p => p.tasks && p.tasks.length > 0); } async function loadStudentInstructions(uid) { const {data:phases} = await sb.from('instruction_phases').select('*').eq('user_id', uid).order('phase_order'); const phaseList = phases || []; for (const p of phaseList) { const {data:tasks} = await sb.from('instruction_tasks').select('*').eq('phase_id', p.id).order('task_order'); p.tasks = tasks || []; p.open = true; for (const t of p.tasks) { if (!t.page_ranges || !Array.isArray(t.page_ranges)) t.page_ranges = []; if (!t.flow_state) t.flow_state = {}; if (MAT[t.material] && MAT[t.material].type === 'rounds') { const {data:rounds} = await sb.from('exam_prep_rounds').select('*').eq('task_id', t.id).order('round_number'); t.rounds = (rounds || []).map(r => { if (!r.actual_ranges || !Array.isArray(r.actual_ranges)) r.actual_ranges = []; return r; }); } } } selStudentPhases = phaseList; } async function loadAndShow() { if (currentUser.role === 'teacher') { const r = await sb.from('users').select('*').eq('role', 'student').order('name'); students = r.data || []; currentTab = 'teacher'; teacherTab = 'list'; selStudentId = null; } else { currentTab = 'work'; await loadMyInstructions(); } showApp(); } function showApp() { document.getElementById('login-screen').style.display = 'none'; const app = document.getElementById('app-screen'); app.classList.add('show'); document.getElementById('app-name').textContent = currentUser.name + (currentUser.role === 'teacher' ? '' : ' さん'); const badge = document.getElementById('app-badge'); badge.textContent = currentUser.role === 'teacher' ? '先生' : ('生徒' + (currentUser.grade ? ' ・' + currentUser.grade : '')); badge.className = 'tnav-badge ' + (currentUser.role === 'teacher' ? 'badge-t' : 'badge-s'); renderContent(); renderBnav(); } function doLogout() { currentUser = null; students = []; myPhases=[]; selStudentPhases=[]; selStudentId=null; document.getElementById('login-screen').style.display = 'flex'; document.getElementById('app-screen').classList.remove('show'); document.getElementById('login-btn').textContent = 'ログイン'; document.getElementById('login-btn').disabled = false; document.getElementById('login-status').textContent = ''; document.getElementById('inp-id').value = ''; document.getElementById('inp-pw').value = ''; } function renderBnav() { const isT = currentUser.role === 'teacher'; const tabs = isT ? [{k:'teacher',i:'👥',l:'生徒管理'}] : [{k:'work',i:'📖',l:'学校ワーク'},{k:'instr',i:'📋',l:'指示書'},{k:'daily',i:'✏️',l:'日々の記録'}]; document.getElementById('app-bnav').innerHTML = tabs.map(t => '' ).join(''); } async function switchTab(tab) { currentTab = tab; if (tab === 'daily') { await loadDailyData(dailyDate); } renderContent(); renderBnav(); } function renderContent() { const el = document.getElementById('app-content'); if (currentUser.role === 'teacher') { el.innerHTML = renderTeacher(); } else { if (currentTab === 'work') el.innerHTML = renderWork(); else if (currentTab === 'instr') el.innerHTML = renderInstrStudent(); else if (currentTab === 'daily') el.innerHTML = renderDaily(); } } function renderWork() { return '
📖
学校ワーク管理
この機能は近日公開予定です
'; } // ===== 日々の記録 ===== let dailyDate = new Date(); dailyDate.setHours(0,0,0,0); let dailyRecords = {}; // date -> {taskId -> {plan_from,plan_to,plan_units,actual_from,actual_to,actual_units,status,memo,id}} let dailyExtras = {}; // date -> [{text,id}] let dailyDiary = {}; // date -> {content,id} let dailyAddingTasks = false; // タスク追加パネル表示中か function dk(d){ return d.toFullYear?d.toFullYear():d.toISOString().slice(0,10); } function dateKey(d){ const y=d.getFullYear(),m=String(d.getMonth()+1).padStart(2,'0'),dd2=String(d.getDate()).padStart(2,'0'); return y+'-'+m+'-'+dd2; } function addDays(d,n){ const r=new Date(d); r.setDate(r.getDate()+n); return r; } function fmtDate(d){ return d.getFullYear()+'年'+(d.getMonth()+1)+'月'+d.getDate()+'日('+'日月火水木金土'[d.getDay()]+')'; } async function loadDailyData(date) { const dk2 = dateKey(date); const uid = currentUser.id; // daily_records const {data:recs} = await sb.from('daily_records').select('*').eq('user_id',uid).eq('record_date',dk2); dailyRecords[dk2] = {}; for (const r of (recs||[])) { if (r.task_id) dailyRecords[dk2][r.task_id] = r; } // daily_extras const {data:exts} = await sb.from('daily_extras').select('*').eq('user_id',uid).eq('record_date',dk2).order('created_at'); dailyExtras[dk2] = exts||[]; // daily_diary const {data:diaries} = await sb.from('daily_diary').select('*').eq('user_id',uid).eq('record_date',dk2); dailyDiary[dk2] = diaries&&diaries.length>0 ? diaries[0] : null; } async function initDailyDate(date) { var d = new Date(date); d.setHours(0,0,0,0); dailyDate = d; dailyAddingTasks = false; const dk2 = dateKey(d); // 常に最新データをDBから再ロード await loadDailyData(d); renderContent(); } function getDailyTasks(date) { const dk2 = dateKey(date); const recs = dailyRecords[dk2] || {}; // 今日記録があるタスクを返す return Object.keys(recs).map(taskId => { const task = myPhases.flatMap(p=>p.tasks||[]).find(t=>t.id===taskId); return task ? {task, rec: recs[taskId]} : null; }).filter(Boolean); } function renderDaily() { const dk2 = dateKey(dailyDate); const recs = dailyRecords[dk2] || {}; const extras = dailyExtras[dk2] || []; const diary = dailyDiary[dk2] || null; const todayTasks = getDailyTasks(dailyDate); const allTasks = myPhases.flatMap(p=>(p.tasks||[]).filter(t=>MAT[t.material]&&MAT[t.material].type!=='flow')); let h = ''; // 日付ナビ h += '
' + '' + '
' + '
' + fmtDate(dailyDate) + '
' + (dateKey(dailyDate) === dateKey(new Date()) ? '
今日
' : '') + '
' + '
' + '' + '' + '
'; // サマリー const doneCount = todayTasks.filter(function(x){return x.rec.status==='done';}).length; const missCount = todayTasks.filter(function(x){return x.rec.status==='miss';}).length; if (todayTasks.length > 0) { h += '
' + '
' + '今日の進捗' + '' + doneCount + '/' + todayTasks.length + 'タスク完了' + (missCount > 0 ? '未達あり' : '') + '
'; } // 今日のタスクリスト if (todayTasks.length > 0) { h += '
今日の取り組み
'; for (var tIdx = 0; tIdx < todayTasks.length; tIdx++) { var task = todayTasks[tIdx].task; var rec = todayTasks[tIdx].rec; var ms = MAT[task.material] || {bg:'#eee', color:'#333'}; var mt = MAT[task.material] ? MAT[task.material].type : 'units'; var isRounds = mt === 'rounds'; var ranges = getPageRanges(task); var totalPages = getTotalPages(task); var actP = (rec.actual_from != null && rec.actual_to != null && rec.actual_to >= rec.actual_from) ? (rec.actual_to - rec.actual_from + 1) : 0; var planP = (rec.plan_from != null && rec.plan_to != null && rec.plan_to >= rec.plan_from) ? (rec.plan_to - rec.plan_from + 1) : 0; var recId = rec.id; // タスクカードを生成 var cardHtml = '
'; cardHtml += '
'; cardHtml += '' + task.material + ''; cardHtml += '' + task.subject + ''; cardHtml += ''; cardHtml += '
'; if (isRounds) { if (ranges.length > 0) { cardHtml += '
範囲:' + pageRangesLabel(task) + '(全' + totalPages + 'ページ)
'; } cardHtml += '
'; cardHtml += '
'; cardHtml += '
今日の計画
'; cardHtml += '
'; cardHtml += ''; cardHtml += ''; cardHtml += ''; cardHtml += '
'; if (planP > 0) cardHtml += '
' + planP + 'p
'; cardHtml += '
'; cardHtml += '
'; cardHtml += '
実績
'; cardHtml += '
'; cardHtml += ''; cardHtml += ''; cardHtml += ''; cardHtml += '
'; if (actP > 0) cardHtml += '
' + actP + 'p' + (totalPages > 0 ? ' (' + Math.min(100,Math.round(actP/totalPages*100)) + '%)' : '') + '
'; cardHtml += '
'; } else { var totalUnits = task.total_units || 0; var donePct = totalUnits > 0 ? Math.min(100, Math.round((task.done_units||0)/totalUnits*100)) : 0; if (totalUnits > 0) { cardHtml += '
全' + totalUnits + '単元(現在 ' + (task.done_units||0) + '単元/' + donePct + '%完了)
'; } cardHtml += '
'; cardHtml += '
'; cardHtml += '
今日の計画
'; cardHtml += '
'; cardHtml += ''; cardHtml += '単元'; cardHtml += '
'; cardHtml += '
'; cardHtml += '
実績(指示書に反映)
'; cardHtml += '
累計完了単元数を入力
'; cardHtml += '
'; cardHtml += ''; cardHtml += '/ ' + totalUnits + '単元'; cardHtml += '
'; } cardHtml += '
'; cardHtml += ''; cardHtml += ''; cardHtml += ''; cardHtml += '
'; cardHtml += ''; cardHtml += '
'; h += cardHtml; } } // タスク追加ボタン h += ''; if (dailyAddingTasks) { h += '
' + '
指示書からタスクを選択
'; if (allTasks.length === 0) { h += '
指示書にタスクがありません
'; } else { for (var pi2 = 0; pi2 < myPhases.length; pi2++) { var phase2 = myPhases[pi2]; var phaseTasks = (phase2.tasks || []).filter(function(t) { return MAT[t.material] && MAT[t.material].type !== 'flow'; }); if (phaseTasks.length === 0) continue; h += '
' + phase2.phase_name + '
'; for (var pt2 = 0; pt2 < phaseTasks.length; pt2++) { var ptask = phaseTasks[pt2]; var pms = MAT[ptask.material] || {bg:'#eee', color:'#333'}; var already = recs[ptask.id] != null; var ptid = ptask.id; h += '
' + '' + ptask.material + '' + '' + ptask.subject + '' + (already ? '追加済' : '') + '
'; } } } h += '
'; } // その他 h += '
' + '
📝 その他に取り組んだこと
'; for (var ei = 0; ei < extras.length; ei++) { var ex = extras[ei]; h += '
' + '' + '' + '
'; } h += ''; h += '
'; // 一言 h += '
' + '
💬 今日の一言
' + '' + '
'; return h; } function navDaily(n) { initDailyDate(addDays(dailyDate, n)); } function navDailyToday() { initDailyDate(new Date()); } function toggleDailyTaskPanel() { dailyAddingTasks = !dailyAddingTasks; renderContent(); } function handleAddDailyTask(btn) { var taskId = btn.getAttribute('data-taskid'); if (taskId) addDailyTask(taskId); } async function addDailyTask(taskId) { var dk2 = dateKey(dailyDate); if (!dailyRecords[dk2]) dailyRecords[dk2] = {}; if (dailyRecords[dk2][taskId]) { dailyAddingTasks = false; renderContent(); return; } var {data, error} = await sb.from('daily_records').insert({ user_id: currentUser.id, task_id: taskId, record_date: dk2, plan_from: null, plan_to: null, plan_units: null, actual_from: null, actual_to: null, actual_units: null, status: '', memo: '' }).select().single(); if (!error && data) dailyRecords[dk2][taskId] = data; dailyAddingTasks = false; renderContent(); } async function removeDailyRecord(recId) { var dk2 = dateKey(dailyDate); var recs2 = dailyRecords[dk2] || {}; var foundKey = null; for (var k in recs2) { if (recs2[k].id === recId) { foundKey = k; break; } } if (!foundKey) return; await sb.from('daily_records').delete().eq('id', recId); delete dailyRecords[dk2][foundKey]; renderContent(); } async function updateDRField(recId, field, val) { var dk2 = dateKey(dailyDate); var recs2 = dailyRecords[dk2] || {}; var rec = null; for (var k in recs2) { if (recs2[k].id === recId) { rec = recs2[k]; break; } } if (!rec) return; var v = (field === 'memo') ? val : (val === '' ? null : parseInt(val)); rec[field] = v; var upd = {}; upd[field] = v; await sb.from('daily_records').update(upd).eq('id', recId); } async function updateDRActual(recId, field, val) { var dk2 = dateKey(dailyDate); var recs2 = dailyRecords[dk2] || {}; var rec = null; var taskId = null; for (var k in recs2) { if (recs2[k].id === recId) { rec = recs2[k]; taskId = k; break; } } if (!rec) return; var v = val === '' ? null : parseInt(val); rec[field] = v; renderContent(); var upd = {}; upd[field] = v; await sb.from('daily_records').update(upd).eq('id', recId); // 指示書の周回進捗に自動反映 if (taskId) await syncToRounds(taskId, rec); } async function updateDRStatus(recId, status) { var dk2 = dateKey(dailyDate); var recs2 = dailyRecords[dk2] || {}; var rec = null; for (var k in recs2) { if (recs2[k].id === recId) { rec = recs2[k]; break; } } if (!rec) return; rec.status = rec.status === status ? '' : status; renderContent(); await sb.from('daily_records').update({status: rec.status}).eq('id', recId); } async function syncToRounds(taskId, rec) { var targetTask = null; for (var pi3 = 0; pi3 < myPhases.length; pi3++) { for (var ti3 = 0; ti3 < (myPhases[pi3].tasks||[]).length; ti3++) { if (myPhases[pi3].tasks[ti3].id === taskId) { targetTask = myPhases[pi3].tasks[ti3]; break; } } if (targetTask) break; } if (!targetTask) return; var mt = MAT[targetTask.material] ? MAT[targetTask.material].type : null; if (mt === 'units') { // eboard等:累計完了単元数を指示書に反映(大きい方を採用) if (rec.actual_units != null) { var currentDone = targetTask.done_units || 0; var newDone = Math.max(currentDone, parseInt(rec.actual_units) || 0); if (newDone !== currentDone) { targetTask.done_units = newDone; await sb.from('instruction_tasks').update({done_units: newDone}).eq('id', taskId); } } return; } if (mt !== 'rounds') return; var rounds = targetTask.rounds || []; var targetRound = null; for (var ri3 = 0; ri3 < rounds.length; ri3++) { if (rounds[ri3].status === 'wip') { targetRound = rounds[ri3]; break; } } if (!targetRound && rounds.length > 0) { targetRound = rounds[0]; targetRound.status = 'wip'; await sb.from('exam_prep_rounds').update({status:'wip'}).eq('id', targetRound.id); } if (!targetRound) return; if (rec.actual_from != null && rec.actual_to != null) { // 今回の入力と既存の範囲を合わせて最小from〜最大toを計算 var existFrom = targetRound.actual_from; var existTo = targetRound.actual_to; var newFrom = (existFrom != null) ? Math.min(existFrom, rec.actual_from) : rec.actual_from; var newTo = (existTo != null) ? Math.max(existTo, rec.actual_to) : rec.actual_to; targetRound.actual_from = newFrom; targetRound.actual_to = newTo; // actual_rangesも単一範囲で更新 targetRound.actual_ranges = [{from: newFrom, to: newTo}]; await sb.from('exam_prep_rounds').update({ actual_from: newFrom, actual_to: newTo, actual_ranges: [{from: newFrom, to: newTo}], status: 'wip' }).eq('id', targetRound.id); } } async function addExtra() { var dk2 = dateKey(dailyDate); var {data, error} = await sb.from('daily_extras').insert({user_id: currentUser.id, record_date: dk2, content: ''}).select().single(); if (!error && data) { if (!dailyExtras[dk2]) dailyExtras[dk2] = []; dailyExtras[dk2].push(data); } renderContent(); } async function updateExtra(id, val) { var dk2 = dateKey(dailyDate); var ext = (dailyExtras[dk2]||[]).find(function(e){return e.id===id;}); if (ext) ext.content = val; await sb.from('daily_extras').update({content: val}).eq('id', id); } async function removeExtra(id) { var dk2 = dateKey(dailyDate); await sb.from('daily_extras').delete().eq('id', id); if (dailyExtras[dk2]) dailyExtras[dk2] = dailyExtras[dk2].filter(function(e){return e.id!==id;}); renderContent(); } async function updateDiary(val) { var dk2 = dateKey(dailyDate); var existing = dailyDiary[dk2]; if (existing) { existing.content = val; await sb.from('daily_diary').update({content: val}).eq('id', existing.id); } else { var {data, error} = await sb.from('daily_diary').insert({user_id: currentUser.id, record_date: dk2, content: val}).select().single(); if (!error && data) dailyDiary[dk2] = data; } } // ========= 先生:生徒詳細画面 ========= async function openStudentDetail(sid) { detailStudentId = sid; detailStudent = students.find(function(s){ return s.id === sid; }); detailPhases = []; detailDailyRecords = {}; detailCalYear = new Date().getFullYear(); detailCalMonth = new Date().getMonth(); detailSelDate = null; teacherTab = 'detail'; // 指示書データを読み込む document.getElementById('app-content').innerHTML = '
読み込み中...
'; await loadStudentInstructions(sid); detailPhases = selStudentPhases; // 日々の記録データを読み込む await loadDetailDailyRecords(sid); renderContent(); } async function loadDetailDailyRecords(uid) { var result = await sb.from('daily_records').select('*').eq('user_id', uid).order('record_date', {ascending: false}); var recs = result.data || []; detailDailyRecords = {}; recs.forEach(function(r) { if (!detailDailyRecords[r.record_date]) detailDailyRecords[r.record_date] = []; // タスク情報を付加 var task = detailPhases.flatMap(function(p){return p.tasks||[];}).find(function(t){return t.id===r.task_id;}); detailDailyRecords[r.record_date].push({task: task, rec: r}); }); } function renderStudentDetail() { if (!detailStudent) return '
生徒が見つかりません
'; var st = detailStudent; var detailTabs = [{k:'progress',l:'指示書進捗'},{k:'calendar',l:'学習カレンダー'}]; if (!window.detailSubTab) window.detailSubTab = 'progress'; var h = ''; // ヘッダー h += '
' + '' + '
'+st.name.charAt(0)+'
' + '
' + '
'+st.name+'
' + '
'+(st.grade||'')+' ・ ID: '+st.login_id+'
' + '
'; // サブタブ h += '
'; detailTabs.forEach(function(t) { var isActive = window.detailSubTab === t.k; var btn = ''; h += btn; }); h += '
'; if (window.detailSubTab === 'progress') { h += renderDetailProgress(); } else { h += renderDetailCalendar(); } return h; } function setDetailSubTab(tab) { window.detailSubTab = tab; renderContent(); } function backToStudentList() { teacherTab = 'list'; detailStudentId = null; detailStudent = null; window.detailSubTab = 'progress'; renderContent(); } function subjDoneDetail(fs, gi, si) { if (!fs || !fs.clr) return false; var c1 = fs.clr[gi][si].t1, c2 = fs.clr[gi][si].t2; if (c1==='not' || c2==='not') return false; if (c1==='yes' && c2==='yes') return true; return fs.conf && fs.conf[gi][si] && fs.conf[gi][si].done; } function renderDetailProgress() { if (!detailPhases || detailPhases.length === 0) { return '
📋
指示書がありません
'; } var allTasks = detailPhases.flatMap(function(p){return p.tasks||[];}); var avg = allTasks.length ? Math.round(allTasks.reduce(function(a,t){return a+taskPct(t);},0)/allTasks.length) : 0; var h = '
' + '
学習メニュー全体'+avg+'%
' + '
' + '
'; detailPhases.forEach(function(phase) { var tasks = phase.tasks || []; var pp = tasks.length ? Math.round(tasks.reduce(function(a,t){return a+taskPct(t);},0)/tasks.length) : 0; var color = phase.color || '#888'; h += '
' + '
' + ''+phase.phase_name+'' + '
' + ''+pp+'%' + '
'; tasks.forEach(function(task) { var ms = MAT[task.material] || {bg:'#eee',color:'#333'}; var mt = MAT[task.material] ? MAT[task.material].type : 'units'; var pct = taskPct(task); var statusLabel = ''; if (mt === 'rounds') { var rs = task.rounds || []; var doneRounds = rs.filter(function(r){return r.status==='done';}).length; var wipRound = rs.findIndex(function(r){return r.status==='wip';}); statusLabel = doneRounds > 0 ? doneRounds+'周完了' : (wipRound >= 0 ? (wipRound+1)+'周目進行中' : '未着手'); } else if (mt === 'flow') { statusLabel = pct+'% クリア'; } else { statusLabel = (task.done_units||0)+'/'+task.total_units+'単元'; } if (mt === 'flow') { h += '
'; h += '
' + ''+task.material+'' + ''+task.subject+'' + '
'; var fs2 = task.flow_state; if (!fs2 || !fs2.test) { h += '
未着手
'; } else { GRP.forEach(function(g2, gi2) { var unlocked2 = gi2===0 || GRP[0].subjects.every(function(_,si3){return subjDoneDetail(fs2,0,si3);}); h += '
'; h += '
' + g2.label + (unlocked2?'':' 🔒英数クリア後') + '
'; if (unlocked2) { g2.subjects.forEach(function(subj2, si2) { var t1=fs2.test[gi2][si2].t1, t2=fs2.test[gi2][si2].t2; var w1=fs2.weak[gi2][si2].t1, w2=fs2.weak[gi2][si2].t2; var c1=fs2.clr[gi2][si2].t1, c2=fs2.clr[gi2][si2].t2; var conf2=fs2.conf[gi2][si2]; var finalDone2=(c1==='yes'&&c2==='yes')||(conf2&&conf2.done); h += '
'; h += '
'; h += ''+subj2+''; var statusBg = finalDone2?'#E1F5EE':(c1==='not'&&t1==='not'?'#F3F4F6':'#E6F1FB'); var statusC = finalDone2?'#0F6E56':(c1==='not'&&t1==='not'?'#6B7280':'#185FA5'); var statusTxt= finalDone2?'✅ 合格':(c1==='not'&&t1==='not'?'⬜ 未着手':'🔶 取り組み中'); h += ''+statusTxt+''; h += '
'; h += ''; h += '' + '' + ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += '
1回目2回目
力試しテスト'+(t1==='done'?'✅受験済':'⬜')+''+(t2==='done'?'✅受験済':(t1==='done'?'⬜':'—'))+'
苦手リスト'+(w1==='done'?'✅完了':(w1==='wip'?'🔶中':(t1==='done'?'⬜':'—')))+''+(w2==='done'?'✅完了':(w2==='wip'?'🔶中':(t2==='done'?'⬜':'—')))+'
クリア判定'+(c1==='yes'?'✅クリア':(c1==='no'?'❌未クリア':(w1==='done'?'⬜':'—')))+''+(c2==='yes'?'✅クリア':(c2==='no'?'❌未クリア':(w2==='done'?'⬜':'—')))+'
'; if (conf2&&conf2.pattern&&!conf2.done) { var patL={'②':'第1回クリア・第2回未クリア','③':'第1回未クリア・第2回クリア','④':'両回未クリア'}; h += '
⚠️ 確認フェーズ中('+(patL[conf2.pattern]||conf2.pattern)+')
'; } else if (finalDone2) { h += '
🎉 合格!
'; } h += '
'; }); } else { h += '
英語・数学を全教科クリアすると解放されます
'; } h += '
'; }); } h += '
'; } else { h += '
' + ''+task.material+'' + ''+task.subject+'' + ''+statusLabel+'' + '
' + ''+pct+'%' + '
'; } }); h += '
'; }); return h; } function renderDetailCalendar() { var now = new Date(detailCalYear, detailCalMonth, 1); var year = detailCalYear; var month = detailCalMonth; var firstDay = new Date(year, month, 1).getDay(); var daysInMonth = new Date(year, month+1, 0).getDate(); // 月曜始まりに調整 var startOffset = (firstDay === 0) ? 6 : firstDay - 1; var h = ''; // 月ナビ h += '
' + '' + ''+year+'年'+(month+1)+'月' + '' + '
'; // 曜日ヘッダー h += '
'; ['月','火','水','木','金','土','日'].forEach(function(d,i) { h += '
'+d+'
'; }); h += '
'; // カレンダーグリッド h += '
'; // 空白 for (var i = 0; i < startOffset; i++) { h += '
'; } for (var d = 1; d <= daysInMonth; d++) { var dateStr = year+'-'+String(month+1).padStart(2,'0')+'-'+String(d).padStart(2,'0'); var recs = detailDailyRecords[dateStr] || []; var hasRecord = recs.length > 0; var allDone = hasRecord && recs.every(function(x){return x.rec.status==='done';}); var hasMiss = hasRecord && recs.some(function(x){return x.rec.status==='miss';}); var hasPart = hasRecord && recs.some(function(x){return x.rec.status==='part';}); var bgColor = !hasRecord ? '#F9FAFB' : allDone ? '#E1F5EE' : hasMiss ? '#FCEBEB' : hasPart ? '#FAEEDA' : '#E6F1FB'; var dotColor = !hasRecord ? '' : allDone ? '#1D9E75' : hasMiss ? '#E24B4A' : hasPart ? '#EF9F27' : '#185FA5'; var isSelected = detailSelDate === dateStr; var dow = new Date(year, month, d).getDay(); var dayStyle = 'text-align:center;padding:5px 2px;border-radius:8px;cursor:' + (hasRecord?'pointer':'default') + ';background:' + bgColor + ';border:1px solid ' + (isSelected?'#185FA5':'transparent') + ';min-height:42px'; var dayDot = hasRecord ? '
' + (allDone?'✅':hasMiss?'❌':'🔶') + '
' : ''; var dayNum = '
' + d + '
'; h += '
' + dayNum + dayDot + '
'; } h += '
'; // 凡例 h += '
' + '✅ 全部できた🔶 一部できた❌ できなかった' + '
'; // 選択日の詳細 if (detailSelDate) { var selRecs = detailDailyRecords[detailSelDate] || []; h += '
' + '
'+detailSelDate.replace(/-/g,'/')+'の記録
'; if (selRecs.length === 0) { h += '
記録なし
'; } else { selRecs.forEach(function(item) { var r = item.rec; var t = item.task; var ms = t ? (MAT[t.material]||{bg:'#eee',color:'#333'}) : {bg:'#eee',color:'#333'}; var statusIcon = r.status==='done'?'✅':r.status==='miss'?'❌':r.status==='part'?'🔶':'—'; var mt = t ? (MAT[t.material]?MAT[t.material].type:'units') : 'units'; var detail = ''; if (mt==='rounds') { if (r.actual_from!=null&&r.actual_to!=null) detail = 'p.'+r.actual_from+'〜'+r.actual_to; } else { if (r.actual_units!=null) detail = r.actual_units+'単元'; } h += '
' + ''+(t?t.material:'?')+'' + ''+(t?t.subject:'不明タスク')+'' + (detail?''+detail+'':'') + ''+statusIcon+'' + '
'; if (r.memo) { h += '
📝 '+r.memo+'
'; } }); } h += '
'; } return h; } function detailCalPrev() { detailCalMonth--; if (detailCalMonth < 0) { detailCalMonth = 11; detailCalYear--; } detailSelDate = null; renderContent(); } function detailCalNext() { detailCalMonth++; if (detailCalMonth > 11) { detailCalMonth = 0; detailCalYear++; } detailSelDate = null; renderContent(); } function selectDetailDate(dateStr) { detailSelDate = (detailSelDate === dateStr) ? null : dateStr; renderContent(); } // ========= 先生画面 ========= function renderTeacher() { // 詳細画面表示中 if (teacherTab === 'detail') return renderStudentDetail(); const tabs = [{k:'list',l:'生徒一覧'},{k:'instr',l:'指示書管理'},{k:'account',l:'アカウント管理'}]; const tabHtml = '
'+tabs.map(t=>'').join('')+'
'; if (teacherTab === 'list') return tabHtml + renderStudentList(); if (teacherTab === 'instr') return tabHtml + renderTeacherInstrPlaceholder(); return tabHtml + renderAccount(); } function setTeacherTab(tab){ teacherTab=tab; renderContent(); } function renderStudentList() { if (students.length === 0) { return '
👥
生徒が登録されていません
「アカウント管理」から生徒を追加してください
'; } return '
'+students.map(function(st){ var sid = st.id; return '
' +'
'+st.name.charAt(0)+'
' +'
'+st.name+'
' +'
'+(st.grade||'')+'
' +'
ID: '+st.login_id+'
' +'
詳細を見る →
' +'
'; }).join('')+'
'; } function renderAccount() { let h = '
' +'
生徒アカウントを追加
' +'
' +'
' +'
' +'
' +'
' +'' +'
'; if (students.length>0) { h += '
' +'
登録済み生徒('+students.length+'名)
' +students.map(st=>'
' +'
'+st.name.charAt(0)+'
' +''+st.name+'('+(st.grade||'')+')' +'ID: '+st.login_id+'' +'
').join('') +'
'; } return h; } async function addStudent() { const name = document.getElementById('new-name')?.value?.trim(); const grade = document.getElementById('new-grade')?.value?.trim(); const loginId = document.getElementById('new-id')?.value?.trim(); const pw = document.getElementById('new-pw')?.value?.trim(); const status = document.getElementById('add-status'); if (!name || !loginId || !pw) { status.textContent = '氏名・ID・パスワードは必須です'; return; } status.textContent = '追加中...'; try { const r = await sb.from('users').insert({login_id: loginId, password_hash: pw, role: 'student', name, grade, color: '#185FA5', bg_color: '#E6F1FB'}).select().single(); if (r.error) { status.textContent = r.error.code === '23505' ? 'このIDはすでに使用されています' : 'エラー: ' + r.error.message; return; } students.push(r.data); status.textContent = name + 'さんを追加しました!'; renderContent(); } catch(e) { status.textContent = 'エラー: ' + e.message; } } // ===== 先生:指示書管理 ===== function renderTeacherInstrPlaceholder() { let h = '
' +'
' +'' +'' +'
'; if (!selStudentId) { return h + '
📋
生徒を選択してください
指示書の作成・編集ができます
'; } h += selStudentPhases.map((phase,pi)=>renderTeacherPhase(phase,pi)).join(''); h += ''; return h; } async function selectStudentForInstr(uid) { selStudentId = uid || null; selStudentPhases = []; if (selStudentId) { document.getElementById('app-content').innerHTML = '
読み込み中...
'; await loadStudentInstructions(selStudentId); } renderContent(); } function renderTeacherPhase(phase,pi) { const color = phase.color || PHASE_COLORS[pi % PHASE_COLORS.length]; let h = '
' +'
' +'' +'' +'' +'
'; (phase.tasks||[]).forEach((task,ti)=>{ h += renderTeacherTask(phase,pi,task,ti); }); h += ''; h += '
'; return h; } function renderTeacherTask(phase,pi,task,ti) { const mt = (MAT[task.material] && MAT[task.material].type) || 'units'; let h = '
' +'
' +'' +'' +'' +'' +'
' +'' +''; if (mt==='units') { h += '
' +'総単元数' +'' +'(生徒は完了単元数を入力します)' +'
'; } if (mt==='rounds') { const tRanges = (task.page_ranges && task.page_ranges.length>0) ? task.page_ranges : [{from:null,to:null}]; const totalP = tRanges.reduce((s,r)=>(r.from!=null&&r.to!=null&&r.to>=r.from)?s+(r.to-r.from+1):s,0); h += '
' +'
取り組みページ範囲'+(totalP>0?' (合計'+totalP+'ページ)':'')+'
' +tRanges.map((r,ri2)=>{ const pCnt = (r.from!=null&&r.to!=null&&r.to>=r.from)?(r.to-r.from+1):0; return '
' +'' +'' +'' +(pCnt>0?' '+pCnt+'p':'') +(tRanges.length>1?' ':'') +'
'; }).join('') +'' +'
全周回共通の範囲。生徒は実績ページのみ入力します
' +'
' +'
' +'周回数' +'' +'(生徒が周回ごとに実績ページを記録します)' +'
'; } if (mt==='flow') { h += '
aim@診断テストの取り組みフローが生徒画面に表示されます
'; } h += '
'; return h; } async function addPhase() { const order = selStudentPhases.length; const r = await sb.from('instruction_phases').insert({user_id: selStudentId, phase_name: '新しいフェーズ', phase_order: order, color: PHASE_COLORS[order % PHASE_COLORS.length]}).select().single(); if (r.error) { alert('エラー: '+r.error.message); return; } r.data.tasks = []; r.data.open = true; selStudentPhases.push(r.data); renderContent(); } async function deletePhase(pi) { if(!confirm('このフェーズを削除しますか?タスクも全て削除されます。'))return; const phase = selStudentPhases[pi]; // タスクに紐づく周回を先に削除 for (const task of (phase.tasks||[])) { await sb.from('exam_prep_rounds').delete().eq('task_id', task.id); await sb.from('instruction_tasks').delete().eq('id', task.id); } const {error} = await sb.from('instruction_phases').delete().eq('id', phase.id); if (error) { alert('削除エラー: '+error.message); return; } selStudentPhases.splice(pi,1); renderContent(); } async function updatePhaseField(pi,field,val) { selStudentPhases[pi][field]=val; const upd = {}; upd[field]=val||null; await sb.from('instruction_phases').update(upd).eq('id', selStudentPhases[pi].id); } async function addTask(pi) { const phase = selStudentPhases[pi]; const order = (phase.tasks||[]).length; const r = await sb.from('instruction_tasks').insert({phase_id: phase.id, material:'aim@定テ対策', subject:'', range_text:'', note:'', total_units:0, rounds_count:0, task_order: order, flow_state:{}}).select().single(); if (r.error) { alert('エラー: '+r.error.message); return; } r.data.rounds=[]; phase.tasks = phase.tasks || []; phase.tasks.push(r.data); renderContent(); } async function deleteTask(pi,ti) { if(!confirm('このタスクを削除しますか?'))return; const task = selStudentPhases[pi].tasks[ti]; await sb.from('instruction_tasks').delete().eq('id', task.id); selStudentPhases[pi].tasks.splice(ti,1); renderContent(); } async function updateTaskField(pi,ti,field,val) { const task = selStudentPhases[pi].tasks[ti]; task[field]=val; if(field==='material'){ const mt=MAT[val] && MAT[val].type; if(mt==='flow' && (!task.flow_state || Object.keys(task.flow_state).length===0)){ task.flow_state = initFlowState(); await sb.from('instruction_tasks').update({material:val, flow_state: task.flow_state}).eq('id', task.id); renderContent(); return; } } renderContent(); const upd = {}; upd[field]=(val===''?null:val); await sb.from('instruction_tasks').update(upd).eq('id', task.id); } function ensurePageRanges(task) { if (!task.page_ranges || !Array.isArray(task.page_ranges) || task.page_ranges.length===0) { task.page_ranges = [{from:null, to:null}]; } return task.page_ranges; } async function updatePageRange(pi,ti,rangeIndex,field,val) { const task = selStudentPhases[pi].tasks[ti]; const ranges = ensurePageRanges(task); const v = val===''?null:parseInt(val); ranges[rangeIndex][field] = v; task.page_ranges = ranges; renderContent(); await sb.from('instruction_tasks').update({page_ranges: ranges}).eq('id', task.id); } async function addPageRange(pi,ti) { const task = selStudentPhases[pi].tasks[ti]; const ranges = ensurePageRanges(task); ranges.push({from:null, to:null}); task.page_ranges = ranges; renderContent(); await sb.from('instruction_tasks').update({page_ranges: ranges}).eq('id', task.id); } async function removePageRange(pi,ti,rangeIndex) { const task = selStudentPhases[pi].tasks[ti]; const ranges = ensurePageRanges(task); ranges.splice(rangeIndex,1); if (ranges.length===0) ranges.push({from:null,to:null}); task.page_ranges = ranges; renderContent(); await sb.from('instruction_tasks').update({page_ranges: ranges}).eq('id', task.id); } async function updateRoundsCount(pi,ti,count) { const task = selStudentPhases[pi].tasks[ti]; const current = task.rounds || []; task.rounds_count = count; await sb.from('instruction_tasks').update({rounds_count:count}).eq('id', task.id); if (count > current.length) { for (let r = current.length+1; r <= count; r++) { const ins = await sb.from('exam_prep_rounds').insert({user_id: selStudentId, task_id: task.id, round_number: r, status:'not', completion_rate:0}).select().single(); if (!ins.error) current.push(ins.data); } } else if (count < current.length) { const toRemove = current.splice(count); for (const r of toRemove) await sb.from('exam_prep_rounds').delete().eq('id', r.id); } task.rounds = current; renderContent(); }