33import React , { useEffect , useMemo , useState } from "react" ;
44import dynamic from "next/dynamic" ;
55import type { ApexOptions } from "apexcharts" ;
6-
76const ReactApexChart = dynamic ( ( ) => import ( "react-apexcharts" ) , { ssr : false } ) ;
87
98type Severity = "Critical" | "High" | "Medium" | "Low" ;
109type TrendPoint = { severity : Severity ; count : number } ;
1110type TrendItem = { date : string ; trend : TrendPoint [ ] } ;
12-
1311type QuickRange = "3d" | "7d" | "30d" ;
1412
1513const SEVERITIES : Severity [ ] = [ "Critical" , "High" , "Medium" , "Low" ] ;
14+ const TZ = "Asia/Bangkok" ;
1615
17- function toYMD ( d : Date ) : string {
18- const y = d . getFullYear ( ) ;
19- const m = ( `0${ d . getMonth ( ) + 1 } ` ) . slice ( 0 - 2 ) ;
20- const dd = ( `0${ d . getDate ( ) } ` ) . slice ( 0 - 2 ) ;
21- return `${ y } -${ m } -${ dd } ` ;
16+ /** format เป็น YYYY-MM-DD ตามโซนเวลาเป้าหมาย (ไม่พึ่ง getFullYear/getMonth) */
17+ function toYMD_TZ ( value : number | Date , tz = TZ ) : string {
18+ const d = typeof value === "number" ? new Date ( value ) : value ;
19+ return new Intl . DateTimeFormat ( "en-CA" , { timeZone : tz } ) . format ( d ) ;
2220}
2321
24- function getRangeDates ( range : QuickRange ) : { start : string ; end : string } {
25- const now = new Date ( ) ; // ใช้เวลาเครื่อง (Asia/Bangkok ในโปรเจ็กต์คุณอยู่แล้ว)
26- const end = toYMD ( now ) ;
27- const startDate = new Date ( now ) ;
22+ /** คำนวณช่วงวันฝั่ง client เท่านั้น (ป้องกัน SSR/CSR ไม่ตรงกัน) */
23+ function getRangeDatesClient ( range : QuickRange , tz = TZ ) : { start : string ; end : string } {
2824 const delta = range === "3d" ? 3 : range === "7d" ? 7 : 30 ;
29- startDate . setDate ( now . getDate ( ) - ( delta - 1 ) ) ; // รวมวันนี้ด้วย
30- const start = toYMD ( startDate ) ;
25+ const now = Date . now ( ) ;
26+ const end = toYMD_TZ ( now , tz ) ;
27+ const start = toYMD_TZ ( now - ( delta - 1 ) * 86400000 , tz ) ; // Bangkok ไม่มี DST ใช้ 86400000 ได้
3128 return { start, end } ;
3229}
3330
34- // ไล่เฉดสีจากสีหลัก (โทนเดียวคนละความเข้ม)
31+ /** ไล่เฉดสีจากสีหลัก (โทนเดียวคนละความเข้ม) */
3532function generateMonochromeShades ( base : string ) : string [ ] {
36- // รองรับรูปแบบ #RRGGBB
3733 const hex = base . replace ( "#" , "" ) ;
3834 const r = parseInt ( hex . slice ( 0 , 2 ) , 16 ) ;
3935 const g = parseInt ( hex . slice ( 2 , 4 ) , 16 ) ;
4036 const b = parseInt ( hex . slice ( 4 , 6 ) , 16 ) ;
41-
4237 const mix = ( t : number ) => {
43- // t = 0 (เข้ม) → 1 (อ่อน)
4438 const bg = 255 ;
4539 const rr = Math . round ( r * ( 1 - t ) + bg * t ) ;
4640 const gg = Math . round ( g * ( 1 - t ) + bg * t ) ;
@@ -49,14 +43,22 @@ function generateMonochromeShades(base: string): string[] {
4943 . toString ( 16 )
5044 . padStart ( 2 , "0" ) } ${ bb . toString ( 16 ) . padStart ( 2 , "0" ) } `. toUpperCase ( ) ;
5145 } ;
52-
53- // 4 เฉดจากเข้ม → อ่อน (ปรับค่าตามรสนิยม)
5446 return [ mix ( 0.15 ) , mix ( 0.35 ) , mix ( 0.55 ) , mix ( 0.78 ) ] ;
5547}
5648
49+ /** ย่อ label โดยไม่พึ่ง Date (กัน timezone เพี้ยน) */
50+ function shortLabel ( ymd : string ) : string {
51+ if ( ! / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( ymd ) ) return ymd ;
52+ const [ y , m , d ] = ymd . split ( "-" ) ;
53+ const months = [ "Jan" , "Feb" , "Mar" , "Apr" , "May" , "Jun" , "Jul" , "Aug" , "Sep" , "Oct" , "Nov" , "Dec" ] ;
54+ return `${ d } ${ months [ Number ( m ) - 1 ] } ` ;
55+ }
56+
5757export default function AlertsTrendChart ( ) : React . ReactElement {
5858 const [ range , setRange ] = useState < QuickRange > ( "7d" ) ;
59- const [ { start, end } , setDates ] = useState ( getRangeDates ( "7d" ) ) ;
59+
60+ // เริ่มต้นเป็นค่าว่าง → ค่อยคำนวณบน client ใน useEffect (กัน hydration mismatch)
61+ const [ { start, end } , setDates ] = useState < { start : string ; end : string } > ( { start : "" , end : "" } ) ;
6062
6163 const [ raw , setRaw ] = useState < TrendItem [ ] > ( [ ] ) ;
6264 const [ series , setSeries ] = useState < NonNullable < ApexOptions [ "series" ] > > ( [ ] ) ;
@@ -72,19 +74,18 @@ export default function AlertsTrendChart(): React.ReactElement {
7274 return generateMonochromeShades ( primary ) ;
7375 } , [ ] ) ;
7476
75- // เปลี่ยนช่วงวัน → อัปเดตวันที่
77+ // เปลี่ยนช่วง → คำนวณวันที่ฝั่ง client เท่านั้น
7678 useEffect ( ( ) => {
77- setDates ( getRangeDates ( range ) ) ;
79+ setDates ( getRangeDatesClient ( range ) ) ;
7880 } , [ range ] ) ;
7981
80- // ดึงข้อมูล (ลองส่ง start/end ไปด้วย; ถ้าแบ็กเอนด์ยังไม่รับ ก็ยังกรองฝั่ง client ต่อ)
82+ // ดึงข้อมูลเมื่อมี start/end แล้ว
8183 useEffect ( ( ) => {
84+ if ( ! start || ! end ) return ;
8285 ( async ( ) => {
8386 try {
84- const url = `/api/alerts/analytics/trend?start=${ encodeURIComponent (
85- start
86- ) } &end=${ encodeURIComponent ( end ) } `;
87- const res = await fetch ( url ) ;
87+ const url = `/api/alerts/analytics/trend?start=${ encodeURIComponent ( start ) } &end=${ encodeURIComponent ( end ) } ` ;
88+ const res = await fetch ( url , { cache : "no-store" } ) ;
8889 const data : TrendItem [ ] = await res . json ( ) ;
8990 setRaw ( data ) ;
9091 } catch ( e ) {
@@ -94,12 +95,12 @@ export default function AlertsTrendChart(): React.ReactElement {
9495 } ) ( ) ;
9596 } , [ start , end ] ) ;
9697
97- // แปลงข้อมูล → categories + series (กรองตามช่วงวันอีกชั้น เผื่อ API ไม่รองรับ )
98+ // map raw → categories + series (กรองซ้ำตามช่วง )
9899 useEffect ( ( ) => {
100+ if ( ! start || ! end ) return ;
101+
99102 const inRange = raw . filter ( ( d ) => d . date >= start && d . date <= end ) ;
100- const sorted = [ ...inRange ] . sort (
101- ( a , b ) => new Date ( a . date ) . getTime ( ) - new Date ( b . date ) . getTime ( )
102- ) ;
103+ const sorted = [ ...inRange ] . sort ( ( a , b ) => a . date . localeCompare ( b . date ) ) ;
103104 const cats = sorted . map ( ( d ) => d . date ) ;
104105
105106 const getCount = ( arr : TrendPoint [ ] , sev : Severity ) =>
@@ -117,20 +118,9 @@ export default function AlertsTrendChart(): React.ReactElement {
117118 } , [ raw , start , end ] ) ;
118119
119120 const options : ApexOptions = {
120- chart : {
121- type : "bar" ,
122- stacked : true ,
123- toolbar : { show : true } ,
124- zoom : { enabled : true } ,
125- } ,
126- colors : palette , // โทนเดียวต่างเฉด
127- plotOptions : {
128- bar : {
129- horizontal : false ,
130- borderRadius : 6 ,
131- columnWidth : "55%" ,
132- } ,
133- } ,
121+ chart : { type : "bar" , stacked : true , toolbar : { show : true } , zoom : { enabled : true } } ,
122+ colors : palette ,
123+ plotOptions : { bar : { horizontal : false , borderRadius : 6 , columnWidth : "55%" } } ,
134124 dataLabels : { enabled : false } ,
135125 legend : {
136126 position : "bottom" ,
@@ -143,35 +133,16 @@ export default function AlertsTrendChart(): React.ReactElement {
143133 categories,
144134 labels : {
145135 style : { colors : Array ( categories . length ) . fill ( "#6B7280" ) , fontSize : "12px" } ,
146- // ถ้าอยากย่อรูปแบบวันที่: 2025-08-15 → 15 Aug
147- formatter : ( val : string ) => {
148- if ( ! / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( val ) ) return val ;
149- const d = new Date ( val + "T00:00:00" ) ;
150- return new Intl . DateTimeFormat ( "en-GB" , {
151- day : "2-digit" ,
152- month : "short" ,
153- } ) . format ( d ) ;
154- } ,
136+ formatter : ( val : string ) => shortLabel ( val ) ,
155137 } ,
156138 axisBorder : { color : "#E5E7EB" } ,
157139 axisTicks : { color : "#E5E7EB" } ,
158140 } ,
159- yaxis : {
160- labels : { style : { colors : [ "#6B7280" ] , fontSize : "12px" } } ,
161- } ,
162- grid : {
163- borderColor : "#F3F4F6" ,
164- yaxis : { lines : { show : true } } ,
165- xaxis : { lines : { show : false } } ,
166- } ,
141+ yaxis : { labels : { style : { colors : [ "#6B7280" ] , fontSize : "12px" } } } ,
142+ grid : { borderColor : "#F3F4F6" , yaxis : { lines : { show : true } } , xaxis : { lines : { show : false } } } ,
167143 fill : { opacity : 1 } ,
168144 tooltip : { theme : "light" } ,
169- responsive : [
170- {
171- breakpoint : 640 ,
172- options : { plotOptions : { bar : { columnWidth : "65%" } } } ,
173- } ,
174- ] ,
145+ responsive : [ { breakpoint : 640 , options : { plotOptions : { bar : { columnWidth : "65%" } } } } ] ,
175146 } ;
176147
177148 return (
@@ -192,11 +163,13 @@ export default function AlertsTrendChart(): React.ReactElement {
192163 </ button >
193164 ) ) }
194165
195- < span className = "ml-auto text-xs text-gray-500" >
196- Range: { start } → { end }
166+ { /* ใช้ suppressHydrationWarning กันกรณี edge ที่ SSR/CSR ต่างวันขณะเที่ยงคืน */ }
167+ < span className = "ml-auto text-xs text-gray-500" suppressHydrationWarning >
168+ Range: { start || "—" } → { end || "—" }
197169 </ span >
198170 </ div >
199171
172+ { /* chart แสดงเฉพาะตอน client อยู่แล้วเพราะ react-apexcharts ssr:false */ }
200173 < ReactApexChart options = { options } series = { series } type = "bar" height = { 350 } />
201174 </ div >
202175 ) ;
0 commit comments