diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e99fb80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +bun dev # Start development server (http://localhost:3000) +bun build # Production build +bun start # Start production server +bun install # Install dependencies +``` + +No test suite is configured. Manual testing is expected. + +### Docker + +```bash +docker compose up --build # Build and run with Docker +``` + +Requires env vars: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` + +### Local Supabase (optional) + +```bash +supabase start # Requires Docker — runs local Postgres + Auth +``` + +## Environment Setup + +Copy `.env.local.example` to `.env.local` and fill in Supabase credentials: + +``` +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +``` + +## Architecture + +**Stack:** Next.js 15 (App Router) + Bun + Supabase (PostgreSQL) + TypeScript + Tailwind CSS 4 + +**UI libraries:** shadcn/ui (primary), Radix UI, Ant Design, Framer Motion, Recharts, Sonner + +**Data fetching:** SWR for client-side caching; server actions and API routes for mutations + +### Directory layout + +``` +app/ + (admin)/ Admin dashboard routes + (login)/ Auth/login routes + (main)/ Main app routes + _actions/ Server actions (Next.js server-side mutations) + patient/ Patient detail and management pages + patient-create/ + api/ REST API routes + v1/patients/ + admin/ + auth/ + forms/ + service/ Business logic — Supabase query functions called by server actions and API routes + +components/ + ui/ shadcn/ui primitives + patient/ Patient-specific components + admin/ Admin-specific components + question-types/ Form question type renderers + +lib/ + timezone.ts Bangkok (GMT+7) timezone utilities — important for all date operations + question-types.ts Form field type definitions + +utils/supabase/ + client.ts Browser-side Supabase client + server.ts Server-side Supabase client (uses cookies) + middleware.ts Auth session refresh middleware + +supabase/ + migrations/ Versioned SQL migrations (format: YYYYMMDD*) + functions/ Supabase Edge Functions (auto-assign-groups, manage-groups, export-data) +``` + +### Data layer + +No ORM — the app uses the Supabase JS SDK directly. Query logic lives in `app/service/`. Key tables: `patients`, `patient_groups`, `forms`, and form submission tables. + +Row Level Security (RLS) is enabled. Auth flows through Supabase Auth with SSR session handling via `@supabase/ssr`. + +### Timezone + +All dates must be handled in **Bangkok time (Asia/Bangkok, GMT+7)**. See `lib/timezone.ts` for helpers. This was a known production issue on Vercel — see `TIMEZONE_FIX.md` for context. + +### Docker runtime config + +The multi-stage Dockerfile uses Next.js standalone output. `docker-entrypoint.sh` injects runtime environment variables by replacing placeholders in the built JS files — this enables environment configuration at container start without rebuilding. diff --git a/app/(main)/patient/[id]/[formId]/[questionId]/page.tsx b/app/(main)/patient/[id]/[formId]/[questionId]/page.tsx index fe4c915..3d9a5a5 100644 --- a/app/(main)/patient/[id]/[formId]/[questionId]/page.tsx +++ b/app/(main)/patient/[id]/[formId]/[questionId]/page.tsx @@ -20,6 +20,7 @@ import { CalendarDays } from 'lucide-react'; import { getFormById, getQuestionsByFormId } from '@/app/service/patient-client'; import { Form, Question } from '@/app/service/patient-client'; import { createClient } from '@/utils/supabase/client'; +import { calculateTotalScore } from '@/lib/scoring'; export default function QuestionPage() { const params = useParams(); @@ -118,106 +119,6 @@ export default function QuestionPage() { } }; - const calculateTotalScore = (answers: Record, questions: any[]) => { - let totalScore = 0; - - console.log('🔍 Starting score calculation...'); - console.log('📝 All answers:', answers); - console.log('🔢 Number of answers:', Object.keys(answers).length); - console.log('❓ All questions:', questions.map(q => ({ id: q.question_id, type: q.question_type }))); - console.log('🔢 Number of questions:', questions.length); - - // Check if we have answers for all questions - const questionIds = questions.map(q => q.question_id); - const answeredIds = Object.keys(answers).map(id => parseInt(id)); - const missingAnswers = questionIds.filter(id => !answeredIds.includes(id)); - - if (missingAnswers.length > 0) { - console.warn('⚠️ Missing answers for questions:', missingAnswers); - } - - Object.entries(answers).forEach(([questionIdStr, answer]) => { - const questionId = parseInt(questionIdStr, 10); - const question = questions.find(q => q.question_id === questionId); - - if (!question) { - console.log(`⚠️ Question not found for ID: ${questionId}`); - return; - } - - let questionScore = 0; - console.log(`\n🔍 Processing question ${questionId}:`); - console.log(` Type: ${question.question_type}`); - console.log(` Answer: "${answer}"`); - console.log(` Options:`, question.options); - - switch (question.question_type) { - case 'multiple_choice': - case 'multipleChoice': - const choices = question.options?.choices || []; - console.log(` Choices available:`, choices); - - const selectedChoice = choices.find((choice: any) => { - const choiceText = typeof choice === 'string' ? choice : (choice.text || choice.choice); - return choiceText === answer; - }); - - if (selectedChoice) { - questionScore = typeof selectedChoice === 'string' ? 0 : (parseFloat(selectedChoice.score) || 0); - console.log(` ✅ Found matching choice, score: ${questionScore}`); - } else { - console.log(` ⚠️ No matching choice found for answer: "${answer}"`); - } - break; - - case 'true_false': - case 'trueFalse': - const options = question.options || {}; - if (answer === 'true' && options.trueScore !== undefined) { - questionScore = parseFloat(options.trueScore) || 0; - } else if (answer === 'false' && options.falseScore !== undefined) { - questionScore = parseFloat(options.falseScore) || 0; - } - console.log(` ✅ True/False score: ${questionScore}`); - break; - - case 'rating': - const ratingValue = parseFloat(answer || '0'); - const multiplier = Number(question.options?.scoreMultiplier) || 1; - // Calculate exact score with precision, no rounding to integer - questionScore = parseFloat((ratingValue * multiplier).toFixed(2)); - console.log(` ✅ Rating score: ${questionScore} (${ratingValue} × ${multiplier})`); - break; - - case 'number': - const numberValue = parseFloat(answer || '0'); - const numberMultiplier = Number(question.options?.scoreMultiplier) || 1; - // Calculate exact score with precision, no rounding to integer - questionScore = parseFloat((numberValue * numberMultiplier).toFixed(2)); - console.log(` ✅ Number score: ${questionScore} (${numberValue} × ${numberMultiplier})`); - break; - - case 'text': - questionScore = 0; - console.log(` ✅ Text question score: ${questionScore}`); - break; - - default: - questionScore = 0; - console.log(` ⚠️ Unknown question type: ${question.question_type}`); - } - - console.log(` 📊 Question ${questionId} contributes: ${questionScore} points`); - console.log(` 📈 Total before adding: ${totalScore}`); - totalScore += questionScore; - console.log(` 📈 Total after adding: ${totalScore}`); - }); - - console.log(`\n🎯 FINAL TOTAL SCORE: ${totalScore}`); - console.log(`📊 Summary: Processed ${Object.keys(answers).length} answers out of ${questions.length} questions`); - return totalScore; - }; - const handleComplete = async () => { setIsSaving(true); console.log('🚀 Starting form submission process...'); diff --git a/app/(main)/patient/[id]/history/[submissionId]/EditSubmissionClient.tsx b/app/(main)/patient/[id]/history/[submissionId]/EditSubmissionClient.tsx new file mode 100644 index 0000000..4ff26bb --- /dev/null +++ b/app/(main)/patient/[id]/history/[submissionId]/EditSubmissionClient.tsx @@ -0,0 +1,261 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { formatInTimeZone, fromZonedTime } from 'date-fns-tz'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Label } from '@/components/ui/label'; +import { ArrowLeft, Calendar, Clock, Edit, FileText, Loader2, User, X } from 'lucide-react'; +import QuestionRenderer from '@/components/question-types/QuestionRenderer'; +import { toThaiDate, toThaiTimeShort } from '@/lib/timezone'; +import { updateSubmission } from './_actions/updateSubmission'; + +const BANGKOK_TIMEZONE = 'Asia/Bangkok'; + +interface Props { + submission: any; + questions: any[]; + patientId: string; + submissionId: string; +} + +function toDatetimeLocalValue(isoString: string): string { + // Format the UTC ISO string as a Bangkok-local datetime-local input value + return formatInTimeZone(new Date(isoString), BANGKOK_TIMEZONE, "yyyy-MM-dd'T'HH:mm"); +} + +function fromDatetimeLocalValue(localValue: string): string { + // Interpret the datetime-local value as Bangkok time and return a UTC ISO string + return fromZonedTime(new Date(localValue), BANGKOK_TIMEZONE).toISOString(); +} + +export default function EditSubmissionClient({ submission, questions, patientId, submissionId }: Props) { + const router = useRouter(); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [editedAnswers, setEditedAnswers] = useState>({}); + const [editedDatetime, setEditedDatetime] = useState(''); + + const answers = submission.answers || {}; + + function startEditing() { + setEditedAnswers({ ...answers }); + setEditedDatetime(toDatetimeLocalValue(submission.submitted_at)); + setIsEditing(true); + } + + function cancelEditing() { + setIsEditing(false); + } + + async function handleSave() { + setIsSaving(true); + try { + const utcDatetime = fromDatetimeLocalValue(editedDatetime); + const result = await updateSubmission( + submissionId, + patientId, + submission.form_id, + editedAnswers, + utcDatetime + ); + if (result.success) { + toast.success('บันทึกการแก้ไขเรียบร้อยแล้ว'); + setIsEditing(false); + router.refresh(); + } else { + toast.error(result.error || 'เกิดข้อผิดพลาดในการบันทึก'); + } + } catch (err) { + toast.error('เกิดข้อผิดพลาดที่ไม่คาดคิด'); + } finally { + setIsSaving(false); + } + } + + const evaluationBadgeClass = + submission.evaluation_result?.includes('ดี') + ? 'bg-green-100 text-green-800 hover:bg-green-100' + : submission.evaluation_result?.includes('ปานกลาง') + ? 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100' + : 'bg-red-100 text-red-800 hover:bg-red-100'; + + return ( +
+
+ + + +
+ +
+ {/* Header Card */} + + +
+
+ {submission.forms?.title} + {submission.forms?.description} +
+
+ {submission.evaluation_result && ( + + {submission.evaluation_result} + + )} + {!isEditing && ( + + )} +
+
+
+ + {isEditing ? ( +
+ + setEditedDatetime(e.target.value)} + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> +
+ ) : ( +
+
+ + {toThaiDate(submission.submitted_at)} +
+
+ + {toThaiTimeShort(submission.submitted_at)} +
+
+ + + บันทึกโดย: {submission.profiles?.title || ''}{' '} + {submission.profiles?.first_name} {submission.profiles?.last_name} + +
+
+ )} + + {submission.total_evaluation_score !== null && ( +
+
+ คะแนนรวม + + {Number(submission.total_evaluation_score).toFixed(2)} + +
+ {submission.evaluation_description && ( +

+ {submission.evaluation_description} +

+ )} +
+ )} +
+
+ + {/* Answers Card */} + + +
+ + + รายละเอียดคำตอบ + + {isEditing && ( +
+ + +
+ )} +
+
+ + {questions.map((question: any, index: number) => { + const answer = isEditing + ? (editedAnswers[question.question_id] ?? '') + : (answers[question.question_id]); + + if (isEditing) { + return ( +
+
+ {index + 1}. + {question.question_text} + {question.is_required && ( + * + )} +
+
+ + setEditedAnswers((prev) => ({ + ...prev, + [question.question_id]: value, + })) + } + /> +
+ {index < questions.length - 1 && } +
+ ); + } + + let displayAnswer = answer || '-'; + if (question.question_type === 'multiple_choice' || question.question_type === 'mcq') { + if (question.options?.choices) { + const choice = question.options.choices.find((c: any) => + (typeof c === 'string' ? c : c.value) === answer + ); + if (choice) { + displayAnswer = typeof choice === 'string' ? choice : (choice.text || choice.label || choice.value); + } + } + } else if (question.question_type === 'true_false' || question.question_type === 'trueFalse') { + if (answer === 'true') displayAnswer = 'ใช่ / จริง'; + else if (answer === 'false') displayAnswer = 'ไม่ใช่ / ไม่จริง'; + } + + return ( +
+
+ {index + 1}. + {question.question_text} +
+
{displayAnswer}
+ {index < questions.length - 1 && } +
+ ); + })} +
+
+
+
+ ); +} diff --git a/app/(main)/patient/[id]/history/[submissionId]/_actions/updateSubmission.ts b/app/(main)/patient/[id]/history/[submissionId]/_actions/updateSubmission.ts new file mode 100644 index 0000000..acb6b1c --- /dev/null +++ b/app/(main)/patient/[id]/history/[submissionId]/_actions/updateSubmission.ts @@ -0,0 +1,86 @@ +'use server'; + +import { createClient } from '@/utils/supabase/server'; +import { revalidatePath } from 'next/cache'; +import { calculateTotalScore } from '@/lib/scoring'; + +export async function updateSubmission( + submissionId: string, + patientId: string, + formId: string, + answers: Record, + newDatetime: string +): Promise<{ success: boolean; error?: string }> { + const supabase = await createClient(); + + const { data: userData, error: userError } = await supabase.auth.getUser(); + if (userError || !userData?.user) { + return { success: false, error: 'ไม่สามารถระบุตัวตนผู้ใช้ได้ กรุณาเข้าสู่ระบบใหม่' }; + } + + const { data: questions, error: questionsError } = await supabase + .from('questions') + .select('*') + .eq('form_id', formId) + .order('question_id', { ascending: true }); + + if (questionsError) { + return { success: false, error: `ไม่สามารถโหลดคำถามได้: ${questionsError.message}` }; + } + + const totalScore = calculateTotalScore(answers, questions || []); + + const { data: formData, error: formError } = await supabase + .from('forms') + .select('evaluation_thresholds') + .eq('form_id', formId) + .single(); + + if (formError) { + return { success: false, error: `ไม่สามารถโหลดแบบฟอร์มได้: ${formError.message}` }; + } + + let evaluationResult: string | null = null; + let evaluationDescription: string | null = null; + + if (formData?.evaluation_thresholds) { + for (const threshold of formData.evaluation_thresholds) { + const minScore = threshold.min_score ?? threshold.minScore; + const maxScore = threshold.max_score ?? threshold.maxScore; + if (totalScore >= minScore && totalScore <= maxScore) { + evaluationResult = threshold.result; + evaluationDescription = threshold.description; + break; + } + } + } + + const { error: updateError } = await supabase + .from('submissions') + .update({ + submitted_at: newDatetime, + answers, + total_evaluation_score: totalScore, + evaluation_result: evaluationResult, + evaluation_description: evaluationDescription, + }) + .eq('id', submissionId); + + if (updateError) { + return { success: false, error: `ไม่สามารถบันทึกข้อมูลได้: ${updateError.message}` }; + } + + // Re-evaluate group assignment based on the updated score + const { error: rpcError } = await supabase.rpc('assign_patient_to_multiple_groups', { + patient_id_param: patientId, + }); + + if (rpcError) { + console.error('Group assignment RPC error:', rpcError); + // Don't fail the whole operation — the submission is already saved + } + + revalidatePath(`/patient/${patientId}/history/${submissionId}`); + + return { success: true }; +} diff --git a/app/(main)/patient/[id]/history/[submissionId]/page.tsx b/app/(main)/patient/[id]/history/[submissionId]/page.tsx index 39dcf3b..695f6fc 100644 --- a/app/(main)/patient/[id]/history/[submissionId]/page.tsx +++ b/app/(main)/patient/[id]/history/[submissionId]/page.tsx @@ -1,12 +1,9 @@ import React from 'react'; import { createClient } from '@/utils/supabase/server'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { ArrowLeft, Calendar, Clock, FileText, User } from 'lucide-react'; import Link from 'next/link'; -import { Separator } from '@/components/ui/separator'; -import { toThaiDate, toThaiTimeShort } from '@/lib/timezone'; +import EditSubmissionClient from './EditSubmissionClient'; interface HistoryDetailPageProps { params: Promise<{ @@ -54,7 +51,7 @@ async function getSubmissionDetails(submissionId: string) { async function getFormQuestions(formId: string) { const supabase = await createClient(); - + const { data: questions, error } = await supabase .from('questions') .select('*') @@ -89,126 +86,13 @@ export default async function HistoryDetailPage({ params }: HistoryDetailPagePro } const questions = await getFormQuestions(submission.form_id); - const answers = submission.answers || {}; - - const submittedDate = new Date(submission.submitted_at); return ( -
-
- - - -
- -
- {/* Header Card */} - - -
-
- {submission.forms?.title} - {submission.forms?.description} -
- {submission.evaluation_result && ( - - {submission.evaluation_result} - - )} -
-
- -
-
- - - {toThaiDate(submission.submitted_at)} - -
-
- - - {toThaiTimeShort(submission.submitted_at)} - -
-
- - - บันทึกโดย: {submission.profiles?.title || ''} {submission.profiles?.first_name} {submission.profiles?.last_name} - -
-
- - {submission.total_evaluation_score !== null && ( -
-
- คะแนนรวม - - {submission.total_evaluation_score.toFixed(2)} - -
- {submission.evaluation_description && ( -

- {submission.evaluation_description} -

- )} -
- )} -
-
- - {/* Answers Card */} - - - - - รายละเอียดคำตอบ - - - - {questions.map((question: any, index: number) => { - const answer = answers[question.question_id]; - let displayAnswer = answer || '-'; - - // Format answer based on question type if needed - if (question.question_type === 'multiple_choice' || question.question_type === 'mcq') { - // Try to find the label if the answer is a value - if (question.options?.choices) { - const choice = question.options.choices.find((c: any) => - (typeof c === 'string' ? c : c.value) === answer - ); - if (choice) { - displayAnswer = typeof choice === 'string' ? choice : choice.text || choice.label || choice.value; - } - } - } else if (question.question_type === 'true_false' || question.question_type === 'trueFalse') { - if (answer === 'true') displayAnswer = 'ใช่ / จริง'; - else if (answer === 'false') displayAnswer = 'ไม่ใช่ / ไม่จริง'; - } - - return ( -
-
- {index + 1}. - {question.question_text} -
-
- {displayAnswer} -
- {index < questions.length - 1 && } -
- ); - })} -
-
-
-
+ ); } diff --git a/lib/scoring.ts b/lib/scoring.ts new file mode 100644 index 0000000..60b49c5 --- /dev/null +++ b/lib/scoring.ts @@ -0,0 +1,56 @@ +export function calculateTotalScore(answers: Record, questions: any[]): number { + let totalScore = 0; + + Object.entries(answers).forEach(([questionIdStr, answer]) => { + const questionId = parseInt(questionIdStr, 10); + const question = questions.find((q) => q.question_id === questionId); + + if (!question) return; + + let questionScore = 0; + + switch (question.question_type) { + case 'multiple_choice': + case 'multipleChoice': { + const choices = question.options?.choices || []; + const selectedChoice = choices.find((choice: any) => { + const choiceText = typeof choice === 'string' ? choice : (choice.text || choice.choice); + return choiceText === answer; + }); + if (selectedChoice) { + questionScore = typeof selectedChoice === 'string' ? 0 : (parseFloat(selectedChoice.score) || 0); + } + break; + } + case 'true_false': + case 'trueFalse': { + const options = question.options || {}; + if (answer === 'true' && options.trueScore !== undefined) { + questionScore = parseFloat(options.trueScore) || 0; + } else if (answer === 'false' && options.falseScore !== undefined) { + questionScore = parseFloat(options.falseScore) || 0; + } + break; + } + case 'rating': { + const ratingValue = parseFloat(answer || '0'); + const multiplier = Number(question.options?.scoreMultiplier) || 1; + questionScore = parseFloat((ratingValue * multiplier).toFixed(2)); + break; + } + case 'number': { + const numberValue = parseFloat(answer || '0'); + const numberMultiplier = Number(question.options?.scoreMultiplier) || 1; + questionScore = parseFloat((numberValue * numberMultiplier).toFixed(2)); + break; + } + case 'text': + default: + questionScore = 0; + } + + totalScore += questionScore; + }); + + return totalScore; +}