- {pagedResult.items.map((device) => (
-
-
-
-
-
-
- {device.deviceName}
-
-
- {device.location || 'بدون موقعیت'}
-
-
-
{device.userName} {device.userFamily}
+ {pagedResult.items.map((device) => {
+ const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
+ return (
+
+
+
+
+
+
+
+ {device.deviceName}
+
+
+ {device.location || 'بدون موقعیت'}
+
+
+ {device.userName} {device.userFamily}
+
+
+
+
-
-
-
-
-
-
- ))}
+
+
+ )
+ })}
{/* Pagination */}
diff --git a/src/app/globals.css b/src/app/globals.css
index a72a6d1..e188e3e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -35,4 +35,76 @@ body {
font-feature-settings: 'ss01';
font-variant-numeric: persian;
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
+}
+
+/* Markdown content styles */
+.markdown-content {
+ line-height: 1.8;
+ color: #374151;
+}
+
+.markdown-content h1,
+.markdown-content h2,
+.markdown-content h3,
+.markdown-content h4 {
+ color: #1f2937;
+ font-weight: 700;
+ margin-top: 1.5em;
+ margin-bottom: 0.5em;
+}
+
+.markdown-content h1 {
+ font-size: 1.5rem;
+}
+
+.markdown-content h2 {
+ font-size: 1.25rem;
+}
+
+.markdown-content h3 {
+ font-size: 1.125rem;
+}
+
+.markdown-content h4 {
+ font-size: 1rem;
+}
+
+.markdown-content p {
+ margin-bottom: 1em;
+}
+
+.markdown-content ul,
+.markdown-content ol {
+ margin: 0.75em 0;
+ padding-right: 1.5em;
+}
+
+.markdown-content li {
+ margin: 0.25em 0;
+}
+
+.markdown-content strong {
+ font-weight: 700;
+ color: #1f2937;
+}
+
+.markdown-content code {
+ background-color: #f3f4f6;
+ padding: 0.125em 0.375em;
+ border-radius: 0.25em;
+ font-size: 0.875em;
+}
+
+.markdown-content blockquote {
+ border-right: 4px solid #6366f1;
+ padding-right: 1em;
+ margin: 1em 0;
+ color: #6b7280;
+ font-style: italic;
+}
+
+.markdown-content hr {
+ border: none;
+ border-top: 1px solid #e5e7eb;
+ margin: 1.5em 0;
}
\ No newline at end of file
diff --git a/src/app/telemetry/GreenHomeBack.code-workspace b/src/app/telemetry/GreenHomeBack.code-workspace
new file mode 100644
index 0000000..e7c8d97
--- /dev/null
+++ b/src/app/telemetry/GreenHomeBack.code-workspace
@@ -0,0 +1,10 @@
+{
+ "folders": [
+ {
+ "path": "../../../../GreenHomeBack"
+ },
+ {
+ "path": "../../.."
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/app/telemetry/page.tsx b/src/app/telemetry/page.tsx
deleted file mode 100644
index ff92c4b..0000000
--- a/src/app/telemetry/page.tsx
+++ /dev/null
@@ -1,315 +0,0 @@
-"use client"
-import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
-import { api, TelemetryDto } from '@/lib/api'
-import { Card, DashboardGrid } from '@/components/DashboardCards'
-import { LineChart, Panel } from '@/components/Charts'
-import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
-import { BarChart3, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'
-import Link from 'next/link'
-import Loading from '@/components/Loading'
-
-// زمان بهروزرسانی خودکار (به میلیثانیه) - میتوانید این مقدار را تغییر دهید
-const AUTO_REFRESH_INTERVAL = 10 * 1000 // 10 ثانیه
-
-type TimeRange = 'today' | '1day' | '1hour' | '2hours' | '6hours' | '10hours'
-
-const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
- { value: 'today', label: 'امروز' },
- { value: '1day', label: '۲۴ ساعت اخیر' },
- { value: '10hours', label: '۱۰ ساعت اخیر' },
- { value: '6hours', label: '۶ ساعت اخیر' },
- { value: '2hours', label: 'دو ساعت اخیر' },
- { value: '1hour', label: 'یک ساعت اخیر' },
-]
-
-function useQueryParam(name: string) {
- if (typeof window === 'undefined') return null as string | null
- return new URLSearchParams(window.location.search).get(name)
-}
-
-export default function TelemetryPage() {
- const [telemetry, setTelemetry] = useState
([])
- const [total, setTotal] = useState(0)
- const [loading, setLoading] = useState(true)
- const [lastUpdate, setLastUpdate] = useState(null)
- const [timeRange, setTimeRange] = useState('1day')
- const deviceId = Number(useQueryParam('deviceId') ?? '1')
- const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}`
- const intervalRef = useRef(null)
-
- const selectedDate = useMemo(() => {
- if (!dateParam) return null
- try {
- const decodedDate = decodeURIComponent(dateParam)
- return decodedDate
- } catch (error) {
- console.error('Error decoding date parameter:', error)
- return null
- }
- }, [dateParam])
-
- // تابع بارگذاری دادهها
- const loadData = useCallback(async (showLoading = true) => {
- if (!selectedDate) {
- setLoading(false)
- return
- }
-
- if (showLoading) {
- setLoading(true)
- }
-
- try {
- let startDate: Date
- let endDate: Date
-
- if (timeRange === 'today') {
- // برای یک روز، از تاریخ انتخابی استفاده میکنیم
- const [year, month, day] = selectedDate.split('/').map(Number)
- startDate = persianToGregorian(year, month, day)
- startDate.setHours(0, 0, 0, 0)
- endDate = new Date(startDate)
- endDate.setHours(23, 59, 59, 999)
- } else {
- // برای بازههای زمانی دیگر، از زمان فعلی به عقب میرویم
- endDate = new Date()
- const now = new Date()
-
- switch (timeRange) {
- case '1day':
- startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
- break
- case '1hour':
- startDate = new Date(now.getTime() - 60 * 60 * 1000)
- break
- case '2hours':
- startDate = new Date(now.getTime() - 2 * 60 * 60 * 1000)
- break
- case '6hours':
- startDate = new Date(now.getTime() - 6 * 60 * 60 * 1000)
- break
- case '10hours':
- startDate = new Date(now.getTime() - 10 * 60 * 60 * 1000)
- break
- default:
- startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
- }
- }
-
- const startUtc = startDate.toISOString();
- const endUtc = endDate.toISOString();
-
- const result = await api.listTelemetry({ deviceId, startUtc, endUtc, pageSize: 100000 })
- setTelemetry(result.items)
- setTotal(result.totalCount)
- setLastUpdate(new Date())
- } catch (error) {
- console.error('Error loading telemetry:', error)
- } finally {
- if (showLoading) {
- setLoading(false)
- }
- }
- }, [deviceId, selectedDate, timeRange])
-
- // بارگذاری اولیه
- useEffect(() => {
- loadData(true)
- }, [loadData])
-
- // تنظیم بهروزرسانی خودکار
- useEffect(() => {
- if (!selectedDate) return
-
- // پاک کردن interval قبلی
- if (intervalRef.current) {
- clearInterval(intervalRef.current)
- }
-
- // تنظیم interval جدید
- intervalRef.current = setInterval(() => {
- loadData(false) // بدون نمایش loading
- }, AUTO_REFRESH_INTERVAL)
-
- // Cleanup
- return () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current)
- }
- }
- }, [selectedDate, loadData])
-
- const sortedTelemetry = useMemo(() => {
- return [...telemetry].sort((a, b) => {
- const aTime = a.serverTimestampUtc || a.timestampUtc
- const bTime = b.serverTimestampUtc || b.timestampUtc
- return new Date(aTime).getTime() - new Date(bTime).getTime()
- })
- }, [telemetry])
-
- // تبدیل timestamp به ساعت (HH:MM:SS)
- const labels = useMemo(() => {
- return sortedTelemetry.map(t => {
- const timestamp = t.serverTimestampUtc || t.timestampUtc
- const date = new Date(timestamp)
- const hours = date.getHours().toString().padStart(2, '0')
- const minutes = date.getMinutes().toString().padStart(2, '0')
- const seconds = date.getSeconds().toString().padStart(2, '0')
- return `${hours}:${minutes}:${seconds}`
- })
- }, [sortedTelemetry])
- const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry])
- const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry])
- const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry])
- const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
- const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
-
-
- const tempMinMax = useMemo(() => {
- const min = Math.min(...temp);
- const max = Math.max(...temp);
- return {
- min: min < 0 ? Math.floor(min / 10) * 10 : 0,
- max: max > 40 ? Math.floor(max / 10) * 10 : 40
- }
- }, [temp]);
-
- const luxMinMax = useMemo(() => {
- const max = Math.max(...lux);
- return {
- min: 0,
- max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
- }
- }, [lux]);
-
-
- if (loading) {
- return
- }
-
- if (!selectedDate) {
- return (
-
-
-
-
تاریخ انتخاب نشده است
-
-
- بازگشت به تقویم
-
-
-
- )
- }
-
- return (
-
-
- {/* Header */}
-
-
-
- بازگشت به تقویم
-
-
-
-
-
-
-
-
- جزئیات دادههای {selectedDate}
-
- {lastUpdate && (
-
- آخرین بهروزرسانی: {lastUpdate.toLocaleTimeString('fa-IR')}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
diff --git a/src/components/Charts.tsx b/src/components/Charts.tsx
index aa4df53..af1a074 100644
--- a/src/components/Charts.tsx
+++ b/src/components/Charts.tsx
@@ -1,4 +1,5 @@
"use client"
+import React from 'react'
import { Line } from 'react-chartjs-2'
import {
Chart,
@@ -10,8 +11,9 @@ import {
Tooltip,
Filler
} from 'chart.js'
+import annotationPlugin from 'chartjs-plugin-annotation'
-Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler)
+Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler, annotationPlugin)
// تابع تبدیل ارقام انگلیسی به فارسی
function toPersianDigits(str: string | number): string {
@@ -19,7 +21,12 @@ function toPersianDigits(str: string | number): string {
return str.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
}
-type Series = { label: string; data: number[]; borderColor: string; backgroundColor?: string; fill?: boolean }
+type Series = { label: string; data: (number | null)[]; borderColor: string; backgroundColor?: string; fill?: boolean }
+
+type DataGapAnnotation = {
+ startIndex: number
+ endIndex: number
+}
type Props = {
labels: string[]
@@ -27,24 +34,102 @@ type Props = {
title?: string
yAxisMin?: number
yAxisMax?: number
+ dataGaps?: DataGapAnnotation[] // Indices where gaps occur
}
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
-
-
{title}
+
+
{title}
-
)
}
-export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
+export function LineChart({ labels, series, yAxisMin, yAxisMax, dataGaps = [] }: Props) {
+ // Find gap annotations based on null values in data
+ const gapAnnotations = React.useMemo(() => {
+ const annotations: any = {}
+ let gapCount = 0
+
+ // Find nulls in the first series data
+ if (series.length > 0) {
+ const data = series[0].data
+ for (let i = 0; i < data.length; i++) {
+ if (data[i] === null) {
+ // Find the gap range
+ const startIdx = Math.max(0, i - 1)
+ const endIdx = Math.min(data.length - 1, i + 1)
+
+ annotations[`gap-${gapCount++}`] = {
+ type: 'box' as const,
+ xMin: startIdx,
+ xMax: endIdx,
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
+ borderColor: 'rgba(239, 68, 68, 0.3)',
+ borderWidth: 1,
+ borderDash: [5, 5],
+ label: {
+ display: false
+ }
+ }
+ }
+ }
+ }
+
+ return annotations
+ }, [series])
+
+ // Calculate hour range and determine which hours to show
+ const hourConfig = React.useMemo(() => {
+ const validLabels = labels.filter(l => l)
+ if (validLabels.length === 0) {
+ return { startHour: 0, endHour: 24, hours: Array.from({length: 25}, (_, i) => i) }
+ }
+
+ const firstTime = validLabels[0]
+ const lastTime = validLabels[validLabels.length - 1]
+
+ if (!firstTime || !lastTime) {
+ return { startHour: 0, endHour: 24, hours: Array.from({length: 25}, (_, i) => i) }
+ }
+
+ const [firstHour] = firstTime.split(':').map(Number)
+ const [lastHour] = lastTime.split(':').map(Number)
+
+ // Create array of hours to show (from start to end, one per hour)
+ const startHour = firstHour
+ let endHour = lastHour
+
+ // If last hour is the same as first, extend to next hour
+ if (endHour <= startHour) {
+ endHour = startHour + 24
+ }
+
+ // Create array of hours
+ const hours: number[] = []
+ for (let h = startHour; h <= endHour; h++) {
+ hours.push(h % 24)
+ }
+
+ return { startHour, endHour, hours }
+ }, [labels])
+
+ // Create map of label index to hour
+ const labelHours = React.useMemo(() => {
+ return labels.map(label => {
+ if (!label) return null
+ const [hour] = label.split(':').map(Number)
+ return hour
+ })
+ }, [labels])
+
return (
-
+
h === hour)
+
+ // Only show label if this is the first occurrence of this hour
+ if (firstIndexOfHour !== index) return ''
+
+ // Show only the hour number
+ return toPersianDigits(hour.toString())
}
},
grid: {
+ display: false // Remove grid lines
+ },
+ border: {
display: true,
- color: 'rgba(0, 0, 0, 0.05)'
+ color: 'rgba(0, 0, 0, 0.1)'
}
},
y: {
@@ -118,15 +225,19 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
ticks: {
font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
- size: 11
+ size: 10
},
+ padding: 8,
callback: function(value) {
return toPersianDigits(value.toString())
}
},
grid: {
+ display: false // Remove grid lines
+ },
+ border: {
display: true,
- color: 'rgba(0, 0, 0, 0.05)'
+ color: 'rgba(0, 0, 0, 0.1)'
}
}
}
diff --git a/src/components/Gauges.tsx b/src/components/Gauges.tsx
new file mode 100644
index 0000000..5393186
--- /dev/null
+++ b/src/components/Gauges.tsx
@@ -0,0 +1,330 @@
+"use client"
+
+// تابع تبدیل ارقام انگلیسی به فارسی
+function toPersianDigits(num: number | string): string {
+ const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
+ return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
+}
+
+// Temperature Gauge - Semi-circular gauge with blue to red gradient
+type TemperatureGaugeProps = {
+ value: number
+ min?: number
+ max?: number
+}
+
+export function TemperatureGauge({ value, min = -20, max = 80 }: TemperatureGaugeProps) {
+ const range = max - min
+ const percentage = Math.max(0, Math.min(100, ((value - min) / range) * 100))
+ const valueAngle = (percentage / 100) * 180 // 0 to 180 degrees
+
+ // Create gradient stops for temperature (blue to red)
+ const gradientId = `temp-gradient-${Math.random().toString(36).substr(2, 9)}`
+ const glowId = `temp-glow-${Math.random().toString(36).substr(2, 9)}`
+
+ // Center point and radius for the arc
+ const cx = 75
+ const cy = 75
+ const radius = 50
+ const gaugeWidth = 18
+
+ // Calculate pointer position (triangle pointing inward from outside)
+ const pointerAngle = (180 - valueAngle) * (Math.PI / 180)
+ const pointerOuterRadius = radius - gaugeWidth / 2 - 2
+ const pointerX = cx + pointerOuterRadius * Math.cos(pointerAngle)
+ const pointerY = cy - pointerOuterRadius * Math.sin(pointerAngle)
+
+ // Triangle points for the pointer
+ const triangleSize = 6
+ const perpAngle = pointerAngle + Math.PI / 2
+ const p1x = pointerX + triangleSize * Math.cos(perpAngle)
+ const p1y = pointerY - triangleSize * Math.sin(perpAngle)
+ const p2x = pointerX - triangleSize * Math.cos(perpAngle)
+ const p2y = pointerY + triangleSize * Math.sin(perpAngle)
+ const p3x = cx + (pointerOuterRadius - 12) * Math.cos(pointerAngle)
+ const p3y = cy - (pointerOuterRadius - 12) * Math.sin(pointerAngle)
+
+ // Generate tick marks and labels every 10 degrees
+ const ticks = []
+ for (let temp = min; temp <= max; temp += 10) {
+ const tickPercentage = ((temp - min) / range) * 100
+ const tickAngle = (180 - (tickPercentage / 100) * 180) * (Math.PI / 180)
+ // خطوط بیرون گیج با فاصله کم - کوتاهتر (نصف)
+ const innerX = cx + (radius + gaugeWidth / 2 + 3) * Math.cos(tickAngle)
+ const innerY = cy - (radius + gaugeWidth / 2 + 3) * Math.sin(tickAngle)
+ const outerX = cx + (radius + gaugeWidth / 2 + 6) * Math.cos(tickAngle)
+ const outerY = cy - (radius + gaugeWidth / 2 + 6) * Math.sin(tickAngle)
+ const labelX = cx + (radius + gaugeWidth / 2 + 14) * Math.cos(tickAngle)
+ const labelY = cy - (radius + gaugeWidth / 2 + 14) * Math.sin(tickAngle)
+
+ ticks.push({ temp, outerX, outerY, innerX, innerY, labelX, labelY })
+ }
+
+ return (
+
+
+
+ {/* Center value */}
+
+ {toPersianDigits(value.toFixed(1))}
+ °C
+
+
+ )
+}
+
+// Humidity Gauge - Water droplet style fill
+type HumidityGaugeProps = {
+ value: number
+ type?: 'air' | 'soil'
+}
+
+export function HumidityGauge({ value, type = 'air' }: HumidityGaugeProps) {
+ const percentage = Math.max(0, Math.min(100, value))
+ const fillColor = type === 'air' ? '#3b82f6' : '#16a34a'
+ const bgColor = type === 'air' ? '#dbeafe' : '#dcfce7'
+ const textColor = type === 'air' ? '#1e40af' : '#166534'
+ const gradientId = `humidity-gradient-${Math.random().toString(36).substr(2, 9)}`
+ const clipId = `humidity-clip-${Math.random().toString(36).substr(2, 9)}`
+
+ return (
+
+
+
+ )
+}
+
+// Light/Lux Gauge - Sun rays style
+type LuxGaugeProps = {
+ value: number
+ max?: number
+}
+
+export function LuxGauge({ value, max = 2000 }: LuxGaugeProps) {
+ const percentage = Math.max(0, Math.min(100, (value / max) * 100))
+ const numRays = 12
+ const activeRays = Math.round((percentage / 100) * numRays)
+
+ return (
+
+
+
+ {/* Value below */}
+
+ {toPersianDigits(Math.round(value))}
+ Lux
+
+
+ )
+}
+
+// Gas/CO Gauge - Circular progress with warning colors
+type GasGaugeProps = {
+ value: number
+ max?: number
+}
+
+export function GasGauge({ value, max = 100 }: GasGaugeProps) {
+ const percentage = Math.max(0, Math.min(100, (value / max) * 100))
+ const circumference = 2 * Math.PI * 35
+ const strokeDashoffset = circumference - (percentage / 100) * circumference
+
+ // Color based on level (green -> yellow -> orange -> red)
+ const getColor = () => {
+ if (percentage < 25) return '#22c55e'
+ if (percentage < 50) return '#eab308'
+ if (percentage < 75) return '#f97316'
+ return '#ef4444'
+ }
+
+ const color = getColor()
+
+ return (
+
+
+
+ {/* Center content */}
+
+ {toPersianDigits(Math.round(value))}
+ ppm
+
+
+ )
+}
+
diff --git a/src/components/MiniChart.tsx b/src/components/MiniChart.tsx
new file mode 100644
index 0000000..3b68946
--- /dev/null
+++ b/src/components/MiniChart.tsx
@@ -0,0 +1,70 @@
+"use client"
+import { Line } from 'react-chartjs-2'
+import {
+ Chart,
+ LineElement,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ Filler
+} from 'chart.js'
+
+Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Filler)
+
+type MiniLineChartProps = {
+ data: number[]
+ color: string
+}
+
+export function MiniLineChart({ data, color }: MiniLineChartProps) {
+ // Sample data if too many points (for performance)
+ const sampledData = data.length > 50
+ ? data.filter((_, i) => i % Math.ceil(data.length / 50) === 0)
+ : data
+
+ // Create slightly darker color for fill
+ const fillColor = `${color}33` // 20% opacity
+
+ return (
+
+ i.toString()),
+ datasets: [{
+ data: sampledData,
+ borderColor: color,
+ backgroundColor: fillColor,
+ fill: true,
+ tension: 0.4,
+ pointRadius: 0,
+ borderWidth: 2
+ }]
+ }}
+ options={{
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: { enabled: false }
+ },
+ scales: {
+ x: {
+ display: false,
+ grid: { display: false }
+ },
+ y: {
+ display: false,
+ grid: { display: false }
+ }
+ },
+ interaction: {
+ intersect: false,
+ mode: 'nearest'
+ },
+ events: [] // Disable all interactions
+ }}
+ />
+
+ )
+}
+
diff --git a/src/components/daily-report/AnalysisTab.tsx b/src/components/daily-report/AnalysisTab.tsx
new file mode 100644
index 0000000..518c4aa
--- /dev/null
+++ b/src/components/daily-report/AnalysisTab.tsx
@@ -0,0 +1,80 @@
+import { Loader2, AlertCircle, RefreshCw } from 'lucide-react'
+import ReactMarkdown from 'react-markdown'
+import { DailyReportDto } from '@/lib/api'
+import { toPersianDigits } from './utils'
+
+type AnalysisTabProps = {
+ loading: boolean
+ error: string | null
+ dailyReport: DailyReportDto | null
+ onRetry: () => void
+}
+
+export function AnalysisTab({ loading, error, dailyReport, onRetry }: AnalysisTabProps) {
+ if (loading) {
+ return (
+
+
+
در حال دریافت تحلیل...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
{error}
+
+
+ )
+ }
+
+ if (!dailyReport) {
+ return (
+
+
تحلیلی برای نمایش وجود ندارد
+
+ )
+ }
+
+ return (
+
+ {/* Report Info */}
+
+
+
+
تعداد رکوردها
+
{toPersianDigits(dailyReport.recordCount)}
+
+
+
رکوردهای نمونه
+
{toPersianDigits(dailyReport.sampledRecordCount)}
+
+
+
توکنهای مصرفی
+
{toPersianDigits(dailyReport.totalTokens)}
+
+
+
منبع
+
{dailyReport.fromCache ? '💾 کش' : '🆕 جدید'}
+
+
+
+
+ {/* Analysis Content */}
+
+ {dailyReport.analysis}
+
+
+ )
+}
+
diff --git a/src/components/daily-report/ChartsTab.tsx b/src/components/daily-report/ChartsTab.tsx
new file mode 100644
index 0000000..c5d4b19
--- /dev/null
+++ b/src/components/daily-report/ChartsTab.tsx
@@ -0,0 +1,104 @@
+import { BarChart3 } from 'lucide-react'
+import { LineChart, Panel } from '@/components/Charts'
+import { TimeRangeSelector } from './TimeRangeSelector'
+import { DataGap } from './utils'
+
+type ChartsTabProps = {
+ chartStartMinute: number
+ chartEndMinute: number
+ onStartMinuteChange: (minute: number) => void
+ onEndMinuteChange: (minute: number) => void
+ labels: string[]
+ soil: (number | null)[]
+ humidity: (number | null)[]
+ temperature: (number | null)[]
+ lux: (number | null)[]
+ gas: (number | null)[]
+ tempMinMax: { min: number; max: number }
+ luxMinMax: { min: number; max: number }
+ totalRecords: number
+ dataGaps?: DataGap[]
+}
+
+export function ChartsTab({
+ chartStartMinute,
+ chartEndMinute,
+ onStartMinuteChange,
+ onEndMinuteChange,
+ labels,
+ soil,
+ humidity,
+ temperature,
+ lux,
+ gas,
+ tempMinMax,
+ luxMinMax,
+ totalRecords,
+ dataGaps = []
+}: ChartsTabProps) {
+ return (
+
+ {/* Time Range Selector */}
+
+
+ {/* Charts Grid */}
+ {totalRecords === 0 ? (
+
+
+
دادهای برای این بازه زمانی موجود نیست
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
diff --git a/src/components/daily-report/SummaryCard.tsx b/src/components/daily-report/SummaryCard.tsx
new file mode 100644
index 0000000..3582a13
--- /dev/null
+++ b/src/components/daily-report/SummaryCard.tsx
@@ -0,0 +1,82 @@
+import { TrendingUp, TrendingDown } from 'lucide-react'
+import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges'
+import { MiniLineChart } from '@/components/MiniChart'
+import { paramConfig, toPersianDigits } from './utils'
+
+type SummaryCardProps = {
+ param: string
+ currentValue: number
+ minValue: number
+ maxValue: number
+ data: number[]
+}
+
+export function SummaryCard({ param, currentValue, minValue, maxValue, data }: SummaryCardProps) {
+ const config = paramConfig[param]
+ if (!config) return null
+
+ // Render the appropriate gauge based on parameter type
+ const renderGauge = () => {
+ switch (param) {
+ case 'temperature':
+ return
+ case 'humidity':
+ return
+ case 'soil':
+ return
+ case 'lux':
+ return
+ case 'gas':
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
{config.title}
+
+
+ {/* Main Content - Gauge on left, Stats on right */}
+
+
+ {/* Gauge on left */}
+
+ {renderGauge()}
+
+
+ {/* Stats on right - stacked */}
+
+
+
+
+ حداکثر
+
+ {toPersianDigits(maxValue.toFixed(1))} {config.unit}
+
+
+
+
+
+
+ حداقل
+
+ {toPersianDigits(minValue.toFixed(1))} {config.unit}
+
+
+
+
+
+
+
+ {/* Mini Chart */}
+
+
+
+
+ )
+}
+
diff --git a/src/components/daily-report/SummaryTab.tsx b/src/components/daily-report/SummaryTab.tsx
new file mode 100644
index 0000000..a6a9004
--- /dev/null
+++ b/src/components/daily-report/SummaryTab.tsx
@@ -0,0 +1,77 @@
+import { SummaryCard } from './SummaryCard'
+
+type SummaryTabProps = {
+ temperature: {
+ current: number
+ min: number
+ max: number
+ data: number[]
+ }
+ humidity: {
+ current: number
+ min: number
+ max: number
+ data: number[]
+ }
+ soil: {
+ current: number
+ min: number
+ max: number
+ data: number[]
+ }
+ gas: {
+ current: number
+ min: number
+ max: number
+ data: number[]
+ }
+ lux: {
+ current: number
+ min: number
+ max: number
+ data: number[]
+ }
+}
+
+export function SummaryTab({ temperature, humidity, soil, gas, lux }: SummaryTabProps) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/components/daily-report/TimeRangeSelector.tsx b/src/components/daily-report/TimeRangeSelector.tsx
new file mode 100644
index 0000000..49661c2
--- /dev/null
+++ b/src/components/daily-report/TimeRangeSelector.tsx
@@ -0,0 +1,265 @@
+import { BarChart3, AlertTriangle } from 'lucide-react'
+import { toPersianDigits, DataGap } from './utils'
+
+type TimeRangeSelectorProps = {
+ startMinute: number // دقیقه از نیمه شب (0-1439)
+ endMinute: number // دقیقه از نیمه شب (0-1439)
+ onStartMinuteChange: (minute: number) => void
+ onEndMinuteChange: (minute: number) => void
+ totalRecords: number
+ dataGaps?: DataGap[] // گپهای داده
+}
+
+// محاسبه زمان طلوع و غروب خورشید برای قم
+// عرض جغرافیایی: 34.6416° شمالی، طول جغرافیایی: 50.8746° شرقی
+function calculateSunTimes() {
+ const latitude = 34.6416
+ const now = new Date()
+ const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000)
+
+ // محاسبه انحراف خورشید (Solar Declination)
+ const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10))
+
+ // محاسبه زاویه ساعتی طلوع (Hour Angle)
+ const latRad = latitude * Math.PI / 180
+ const decRad = declination * Math.PI / 180
+ const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad)
+
+ // در صورتی که خورشید طلوع/غروب میکند
+ if (Math.abs(cosHourAngle) <= 1) {
+ const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI
+
+ // زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق)
+ const sunriseDecimal = 12 - hourAngle / 15 + (50.8746 / 15 - 3.5) // تصحیح برای طول جغرافیایی و منطقه زمانی ایران
+ const sunsetDecimal = 12 + hourAngle / 15 + (50.8746 / 15 - 3.5)
+
+ // تبدیل به ساعت و دقیقه
+ const sunriseHour = Math.floor(sunriseDecimal)
+ const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60)
+
+ const sunsetHour = Math.floor(sunsetDecimal)
+ const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60)
+
+ return {
+ sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal },
+ sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal }
+ }
+ }
+
+ // مقادیر پیشفرض
+ return {
+ sunrise: { hour: 6, minute: 0, decimal: 6 },
+ sunset: { hour: 18, minute: 0, decimal: 18 }
+ }
+}
+
+export function TimeRangeSelector({
+ startMinute,
+ endMinute,
+ onStartMinuteChange,
+ onEndMinuteChange,
+ totalRecords,
+ dataGaps = []
+}: TimeRangeSelectorProps) {
+ const { sunrise, sunset } = calculateSunTimes()
+
+ // تبدیل دقیقه به ساعت برای نمایش
+ const startHour = Math.floor(startMinute / 60)
+ const startMin = startMinute % 60
+ const endHour = Math.floor(endMinute / 60)
+ const endMin = endMinute % 60
+
+ // محاسبه موقعیت دقیق با دقیقه (از 0 تا 24 ساعت)
+ const sunrisePosition = sunrise.decimal
+ const sunsetPosition = sunset.decimal
+
+ // محاسبه درصد موقعیت برای نمایش (0 ساعت در راست، 1440 دقیقه در چپ)
+ const sunrisePercent = ((1439 - (sunrisePosition * 60)) / 1439) * 100
+ const sunsetPercent = ((1439 - (sunsetPosition * 60)) / 1439) * 100
+
+ return (
+
+ {/* Header */}
+
+
+
محدوده زمانی
+ {dataGaps.length > 0 && (
+
+
+
{toPersianDigits(dataGaps.length)} گپ در دادهها
+
+ )}
+
+
+
+
+ {/* Timeline Selector */}
+
+ {/* Track background */}
+
+ {/* Sunrise dashed line */}
+
+
+ {/* Sunset dashed line */}
+
+
+
+ طلوع {toPersianDigits(sunrise.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunrise.minute.toString().padStart(2, '0'))}
+
+
+
+
+
+
+
+
+ {/* Data gaps visualization */}
+ {dataGaps.map((gap, idx) => {
+ const gapStartPercent = ((1439 - gap.startMinute) / 1439) * 100
+ const gapEndPercent = ((1439 - gap.endMinute) / 1439) * 100
+ const gapWidth = gapStartPercent - gapEndPercent
+ const gapHours = Math.floor(gap.durationMinutes / 60)
+ const gapMins = gap.durationMinutes % 60
+
+ return (
+
+ {/* Gap area */}
+
+ {/* Warning icon in gap */}
+
+
+
+ {/* Gap tooltip */}
+ {gapWidth > 5 && (
+
+ گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))}
+
+ )}
+
+ )
+ })}
+
+
+ غروب {toPersianDigits(sunset.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunset.minute.toString().padStart(2, '0'))}
+
+
+ {/* Hour markers inside track */}
+ {[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map(hour => (
+
+
+
+ {toPersianDigits(hour.toString().padStart(2, '0'))}
+
+
+
+
+ ))}
+
+
+ {/* Start time label - above handle */}
+
+
+ {toPersianDigits(startHour.toString().padStart(2, '0'))}:{toPersianDigits(startMin.toString().padStart(2, '0'))}
+
+
+
+ {/* End time label - above handle */}
+
+
+ {toPersianDigits(endHour.toString().padStart(2, '0'))}:{toPersianDigits(endMin.toString().padStart(2, '0'))}
+
+
+
+ {/* Range inputs container */}
+
+ {/* Start time slider */}
+ {
+ const val = 1439 - Number(e.target.value)
+ if (val <= endMinute) onStartMinuteChange(val)
+ }}
+ className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-emerald-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
+ />
+
+ {/* End time slider */}
+ {
+ const val = 1439 - Number(e.target.value)
+ if (val >= startMinute) onEndMinuteChange(val)
+ }}
+ className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-rose-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-rose-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
+ />
+
+
+
+ {/* Info section */}
+
+
+
+ {toPersianDigits(Math.floor((endMinute - startMinute + 1) / 60))}:{toPersianDigits(((endMinute - startMinute + 1) % 60).toString().padStart(2, '0'))}
+
+ بازه انتخاب شده
+
+
+ {totalRecords > 0
+ ? <>{toPersianDigits(totalRecords)} رکورد>
+ : <>بدون رکورد>
+ }
+
+
+
+ )
+}
+
diff --git a/src/components/daily-report/WeatherTab.tsx b/src/components/daily-report/WeatherTab.tsx
new file mode 100644
index 0000000..274495b
--- /dev/null
+++ b/src/components/daily-report/WeatherTab.tsx
@@ -0,0 +1,516 @@
+import { Loader2, AlertCircle, RefreshCw, MapPin, Droplets, Wind, Thermometer, Sun, CloudRain, Calendar as CalendarIcon, ChevronDown } from 'lucide-react'
+import { WeatherData, toPersianDigits, getWeatherInfo, getPersianDayName, getGreenhouseAlerts } from '.'
+import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
+
+type WeatherTabProps = {
+ loading: boolean
+ error: string | null
+ weatherData: WeatherData | null
+ onRetry: () => void
+ expandedDayIndex: number | null
+ onDayToggle: (index: number | null) => void
+ selectedDate: string | null // Persian date in format "yyyy/MM/dd"
+}
+
+export function WeatherTab({
+ loading,
+ error,
+ weatherData,
+ onRetry,
+ expandedDayIndex,
+ onDayToggle,
+ selectedDate
+}: WeatherTabProps) {
+ // Check if selected date is today by comparing Persian dates
+ const isToday = (() => {
+ if (!selectedDate) return true
+
+ try {
+ // Get today's Persian date
+ const todayYear = getCurrentPersianYear()
+ const todayMonth = getCurrentPersianMonth()
+ const todayDay = getCurrentPersianDay()
+ const todayPersian = `${todayYear}/${String(todayMonth).padStart(2, '0')}/${String(todayDay).padStart(2, '0')}`
+
+ // Normalize selected date format
+ const [y, m, d] = selectedDate.split('/').map(s => s.trim())
+ const normalizedSelected = `${y}/${String(Number(m)).padStart(2, '0')}/${String(Number(d)).padStart(2, '0')}`
+
+ return normalizedSelected === todayPersian
+ } catch (e) {
+ console.error('Error checking if today:', e)
+ return true
+ }
+ })()
+
+ if (loading) {
+ return (
+
+
+
در حال دریافت اطلاعات آب و هوا...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
{error}
+
+
+ )
+ }
+
+ if (!weatherData) {
+ return null
+ }
+
+ const alerts = getGreenhouseAlerts(weatherData)
+
+ return (
+
+ {/* Location Header */}
+
+
+
+ قم، ایران
+
+
+ {isToday ? 'پیشبینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
+
+
+
+ {/* Greenhouse Alerts - Only for today */}
+ {isToday && alerts.length > 0 && (
+
+
🌱 هشدارها و توصیههای گلخانه
+ {alerts.map((alert, index) => (
+
+
{alert.title}
+
{alert.description}
+
+ ))}
+
+ )}
+
+ {/* Today's Status Card */}
+
+
+ {/* Current Weather Header - Only for today */}
+ {isToday && (
+
+
+
+
🌡️ الان
+
+
+ {toPersianDigits(Math.round(weatherData.current.temperature))}
+
+ درجه
+
+
+
+ {(() => {
+ const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
+ return
+ })()}
+
{getWeatherInfo(weatherData.current.weatherCode).description}
+
+
+
+ )}
+
+ {/* Past Date Header */}
+ {!isToday && (
+
+
+
📅 وضعیت آب و هوای روز
+
{selectedDate}
+
+
+ )}
+
+ {/* Status Grid */}
+
+ {/* Temperature Card */}
+
35 ? 'bg-red-100 border-2 border-red-300' :
+ 'bg-green-100 border-2 border-green-300'
+ }`}>
+
+
35 ? 'bg-red-500' :
+ 'bg-green-500'
+ }`}>
+
+
+
+
{isToday ? 'دمای امروز' : 'دمای روز'}
+
35 ? 'text-red-600' :
+ 'text-green-600'
+ }`}>
+ {weatherData.daily[0]?.tempMin < 5 ? '❄️ سرد!' :
+ weatherData.daily[0]?.tempMax > 35 ? '🔥 گرم!' :
+ '✅ مناسب'}
+
+
+
+
+
+
🌙 شب
+
{toPersianDigits(Math.round(weatherData.daily[0]?.tempMin || 0))}°
+
+
←
+
+
☀️ روز
+
{toPersianDigits(Math.round(weatherData.daily[0]?.tempMax || 0))}°
+
+
+
+
+ {/* Rain Card */}
+ {isToday ? (
+ /* Forecast: احتمال بارش */
+
60 ? 'bg-blue-100 border-2 border-blue-300' :
+ (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-50 border-2 border-sky-200' :
+ 'bg-amber-50 border-2 border-amber-200'
+ }`}>
+
+
60 ? 'bg-blue-500' :
+ (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-400' :
+ 'bg-amber-400'
+ }`}>
+ {(weatherData.daily[0]?.precipitationProbability || 0) > 30 ?
+ :
+
+ }
+
+
+
بارش
+
60 ? 'text-blue-600' :
+ (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'text-sky-600' :
+ 'text-amber-600'
+ }`}>
+ {(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? '🌧️ باران میآید' :
+ (weatherData.daily[0]?.precipitationProbability || 0) > 30 ? '🌦️ شاید ببارد' :
+ '☀️ خشک است'}
+
+
+
+
+
{toPersianDigits(weatherData.daily[0]?.precipitationProbability || 0)}%
+
احتمال بارش
+
+
+ ) : (
+ /* Historical: میزان بارش واقعی */
+
5 ? 'bg-blue-100 border-2 border-blue-300' :
+ (weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-50 border-2 border-sky-200' :
+ 'bg-amber-50 border-2 border-amber-200'
+ }`}>
+
+
5 ? 'bg-blue-500' :
+ (weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-400' :
+ 'bg-amber-400'
+ }`}>
+ {(weatherData.daily[0]?.precipitation || 0) > 0 ?
+ :
+
+ }
+
+
+
بارش
+
5 ? 'text-blue-600' :
+ (weatherData.daily[0]?.precipitation || 0) > 0 ? 'text-sky-600' :
+ 'text-amber-600'
+ }`}>
+ {(weatherData.daily[0]?.precipitation || 0) > 5 ? '🌧️ بارش زیاد' :
+ (weatherData.daily[0]?.precipitation || 0) > 0 ? '🌦️ بارش کم' :
+ '☀️ بدون بارش'}
+
+
+
+
+
{toPersianDigits((weatherData.daily[0]?.precipitation || 0).toFixed(1))}
+
میلیمتر بارش
+
+
+ )}
+
+ {/* Sunlight Card */}
+
+
+
+
+
+
+
نور آفتاب
+
+ {(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 8 ? '☀️ آفتاب زیاد' :
+ (weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 4 ? '🌤️ آفتاب متوسط' :
+ '☁️ کمآفتاب'}
+
+
+
+
+
+
{toPersianDigits(Math.round((weatherData.daily[0]?.sunshineDuration || 0) / 3600))}
+
ساعت آفتاب
+
+
+
{toPersianDigits(Math.round(weatherData.daily[0]?.uvIndexMax || 0))}
+
شاخص UV
+
+
+
+
+ {/* Wind & Humidity Card */}
+
+
+
+
+
+
+
باد و رطوبت
+
+ {(weatherData.daily[0]?.windSpeedMax || 0) > 40 ? '💨 باد شدید!' :
+ (weatherData.daily[0]?.windSpeedMax || 0) > 20 ? '🍃 وزش باد' :
+ '😌 آرام'}
+
+
+
+
+
+
{toPersianDigits(Math.round(weatherData.daily[0]?.windSpeedMax || 0))}
+
کیلومتر/ساعت باد
+
+
+
{toPersianDigits(weatherData.current.humidity)}%
+
رطوبت هوا
+
+
+
+
+
+
+ {/* Hourly Forecast - Only for today */}
+ {isToday && (
+
+
+
+ 🕐 وضعیت ساعت به ساعت امروز
+
+
+
+
+
+
+ {weatherData.hourly.map((hour, index) => {
+ const hourNum = new Date(hour.time).getHours()
+ const isNow = hourNum === new Date().getHours()
+ const IconComponent = getWeatherInfo(hour.weatherCode).icon
+ const isHot = hour.temperature > 35
+ const isCold = hour.temperature < 10
+
+ return (
+
+
+ {isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
+
+
+
+
+
+
+
+ {toPersianDigits(Math.round(hour.temperature))}°
+
+
+
+
+ {toPersianDigits(hour.humidity)}%
+
+
+ {hour.precipitation > 0 && (
+
+ 🌧️ {toPersianDigits(hour.precipitation.toFixed(1))}
+
+ )}
+
+ )
+ })}
+
+
+
👈 برای دیدن ساعتهای بیشتر به چپ بکشید
+
+
+ )}
+
+
+ {/* 7-Day Forecast - Only for today */}
+ {isToday && (
+
+
+
+ پیشبینی ۷ روز آینده
+
+
+ {weatherData.daily.map((day, index) => {
+ const weatherInfo = getWeatherInfo(day.weatherCode)
+ const IconComponent = weatherInfo.icon
+ const isToday = index === 0
+ const hasFrost = day.tempMin < 5
+ const hasHeat = day.tempMax > 35
+ const isExpanded = expandedDayIndex === index
+
+ return (
+
+
+
+ {isExpanded && (
+
+
+
+
+
+ دما
+
+
{toPersianDigits(Math.round(day.tempMax))}°
+
حداکثر
+
{toPersianDigits(Math.round(day.tempMin))}°
+
حداقل
+
+
+
+
+ بارش
+
+
{toPersianDigits(day.precipitationProbability)}%
+
احتمال
+
{toPersianDigits(day.precipitation.toFixed(1))}
+
میلیمتر
+
+
+
+
+ ساعات آفتابی
+
+
{toPersianDigits(Math.round(day.sunshineDuration / 3600))}
+
ساعت
+
{toPersianDigits(Math.round(day.uvIndexMax))}
+
UV Index
+
+
+
+
+ باد
+
+
{toPersianDigits(Math.round(day.windSpeedMax))}
+
کیلومتر/ساعت
+
+
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
+ )
+}
+
diff --git a/src/components/daily-report/index.ts b/src/components/daily-report/index.ts
new file mode 100644
index 0000000..1c93717
--- /dev/null
+++ b/src/components/daily-report/index.ts
@@ -0,0 +1,10 @@
+export { SummaryCard } from './SummaryCard'
+export { SummaryTab } from './SummaryTab'
+export { TimeRangeSelector } from './TimeRangeSelector'
+export { ChartsTab } from './ChartsTab'
+export { WeatherTab } from './WeatherTab'
+export { AnalysisTab } from './AnalysisTab'
+export * from './types'
+export * from './utils'
+export * from './weather-helpers'
+
diff --git a/src/components/daily-report/types.ts b/src/components/daily-report/types.ts
new file mode 100644
index 0000000..0aff828
--- /dev/null
+++ b/src/components/daily-report/types.ts
@@ -0,0 +1,43 @@
+export type TabType = 'summary' | 'charts' | 'weather' | 'analysis'
+
+export type WeatherData = {
+ current: {
+ temperature: number
+ humidity: number
+ windSpeed: number
+ weatherCode: number
+ }
+ hourly: {
+ time: string
+ temperature: number
+ humidity: number
+ weatherCode: number
+ precipitation: number
+ }[]
+ daily: {
+ date: string
+ tempMax: number
+ tempMin: number
+ weatherCode: number
+ precipitation: number
+ precipitationProbability: number
+ uvIndexMax: number
+ sunshineDuration: number // in seconds
+ windSpeedMax: number
+ }[]
+}
+
+export type GreenhouseAlert = {
+ type: 'danger' | 'warning' | 'info' | 'success'
+ title: string
+ description: string
+ icon: React.ComponentType<{ className?: string }>
+}
+
+export const TABS: { value: TabType; label: string }[] = [
+ { value: 'summary', label: 'خلاصه' },
+ { value: 'charts', label: 'گزارش نموداری' },
+ { value: 'weather', label: 'وضعیت آب و هوا' },
+ { value: 'analysis', label: 'تحلیل' },
+]
+
diff --git a/src/components/daily-report/utils.ts b/src/components/daily-report/utils.ts
new file mode 100644
index 0000000..90caaee
--- /dev/null
+++ b/src/components/daily-report/utils.ts
@@ -0,0 +1,171 @@
+import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react'
+
+// Format date to yyyy/MM/dd
+export function formatPersianDate(year: number, month: number, day: number): string {
+ const mm = month.toString().padStart(2, '0')
+ const dd = day.toString().padStart(2, '0')
+ return `${year}/${mm}/${dd}`
+}
+
+// Ensure date string is in yyyy/MM/dd format
+export function ensureDateFormat(dateStr: string): string {
+ const parts = dateStr.split('/')
+ if (parts.length !== 3) return dateStr
+ const [year, month, day] = parts.map(Number)
+ return formatPersianDate(year, month, day)
+}
+
+// تابع تبدیل ارقام انگلیسی به فارسی
+export function toPersianDigits(num: number | string): string {
+ const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
+ return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
+}
+
+// Weather code to description and icon mapping
+export const weatherCodeMap: Record }> = {
+ 0: { description: 'آسمان صاف', icon: Sun },
+ 1: { description: 'عمدتاً صاف', icon: Sun },
+ 2: { description: 'نیمه ابری', icon: Cloud },
+ 3: { description: 'ابری', icon: Cloud },
+ 45: { description: 'مه', icon: CloudFog },
+ 48: { description: 'مه یخزده', icon: CloudFog },
+ 51: { description: 'نمنم باران', icon: CloudRain },
+ 53: { description: 'نمنم باران', icon: CloudRain },
+ 55: { description: 'نمنم باران شدید', icon: CloudRain },
+ 61: { description: 'باران خفیف', icon: CloudRain },
+ 63: { description: 'باران متوسط', icon: CloudRain },
+ 65: { description: 'باران شدید', icon: CloudRain },
+ 71: { description: 'برف خفیف', icon: CloudSnow },
+ 73: { description: 'برف متوسط', icon: CloudSnow },
+ 75: { description: 'برف شدید', icon: CloudSnow },
+ 77: { description: 'دانه برف', icon: CloudSnow },
+ 80: { description: 'رگبار خفیف', icon: CloudRain },
+ 81: { description: 'رگبار متوسط', icon: CloudRain },
+ 82: { description: 'رگبار شدید', icon: CloudRain },
+ 85: { description: 'بارش برف خفیف', icon: CloudSnow },
+ 86: { description: 'بارش برف شدید', icon: CloudSnow },
+ 95: { description: 'رعد و برق', icon: CloudLightning },
+ 96: { description: 'رعد و برق با تگرگ', icon: CloudLightning },
+ 99: { description: 'رعد و برق شدید', icon: CloudLightning },
+}
+
+export function getWeatherInfo(code: number) {
+ return weatherCodeMap[code] || { description: 'نامشخص', icon: Cloud }
+}
+
+// Persian day names
+export const persianDayNames = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه']
+
+export function getPersianDayName(dateStr: string): string {
+ const date = new Date(dateStr)
+ return persianDayNames[date.getDay()]
+}
+
+// Card colors configuration
+export const paramConfig: Record = {
+ temperature: {
+ title: 'دما',
+ unit: '°C',
+ bgColor: 'bg-gradient-to-br from-rose-50 to-red-100',
+ chartColor: '#ef4444',
+ },
+ humidity: {
+ title: 'رطوبت هوا',
+ unit: '%',
+ bgColor: 'bg-gradient-to-br from-sky-50 to-blue-100',
+ chartColor: '#3b82f6',
+ },
+ soil: {
+ title: 'رطوبت خاک',
+ unit: '%',
+ bgColor: 'bg-gradient-to-br from-emerald-50 to-green-100',
+ chartColor: '#16a34a',
+ },
+ gas: {
+ title: 'گاز CO',
+ unit: 'ppm',
+ bgColor: 'bg-gradient-to-br from-slate-50 to-gray-100',
+ chartColor: '#f59e0b',
+ },
+ lux: {
+ title: 'نور',
+ unit: 'Lux',
+ bgColor: 'bg-gradient-to-br from-amber-50 to-yellow-100',
+ chartColor: '#a855f7',
+ },
+}
+
+// Data gap detection
+export type DataGap = {
+ startMinute: number // دقیقه از نیمه شب
+ endMinute: number // دقیقه از نیمه شب
+ durationMinutes: number
+}
+
+// تشخیص گپهای داده (شکافهای زمانی بدون داده)
+export function detectDataGaps(timestamps: string[], gapThresholdMinutes: number = 30): DataGap[] {
+ if (timestamps.length < 2) return []
+
+ const gaps: DataGap[] = []
+
+ for (let i = 0; i < timestamps.length - 1; i++) {
+ const current = new Date(timestamps[i])
+ const next = new Date(timestamps[i + 1])
+
+ const diffMs = next.getTime() - current.getTime()
+ const diffMinutes = diffMs / (1000 * 60)
+
+ if (diffMinutes > gapThresholdMinutes) {
+ const startMinute = current.getHours() * 60 + current.getMinutes()
+ const endMinute = next.getHours() * 60 + next.getMinutes()
+
+ gaps.push({
+ startMinute,
+ endMinute,
+ durationMinutes: Math.round(diffMinutes)
+ })
+ }
+ }
+
+ return gaps
+}
+
+// افزودن null برای گپها در دادههای نمودار
+export function fillGapsWithNull(
+ data: T[],
+ timestamps: string[],
+ gaps: DataGap[]
+): (T | null)[] {
+ if (gaps.length === 0) return data
+
+ const result: (T | null)[] = []
+ let gapIndex = 0
+
+ for (let i = 0; i < data.length; i++) {
+ result.push(data[i])
+
+ // اگر داده بعدی وجود دارد و ما در میانه یک گپ هستیم
+ if (i < data.length - 1 && gapIndex < gaps.length) {
+ const currentDate = new Date(timestamps[i])
+ const nextDate = new Date(timestamps[i + 1])
+ const currentMinute = currentDate.getHours() * 60 + currentDate.getMinutes()
+ const nextMinute = nextDate.getHours() * 60 + nextDate.getMinutes()
+
+ const gap = gaps[gapIndex]
+
+ // اگر این گپ بین دو نقطه فعلی است، یک null اضافه کن
+ if (currentMinute <= gap.startMinute && nextMinute >= gap.endMinute) {
+ result.push(null)
+ gapIndex++
+ }
+ }
+ }
+
+ return result
+}
+
diff --git a/src/components/daily-report/weather-helpers.ts b/src/components/daily-report/weather-helpers.ts
new file mode 100644
index 0000000..1697cdd
--- /dev/null
+++ b/src/components/daily-report/weather-helpers.ts
@@ -0,0 +1,111 @@
+import { Thermometer, Sun, Droplets, Wind, Leaf, AlertTriangle } from 'lucide-react'
+import { WeatherData, GreenhouseAlert } from './types'
+import { toPersianDigits } from './utils'
+
+// Qom coordinates
+export const QOM_LAT = 34.6416
+export const QOM_LON = 50.8746
+
+// Greenhouse-specific recommendations
+export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] {
+ const alerts: GreenhouseAlert[] = []
+ const today = weather.daily[0]
+
+ // Frost warning
+ if (today.tempMin < 5) {
+ alerts.push({
+ type: 'danger',
+ title: '⚠️ هشدار یخزدگی',
+ description: `دمای حداقل امشب ${toPersianDigits(Math.round(today.tempMin))}°C پیشبینی شده. سیستم گرمایش را فعال کنید و پوشش محافظ روی گیاهان حساس قرار دهید.`,
+ icon: Thermometer
+ })
+ }
+
+ // Heat stress warning
+ if (today.tempMax > 35) {
+ alerts.push({
+ type: 'danger',
+ title: '🌡️ هشدار گرمای شدید',
+ description: `دمای حداکثر ${toPersianDigits(Math.round(today.tempMax))}°C پیشبینی شده. سایهبانها را فعال کنید، تهویه را افزایش دهید و آبیاری را در ساعات خنک انجام دهید.`,
+ icon: Sun
+ })
+ }
+
+ // High UV warning
+ if (today.uvIndexMax > 8) {
+ alerts.push({
+ type: 'warning',
+ title: '☀️ شاخص UV بالا',
+ description: `شاخص UV ${toPersianDigits(Math.round(today.uvIndexMax))} است. برای گیاهان حساس به نور از سایهبان استفاده کنید.`,
+ icon: Sun
+ })
+ }
+
+ // Strong wind warning
+ if (today.windSpeedMax > 40) {
+ alerts.push({
+ type: 'warning',
+ title: '💨 باد شدید',
+ description: `سرعت باد به ${toPersianDigits(Math.round(today.windSpeedMax))} کیلومتر بر ساعت میرسد. دریچهها و پنجرهها را ببندید و سازه را بررسی کنید.`,
+ icon: Wind
+ })
+ }
+
+ // Rain/precipitation warning
+ if (today.precipitation > 10) {
+ alerts.push({
+ type: 'info',
+ title: '🌧️ بارش قابل توجه',
+ description: `بارش ${toPersianDigits(Math.round(today.precipitation))} میلیمتر پیشبینی شده. سیستم زهکشی را بررسی کنید و آبیاری را کاهش دهید.`,
+ icon: Droplets
+ })
+ }
+
+ // Optimal conditions
+ if (today.tempMin >= 10 && today.tempMax <= 28 && today.windSpeedMax < 30 && today.precipitation < 5) {
+ alerts.push({
+ type: 'success',
+ title: '✅ شرایط مناسب',
+ description: 'شرایط آب و هوایی امروز برای رشد گیاهان عالی است. میتوانید تهویه طبیعی را افزایش دهید.',
+ icon: Leaf
+ })
+ }
+
+ return alerts
+}
+
+// Get irrigation recommendation
+export function getIrrigationRecommendation(weather: WeatherData): { level: string; color: string; description: string } {
+ const today = weather.daily[0]
+
+ if (today.precipitationProbability > 60) {
+ return { level: 'کم', color: 'text-blue-600', description: 'به دلیل احتمال بارش، آبیاری را کاهش دهید' }
+ }
+ if (today.tempMax > 35) {
+ return { level: 'زیاد', color: 'text-red-600', description: 'به دلیل گرمای شدید، آبیاری بیشتری لازم است' }
+ }
+ if (today.tempMax > 28) {
+ return { level: 'متوسط-زیاد', color: 'text-orange-600', description: 'آبیاری در ساعات صبح و عصر توصیه میشود' }
+ }
+ return { level: 'متوسط', color: 'text-green-600', description: 'آبیاری معمول کافی است' }
+}
+
+// Get ventilation recommendation
+export function getVentilationRecommendation(weather: WeatherData): { level: string; color: string; description: string } {
+ const today = weather.daily[0]
+
+ if (today.windSpeedMax > 40) {
+ return { level: 'بسته', color: 'text-red-600', description: 'به دلیل باد شدید، دریچهها را ببندید' }
+ }
+ if (today.tempMax > 30) {
+ return { level: 'حداکثر', color: 'text-orange-600', description: 'تهویه کامل برای کاهش دما ضروری است' }
+ }
+ if (today.tempMax > 25 && today.windSpeedMax < 25) {
+ return { level: 'متوسط', color: 'text-green-600', description: 'تهویه طبیعی مناسب است' }
+ }
+ if (today.tempMin < 10) {
+ return { level: 'محدود', color: 'text-blue-600', description: 'تهویه را محدود کنید تا گرما حفظ شود' }
+ }
+ return { level: 'معمولی', color: 'text-gray-600', description: 'تهویه استاندارد کافی است' }
+}
+
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 7bb7b4b..7407c5a 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -79,6 +79,55 @@ export type PagedResult = {
pageSize: number
}
+export type DailyReportDto = {
+ id: number
+ deviceId: number
+ deviceName: string
+ persianDate: string
+ analysis: string
+ recordCount: number
+ sampledRecordCount: number
+ totalTokens: number
+ createdAt: string
+ fromCache: boolean
+}
+
+export type AlertRuleDto = {
+ id?: number
+ sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4
+ comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3
+ threshold: number
+ thresholdMax?: number // برای Between و OutOfRange
+}
+
+export type AlertConditionDto = {
+ id: number
+ deviceId: number
+ notificationType: 0 | 1 // Call=0, SMS=1
+ timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2
+ isActive: boolean
+ rules: AlertRuleDto[]
+ createdAt: string
+ updatedAt: string
+}
+
+export type CreateAlertConditionDto = {
+ deviceId: number
+ notificationType: 0 | 1
+ timeType: 0 | 1 | 2
+ isActive: boolean
+ rules: AlertRuleDto[]
+}
+
+export type UpdateAlertConditionDto = {
+ id: number
+ deviceId: number
+ notificationType: 0 | 1
+ timeType: 0 | 1 | 2
+ isActive: boolean
+ rules: AlertRuleDto[]
+}
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
async function http(url: string, init?: RequestInit): Promise {
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
@@ -140,5 +189,23 @@ export const api = {
if (q.page) params.set('page', String(q.page))
if (q.pageSize) params.set('pageSize', String(q.pageSize))
return http>(`${API_BASE}/api/devices/filtered?${params.toString()}`)
- }
+ },
+
+ // Daily Report
+ getDailyReport: (deviceId: number, persianDate: string) =>
+ http(`${API_BASE}/api/DailyReport?deviceId=${deviceId}&persianDate=${encodeURIComponent(persianDate)}`),
+
+ // Alert Conditions
+ getAlertConditions: (deviceId?: number) => {
+ const params = deviceId ? `?deviceId=${deviceId}` : ''
+ return http(`${API_BASE}/api/alertconditions${params}`)
+ },
+ getAlertCondition: (id: number) =>
+ http(`${API_BASE}/api/alertconditions/${id}`),
+ createAlertCondition: (dto: CreateAlertConditionDto) =>
+ http(`${API_BASE}/api/alertconditions`, { method: 'POST', body: JSON.stringify(dto) }),
+ updateAlertCondition: (dto: UpdateAlertConditionDto) =>
+ http(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }),
+ deleteAlertCondition: (id: number) =>
+ http(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
}
diff --git a/src/lib/persian-date.ts b/src/lib/persian-date.ts
index 0f09d93..0707ccb 100644
--- a/src/lib/persian-date.ts
+++ b/src/lib/persian-date.ts
@@ -70,3 +70,57 @@ export function getPersianTodayString(): string {
const persianWeekday = daysOfWeek[(now.getDay() + 1) % 7] // نگاشت درست روز هفته
return `${persianWeekday} ${persian.day} ${months[persian.month - 1]} ${persian.year}`
}
+
+/**
+ * Parse Persian date string in format "yyyy/MM/dd" and return PersianDate
+ */
+export function parsePersianDate(dateStr: string): PersianDate | null {
+ try {
+ const parts = dateStr.split('/')
+ if (parts.length !== 3) return null
+ const year = parseInt(parts[0])
+ const month = parseInt(parts[1])
+ const day = parseInt(parts[2])
+ if (isNaN(year) || isNaN(month) || isNaN(day)) return null
+ return { year, month, day }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Format Persian date as "yyyy/MM/dd"
+ */
+export function formatPersianDateString(date: PersianDate): string {
+ return `${date.year}/${String(date.month).padStart(2, '0')}/${String(date.day).padStart(2, '0')}`
+}
+
+/**
+ * Get previous day in Persian calendar
+ */
+export function getPreviousPersianDay(dateStr: string): string | null {
+ const parsed = parsePersianDate(dateStr)
+ if (!parsed) return null
+
+ // Convert to Gregorian, subtract one day, convert back to Persian
+ const gregorian = persianToGregorian(parsed.year, parsed.month, parsed.day)
+ gregorian.setDate(gregorian.getDate() - 1)
+ const prevPersian = gregorianToPersian(gregorian)
+
+ return formatPersianDateString(prevPersian)
+}
+
+/**
+ * Get next day in Persian calendar
+ */
+export function getNextPersianDay(dateStr: string): string | null {
+ const parsed = parsePersianDate(dateStr)
+ if (!parsed) return null
+
+ // Convert to Gregorian, add one day, convert back to Persian
+ const gregorian = persianToGregorian(parsed.year, parsed.month, parsed.day)
+ gregorian.setDate(gregorian.getDate() + 1)
+ const nextPersian = gregorianToPersian(gregorian)
+
+ return formatPersianDateString(nextPersian)
+}
\ No newline at end of file