new ui and daily report
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 2s
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 2s
This commit is contained in:
1129
package-lock.json
generated
1129
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "15.5.4",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "19.1.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react-dom": "19.1.0",
|
||||
"react-markdown": "^10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
726
src/app/alert-settings/page.tsx
Normal file
726
src/app/alert-settings/page.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
api,
|
||||
AlertConditionDto,
|
||||
CreateAlertConditionDto,
|
||||
UpdateAlertConditionDto,
|
||||
AlertRuleDto
|
||||
} from '@/lib/api'
|
||||
import {
|
||||
Bell,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Thermometer,
|
||||
Droplets,
|
||||
Leaf,
|
||||
Wind,
|
||||
Sun,
|
||||
Check,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Moon,
|
||||
Zap,
|
||||
Maximize2,
|
||||
ArrowLeftRight
|
||||
} from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
|
||||
type SensorType = 0 | 1 | 2 | 3 | 4
|
||||
type ComparisonType = 0 | 1 | 2 | 3
|
||||
type NotificationType = 0 | 1
|
||||
type TimeType = 0 | 1 | 2
|
||||
|
||||
const SENSOR_TYPES: { value: SensorType; label: string; icon: any; unit: string }[] = [
|
||||
{ value: 0, label: 'دما', icon: Thermometer, unit: '°C' },
|
||||
{ value: 1, label: 'رطوبت هوا', icon: Droplets, unit: '%' },
|
||||
{ value: 2, label: 'رطوبت خاک', icon: Leaf, unit: '%' },
|
||||
{ value: 3, label: 'گاز', icon: Wind, unit: 'PPM' },
|
||||
{ value: 4, label: 'نور', icon: Sun, unit: 'Lux' }
|
||||
]
|
||||
|
||||
const COMPARISON_TYPES: { value: ComparisonType; label: string; icon: any; needsMax: boolean }[] = [
|
||||
{ value: 0, label: 'بزرگتر از', icon: () => <span className="text-2xl font-bold">></span>, needsMax: false },
|
||||
{ value: 1, label: 'کوچکتر از', icon: () => <span className="text-2xl font-bold"><</span>, needsMax: false },
|
||||
{ value: 2, label: 'بین', icon: ArrowLeftRight, needsMax: true },
|
||||
{ value: 3, label: 'خارج از محدوده', icon: Maximize2, needsMax: true }
|
||||
]
|
||||
|
||||
const NOTIFICATION_TYPES: { value: NotificationType; label: string; icon: any }[] = [
|
||||
{ value: 0, label: 'تماس تلفنی', icon: Phone },
|
||||
{ value: 1, label: 'پیامک', icon: MessageSquare }
|
||||
]
|
||||
|
||||
const TIME_TYPES: { value: TimeType; label: string; icon: any; description: string }[] = [
|
||||
{ value: 0, label: 'روز', icon: Sun, description: 'فقط در روز بررسی شود' },
|
||||
{ value: 1, label: 'شب', icon: Moon, description: 'فقط در شب بررسی شود' },
|
||||
{ value: 2, label: 'همیشه', icon: Zap, description: 'در هر زمان بررسی شود' }
|
||||
]
|
||||
|
||||
export default function AlertSettingsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||
|
||||
const [alerts, setAlerts] = useState<AlertConditionDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingAlert, setEditingAlert] = useState<AlertConditionDto | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<CreateAlertConditionDto>({
|
||||
deviceId: deviceId,
|
||||
notificationType: 0,
|
||||
timeType: 2,
|
||||
isActive: true,
|
||||
rules: []
|
||||
})
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getAlertConditions(deviceId)
|
||||
setAlerts(data)
|
||||
} catch (error) {
|
||||
console.error('Error loading alerts:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts()
|
||||
}, [loadAlerts])
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingAlert(null)
|
||||
setFormData({
|
||||
deviceId: deviceId,
|
||||
notificationType: 0,
|
||||
timeType: 2,
|
||||
isActive: true,
|
||||
rules: [{
|
||||
sensorType: 0,
|
||||
comparisonType: 0,
|
||||
threshold: 30
|
||||
}]
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openEditModal = (alert: AlertConditionDto) => {
|
||||
setEditingAlert(alert)
|
||||
setFormData({
|
||||
deviceId: alert.deviceId,
|
||||
notificationType: alert.notificationType,
|
||||
timeType: alert.timeType,
|
||||
isActive: alert.isActive,
|
||||
rules: alert.rules.map(r => ({ ...r }))
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setEditingAlert(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (formData.rules.length === 0) {
|
||||
alert('لطفاً حداقل یک قانون اضافه کنید')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (editingAlert) {
|
||||
// Update
|
||||
const updateDto: UpdateAlertConditionDto = {
|
||||
id: editingAlert.id,
|
||||
...formData
|
||||
}
|
||||
await api.updateAlertCondition(updateDto)
|
||||
} else {
|
||||
// Create
|
||||
await api.createAlertCondition(formData)
|
||||
}
|
||||
|
||||
await loadAlerts()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('Error saving alert:', error)
|
||||
alert('خطا در ذخیره هشدار. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('آیا از حذف این هشدار اطمینان دارید؟')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteAlertCondition(id)
|
||||
await loadAlerts()
|
||||
} catch (error) {
|
||||
console.error('Error deleting alert:', error)
|
||||
alert('خطا در حذف هشدار. لطفاً دوباره تلاش کنید.')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleActive = async (alert: AlertConditionDto) => {
|
||||
try {
|
||||
const updateDto: UpdateAlertConditionDto = {
|
||||
id: alert.id,
|
||||
deviceId: alert.deviceId,
|
||||
notificationType: alert.notificationType,
|
||||
timeType: alert.timeType,
|
||||
isActive: !alert.isActive,
|
||||
rules: alert.rules
|
||||
}
|
||||
await api.updateAlertCondition(updateDto)
|
||||
await loadAlerts()
|
||||
} catch (error) {
|
||||
console.error('Error toggling alert:', error)
|
||||
alert('خطا در تغییر وضعیت هشدار.')
|
||||
}
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
rules: [
|
||||
...formData.rules,
|
||||
{
|
||||
sensorType: 0,
|
||||
comparisonType: 0,
|
||||
threshold: 30
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
rules: formData.rules.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const updateRule = (index: number, updates: Partial<AlertRuleDto>) => {
|
||||
const newRules = [...formData.rules]
|
||||
newRules[index] = { ...newRules[index], ...updates }
|
||||
setFormData({ ...formData, rules: newRules })
|
||||
}
|
||||
|
||||
const getSensorIcon = (type: SensorType) => {
|
||||
const sensor = SENSOR_TYPES.find(s => s.value === type)
|
||||
return sensor ? sensor.icon : AlertTriangle
|
||||
}
|
||||
|
||||
const getSensorLabel = (type: SensorType) => {
|
||||
const sensor = SENSOR_TYPES.find(s => s.value === type)
|
||||
return sensor?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const getSensorUnit = (type: SensorType) => {
|
||||
const sensor = SENSOR_TYPES.find(s => s.value === type)
|
||||
return sensor?.unit ?? ''
|
||||
}
|
||||
|
||||
const getComparisonLabel = (type: ComparisonType) => {
|
||||
const comp = COMPARISON_TYPES.find(c => c.value === type)
|
||||
return comp?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const getNotificationLabel = (type: NotificationType) => {
|
||||
const notif = NOTIFICATION_TYPES.find(n => n.value === type)
|
||||
return notif?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const getTimeTypeLabel = (type: TimeType) => {
|
||||
const time = TIME_TYPES.find(t => t.value === type)
|
||||
return time?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const formatRuleText = (rule: AlertRuleDto) => {
|
||||
const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
|
||||
if (comp?.needsMax) {
|
||||
return `${getComparisonLabel(rule.comparisonType)}: ${rule.threshold} - ${rule.thresholdMax ?? '?'} ${getSensorUnit(rule.sensorType)}`
|
||||
}
|
||||
const symbol = rule.comparisonType === 0 ? '>' : rule.comparisonType === 1 ? '<' : '?'
|
||||
return `${symbol} ${rule.threshold} ${getSensorUnit(rule.sensorType)}`
|
||||
}
|
||||
|
||||
const generatePreviewText = () => {
|
||||
if (formData.rules.length === 0) {
|
||||
return 'هنوز هیچ شرطی تعریف نشده است.'
|
||||
}
|
||||
|
||||
// نوع اطلاعرسانی
|
||||
const notifText = formData.notificationType === 0 ? 'تماس تلفنی' : 'پیامک'
|
||||
|
||||
// زمان
|
||||
const timeText = formData.timeType === 0
|
||||
? 'در روز'
|
||||
: formData.timeType === 1
|
||||
? 'در شب'
|
||||
: 'در هر زمان'
|
||||
|
||||
// قوانین
|
||||
const rulesText = formData.rules.map(rule => {
|
||||
const sensorName = getSensorLabel(rule.sensorType)
|
||||
const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
|
||||
|
||||
if (comp?.needsMax) {
|
||||
// بین یا خارج از محدوده
|
||||
const compText = rule.comparisonType === 2 ? 'بین' : 'خارج از'
|
||||
return `${sensorName} ${compText} ${rule.threshold} تا ${rule.thresholdMax ?? '؟'} ${getSensorUnit(rule.sensorType)}`
|
||||
} else {
|
||||
// بزرگتر یا کوچکتر
|
||||
const compText = rule.comparisonType === 0 ? 'بیشتر از' : 'کمتر از'
|
||||
return `${sensorName} ${compText} ${rule.threshold} ${getSensorUnit(rule.sensorType)}`
|
||||
}
|
||||
})
|
||||
|
||||
// ساخت جمله نهایی
|
||||
if (rulesText.length === 1) {
|
||||
return `ارسال ${notifText} برای زمانی که ${timeText} ${rulesText[0]} باشد.`
|
||||
} else {
|
||||
const lastRule = rulesText[rulesText.length - 1]
|
||||
const otherRules = rulesText.slice(0, -1).join(' و ')
|
||||
return `ارسال ${notifText} برای زمانی که ${timeText} ${otherRules} و ${lastRule} باشند.`
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-xl shadow-md">
|
||||
<Bell className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
تنظیمات هشدارها
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
مدیریت شرایط و هشدارهای دستگاه
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
افزودن هشدار
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
لیست هشدارها ({alerts.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">هیچ هشداری ثبت نشده است</p>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
افزودن اولین هشدار
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} className={`p-6 hover:bg-gray-50 transition-colors ${!alert.isActive ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">
|
||||
{NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && (
|
||||
<span className="w-4 h-4">
|
||||
{(() => {
|
||||
const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon
|
||||
return <Icon className="w-4 h-4" />
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{getNotificationLabel(alert.notificationType)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
||||
{TIME_TYPES.find(t => t.value === alert.timeType)?.icon && (
|
||||
<span className="w-4 h-4">
|
||||
{(() => {
|
||||
const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon
|
||||
return <Icon className="w-4 h-4" />
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{getTimeTypeLabel(alert.timeType)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleActive(alert)}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
alert.isActive
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{alert.isActive ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" />
|
||||
فعال
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-3 h-3" />
|
||||
غیرفعال
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-gray-700">شرایط:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{alert.rules.map((rule, idx) => {
|
||||
const Icon = getSensorIcon(rule.sensorType)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-gray-100 rounded-lg text-sm">
|
||||
<Icon className="w-4 h-4 text-gray-600" />
|
||||
<span className="font-medium">{getSensorLabel(rule.sensorType)}</span>
|
||||
<span className="font-mono text-gray-700">{formatRuleText(rule)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(alert)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(alert.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
{editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Notification Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
نوع اطلاعرسانی
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{NOTIFICATION_TYPES.map(notif => {
|
||||
const Icon = notif.icon
|
||||
const isSelected = formData.notificationType === notif.value
|
||||
return (
|
||||
<button
|
||||
key={notif.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, notificationType: notif.value })}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50 text-orange-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${isSelected ? 'text-orange-500' : 'text-gray-400'}`} />
|
||||
<span className="text-sm font-medium">{notif.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
زمان بررسی شرایط
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
مشخص کنید شرایط در چه زمانی بررسی و هشدار ارسال شود
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{TIME_TYPES.map(time => {
|
||||
const Icon = time.icon
|
||||
const isSelected = formData.timeType === time.value
|
||||
return (
|
||||
<button
|
||||
key={time.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, timeType: time.value })}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
title={time.description}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${isSelected ? 'text-blue-500' : 'text-gray-400'}`} />
|
||||
<span className="text-sm font-medium">{time.label}</span>
|
||||
<span className="text-xs text-gray-500">{time.description}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block">
|
||||
شرایط هشدار
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formData.rules.length === 0
|
||||
? 'هیچ شرطی تعریف نشده است'
|
||||
: formData.rules.length === 1
|
||||
? 'یک شرط تعریف شده است'
|
||||
: `${formData.rules.length} شرط تعریف شده است (همه باید برقرار باشند)`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
افزودن شرط
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{formData.rules.map((rule, index) => {
|
||||
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
||||
return (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-4 space-y-4 relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{formData.rules.length === 1 ? 'شرط' : `شرط ${index + 1}`}
|
||||
</span>
|
||||
{formData.rules.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(index)}
|
||||
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="حذف این شرط"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sensor Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
کدام سنسور را میخواهید بررسی کنید؟
|
||||
</label>
|
||||
<div className="grid grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{SENSOR_TYPES.map(sensor => {
|
||||
const Icon = sensor.icon
|
||||
const isSelected = rule.sensorType === sensor.value
|
||||
return (
|
||||
<button
|
||||
key={sensor.value}
|
||||
type="button"
|
||||
onClick={() => updateRule(index, { sensorType: sensor.value })}
|
||||
className={`flex flex-col items-center gap-1 p-2 rounded border transition-all ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isSelected ? 'text-orange-500' : 'text-gray-400'}`} />
|
||||
<span className="text-xs">{sensor.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
چه زمانی باید هشدار داد؟
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{COMPARISON_TYPES.map(comp => {
|
||||
const Icon = comp.icon
|
||||
const isSelected = rule.comparisonType === comp.value
|
||||
return (
|
||||
<button
|
||||
key={comp.value}
|
||||
type="button"
|
||||
onClick={() => updateRule(index, { comparisonType: comp.value })}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded border transition-all ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? 'text-orange-500' : 'text-gray-400'}`} />
|
||||
<div className="text-xs text-center leading-tight">{comp.label}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threshold */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
{needsMax ? 'از چه مقداری' : 'چه مقداری'} ({getSensorUnit(rule.sensorType)})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.threshold}
|
||||
onChange={(e) => updateRule(index, { threshold: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{needsMax && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
تا چه مقداری ({getSensorUnit(rule.sensorType)})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.thresholdMax ?? ''}
|
||||
onChange={(e) => updateRule(index, { thresholdMax: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Bell className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-blue-600 mb-1">پیشنمایش هشدار</div>
|
||||
<div className="text-sm text-gray-800 leading-relaxed">
|
||||
{generatePreviewText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="w-4 h-4 text-orange-600 border-gray-300 rounded focus:ring-orange-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
هشدار فعال باشد
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear } from '@/lib/persian-date'
|
||||
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
|
||||
@@ -22,6 +23,7 @@ function useQueryParam(name: string) {
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter()
|
||||
const deviceIdParam = useQueryParam('deviceId')
|
||||
const [deviceId, setDeviceId] = useState<number>(1)
|
||||
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
||||
@@ -131,9 +133,10 @@ export default function CalendarPage() {
|
||||
const isActive = activeMonths.includes(m)
|
||||
const stats = monthDays[m]
|
||||
return (
|
||||
<Link
|
||||
<button
|
||||
key={m}
|
||||
href={`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`}
|
||||
onClick={() => isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)}
|
||||
disabled={!isActive}
|
||||
className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${
|
||||
isActive
|
||||
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
|
||||
@@ -159,7 +162,7 @@ export default function CalendarPage() {
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 rounded-b-xl transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
)}
|
||||
</Link>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
593
src/app/daily-report/page.tsx
Normal file
593
src/app/daily-report/page.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api, TelemetryDto, DailyReportDto } from '@/lib/api'
|
||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date'
|
||||
import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
import {
|
||||
SummaryTab,
|
||||
ChartsTab,
|
||||
WeatherTab,
|
||||
AnalysisTab,
|
||||
TABS,
|
||||
TabType,
|
||||
WeatherData,
|
||||
ensureDateFormat,
|
||||
formatPersianDate,
|
||||
QOM_LAT,
|
||||
QOM_LON,
|
||||
detectDataGaps,
|
||||
DataGap
|
||||
} from '@/components/daily-report'
|
||||
|
||||
export default function DailyReportPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('summary')
|
||||
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
|
||||
const [analysisLoading, setAnalysisLoading] = useState(false)
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
const [weatherError, setWeatherError] = useState<string | null>(null)
|
||||
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
|
||||
const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00
|
||||
const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59
|
||||
|
||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
if (!dateParam) return null
|
||||
try {
|
||||
const decodedDate = decodeURIComponent(dateParam)
|
||||
// Ensure date is in yyyy/MM/dd format
|
||||
return ensureDateFormat(decodedDate)
|
||||
} catch (error) {
|
||||
console.error('Error decoding date parameter:', error)
|
||||
return null
|
||||
}
|
||||
}, [dateParam])
|
||||
|
||||
// Navigate to previous day
|
||||
const goToPreviousDay = useCallback(() => {
|
||||
if (!selectedDate) return
|
||||
const prevDay = getPreviousPersianDay(selectedDate)
|
||||
if (prevDay) {
|
||||
router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(prevDay)}`)
|
||||
}
|
||||
}, [selectedDate, deviceId, router])
|
||||
|
||||
// Navigate to next day
|
||||
const goToNextDay = useCallback(() => {
|
||||
if (!selectedDate) return
|
||||
const nextDay = getNextPersianDay(selectedDate)
|
||||
if (nextDay) {
|
||||
router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(nextDay)}`)
|
||||
}
|
||||
}, [selectedDate, deviceId, router])
|
||||
|
||||
// Navigate to calendar to select a date
|
||||
const goToCalendar = useCallback(() => {
|
||||
router.push(`/calendar?deviceId=${deviceId}`)
|
||||
}, [deviceId, router])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!selectedDate) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||
const startDate = persianToGregorian(year, month, day)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
|
||||
const startUtc = startDate.toISOString()
|
||||
const endUtc = endDate.toISOString()
|
||||
|
||||
const result = await api.listTelemetry({ deviceId, startUtc, endUtc, pageSize: 100000 })
|
||||
setTelemetry(result.items)
|
||||
} catch (error) {
|
||||
console.error('Error loading telemetry:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceId, selectedDate])
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
if (!selectedDate || dailyReport) return
|
||||
|
||||
setAnalysisLoading(true)
|
||||
setAnalysisError(null)
|
||||
|
||||
try {
|
||||
const report = await api.getDailyReport(deviceId, selectedDate)
|
||||
setDailyReport(report)
|
||||
} catch (error) {
|
||||
console.error('Error loading analysis:', error)
|
||||
setAnalysisError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setAnalysisLoading(false)
|
||||
}
|
||||
}, [deviceId, selectedDate, dailyReport])
|
||||
|
||||
const loadWeather = useCallback(async () => {
|
||||
if (weatherData) return
|
||||
|
||||
setWeatherLoading(true)
|
||||
setWeatherError(null)
|
||||
|
||||
try {
|
||||
if (!selectedDate) {
|
||||
setWeatherError('تاریخ انتخاب نشده است')
|
||||
return
|
||||
}
|
||||
|
||||
// تبدیل تاریخ شمسی به میلادی
|
||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||
const gregorianDate = persianToGregorian(year, month, day)
|
||||
|
||||
// بررسی اینکه تاریخ امروز است یا گذشته
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
gregorianDate.setHours(0, 0, 0, 0)
|
||||
|
||||
const isPast = gregorianDate.getTime() < today.getTime()
|
||||
|
||||
let weather: WeatherData
|
||||
|
||||
if (isPast) {
|
||||
// استفاده از Historical API برای روزهای گذشته
|
||||
const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD
|
||||
const response = await fetch(
|
||||
`https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch historical weather data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// ساختار داده برای روزهای گذشته (بدون current و hourly)
|
||||
weather = {
|
||||
current: {
|
||||
temperature: data.daily.temperature_2m_max?.[0] || 0,
|
||||
humidity: 0, // Historical API رطوبت ندارد
|
||||
windSpeed: data.daily.wind_speed_10m_max?.[0] || 0,
|
||||
weatherCode: data.daily.weather_code?.[0] || 0,
|
||||
},
|
||||
hourly: [], // برای گذشته hourly نداریم
|
||||
daily: [{
|
||||
date: data.daily.time?.[0] || dateStr,
|
||||
tempMax: data.daily.temperature_2m_max?.[0] || 0,
|
||||
tempMin: data.daily.temperature_2m_min?.[0] || 0,
|
||||
weatherCode: data.daily.weather_code?.[0] || 0,
|
||||
precipitation: data.daily.precipitation_sum?.[0] || 0,
|
||||
precipitationProbability: 0,
|
||||
uvIndexMax: 0,
|
||||
sunshineDuration: data.daily.sunshine_duration?.[0] || 0,
|
||||
windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0,
|
||||
}]
|
||||
}
|
||||
} else {
|
||||
// استفاده از Forecast API برای امروز و آینده
|
||||
const response = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max&timezone=Asia/Tehran&forecast_days=7`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch weather data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Get only today's hourly data (first 24 hours)
|
||||
const todayHourly = data.hourly.time.slice(0, 24).map((time: string, i: number) => ({
|
||||
time,
|
||||
temperature: data.hourly.temperature_2m[i],
|
||||
humidity: data.hourly.relative_humidity_2m[i],
|
||||
weatherCode: data.hourly.weather_code[i],
|
||||
precipitation: data.hourly.precipitation[i],
|
||||
}))
|
||||
|
||||
weather = {
|
||||
current: {
|
||||
temperature: data.current.temperature_2m,
|
||||
humidity: data.current.relative_humidity_2m,
|
||||
windSpeed: data.current.wind_speed_10m,
|
||||
weatherCode: data.current.weather_code,
|
||||
},
|
||||
hourly: todayHourly,
|
||||
daily: data.daily.time.map((date: string, i: number) => ({
|
||||
date,
|
||||
tempMax: data.daily.temperature_2m_max[i],
|
||||
tempMin: data.daily.temperature_2m_min[i],
|
||||
weatherCode: data.daily.weather_code[i],
|
||||
precipitation: data.daily.precipitation_sum[i],
|
||||
precipitationProbability: data.daily.precipitation_probability_max[i],
|
||||
uvIndexMax: data.daily.uv_index_max[i],
|
||||
sunshineDuration: data.daily.sunshine_duration[i],
|
||||
windSpeedMax: data.daily.wind_speed_10m_max[i],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
setWeatherData(weather)
|
||||
} catch (error) {
|
||||
console.error('Error loading weather:', error)
|
||||
setWeatherError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setWeatherLoading(false)
|
||||
}
|
||||
}, [weatherData, selectedDate])
|
||||
|
||||
useEffect(() => {
|
||||
// Reset states when date or device changes
|
||||
setDailyReport(null)
|
||||
setWeatherData(null)
|
||||
setAnalysisError(null)
|
||||
setWeatherError(null)
|
||||
loadData()
|
||||
}, [loadData, deviceId, selectedDate])
|
||||
|
||||
// Load analysis when switching to analysis tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'analysis') {
|
||||
loadAnalysis()
|
||||
}
|
||||
}, [activeTab, loadAnalysis])
|
||||
|
||||
// Load weather when switching to weather tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'weather') {
|
||||
loadWeather()
|
||||
}
|
||||
}, [activeTab, loadWeather])
|
||||
|
||||
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])
|
||||
|
||||
// Data arrays
|
||||
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])
|
||||
|
||||
// Min/Max calculations
|
||||
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])
|
||||
|
||||
// Detect data gaps in the full day data
|
||||
const dataGaps = useMemo(() => {
|
||||
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||
return detectDataGaps(timestamps, 30) // 30 minutes threshold
|
||||
}, [sortedTelemetry])
|
||||
|
||||
// Filtered telemetry for charts based on minute range
|
||||
const filteredTelemetryForCharts = useMemo(() => {
|
||||
return sortedTelemetry.filter(t => {
|
||||
const timestamp = t.serverTimestampUtc || t.timestampUtc
|
||||
const date = new Date(timestamp)
|
||||
const minuteOfDay = date.getHours() * 60 + date.getMinutes()
|
||||
return minuteOfDay >= chartStartMinute && minuteOfDay <= chartEndMinute
|
||||
})
|
||||
}, [sortedTelemetry, chartStartMinute, chartEndMinute])
|
||||
|
||||
// Detect gaps in filtered data
|
||||
const filteredDataGaps = useMemo(() => {
|
||||
const timestamps = filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||
return detectDataGaps(timestamps, 30)
|
||||
}, [filteredTelemetryForCharts])
|
||||
|
||||
// Filtered chart labels
|
||||
const chartLabels = useMemo(() => {
|
||||
return filteredTelemetryForCharts.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}`
|
||||
})
|
||||
}, [filteredTelemetryForCharts])
|
||||
|
||||
// Helper function to insert nulls for gaps
|
||||
const insertGapsInData = (data: number[], timestamps: string[], gaps: DataGap[]): (number | null)[] => {
|
||||
if (gaps.length === 0 || data.length < 2) return data
|
||||
|
||||
const result: (number | null)[] = []
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
result.push(data[i])
|
||||
|
||||
// Check if there's a gap after this point
|
||||
if (i < data.length - 1) {
|
||||
const currentTime = new Date(timestamps[i])
|
||||
const nextTime = new Date(timestamps[i + 1])
|
||||
const currentMinute = currentTime.getHours() * 60 + currentTime.getMinutes()
|
||||
const nextMinute = nextTime.getHours() * 60 + nextTime.getMinutes()
|
||||
|
||||
// Find if any gap exists between current and next
|
||||
const hasGap = gaps.some(gap =>
|
||||
currentMinute <= gap.startMinute && nextMinute >= gap.endMinute
|
||||
)
|
||||
|
||||
if (hasGap) {
|
||||
result.push(null) // Insert null to break the line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Filtered data arrays for charts (with gaps as null)
|
||||
const filteredTimestamps = useMemo(() =>
|
||||
filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc),
|
||||
[filteredTelemetryForCharts]
|
||||
)
|
||||
|
||||
const chartSoil = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.soilPercent ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartTemp = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.temperatureC ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartHum = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.humidityPercent ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartGas = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.gasPPM ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartLux = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.lux ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
// Min/Max calculations for filtered charts (filter out nulls)
|
||||
const chartTempMinMax = useMemo(() => {
|
||||
const validTemps = chartTemp.filter((t): t is number => t !== null)
|
||||
if (validTemps.length === 0) return { min: 0, max: 40 }
|
||||
const min = Math.min(...validTemps)
|
||||
const max = Math.max(...validTemps)
|
||||
return {
|
||||
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
|
||||
max: max > 40 ? Math.floor(max / 10) * 10 : 40
|
||||
}
|
||||
}, [chartTemp])
|
||||
|
||||
const chartLuxMinMax = useMemo(() => {
|
||||
const validLux = chartLux.filter((l): l is number => l !== null)
|
||||
if (validLux.length === 0) return { min: 0, max: 2000 }
|
||||
const max = Math.max(...validLux)
|
||||
return {
|
||||
min: 0,
|
||||
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
|
||||
}
|
||||
}, [chartLux])
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="در حال بارگذاری دادهها..." />
|
||||
}
|
||||
|
||||
if (!selectedDate) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||
<button
|
||||
onClick={goToCalendar}
|
||||
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به تقویم
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-md">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
گزارش روزانه {selectedDate}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
مشاهده خلاصه و نمودارهای روز
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/alert-settings?deviceId=${deviceId}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Navigation Buttons */}
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<button
|
||||
onClick={goToPreviousDay}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
روز قبل
|
||||
</button>
|
||||
<button
|
||||
onClick={goToCalendar}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
||||
>
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
انتخاب تاریخ
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNextDay}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
روز بعد
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`flex-1 min-w-[120px] px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
||||
activeTab === tab.value
|
||||
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Summary Tab */}
|
||||
{activeTab === 'summary' && (
|
||||
<SummaryTab
|
||||
temperature={{
|
||||
current: temp.at(-1) ?? 0,
|
||||
min: Math.min(...temp),
|
||||
max: Math.max(...temp),
|
||||
data: temp
|
||||
}}
|
||||
humidity={{
|
||||
current: hum.at(-1) ?? 0,
|
||||
min: Math.min(...hum),
|
||||
max: Math.max(...hum),
|
||||
data: hum
|
||||
}}
|
||||
soil={{
|
||||
current: soil.at(-1) ?? 0,
|
||||
min: Math.min(...soil),
|
||||
max: Math.max(...soil),
|
||||
data: soil
|
||||
}}
|
||||
gas={{
|
||||
current: gas.at(-1) ?? 0,
|
||||
min: Math.min(...gas),
|
||||
max: Math.max(...gas),
|
||||
data: gas
|
||||
}}
|
||||
lux={{
|
||||
current: lux.at(-1) ?? 0,
|
||||
min: Math.min(...lux),
|
||||
max: Math.max(...lux),
|
||||
data: lux
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Charts Tab */}
|
||||
{activeTab === 'charts' && (
|
||||
<ChartsTab
|
||||
chartStartMinute={chartStartMinute}
|
||||
chartEndMinute={chartEndMinute}
|
||||
onStartMinuteChange={setChartStartMinute}
|
||||
onEndMinuteChange={setChartEndMinute}
|
||||
labels={chartLabels}
|
||||
soil={chartSoil}
|
||||
humidity={chartHum}
|
||||
temperature={chartTemp}
|
||||
lux={chartLux}
|
||||
gas={chartGas}
|
||||
tempMinMax={chartTempMinMax}
|
||||
luxMinMax={chartLuxMinMax}
|
||||
totalRecords={filteredTelemetryForCharts.length}
|
||||
dataGaps={dataGaps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Weather Tab */}
|
||||
{activeTab === 'weather' && (
|
||||
<WeatherTab
|
||||
loading={weatherLoading}
|
||||
error={weatherError}
|
||||
weatherData={weatherData}
|
||||
onRetry={() => {
|
||||
setWeatherData(null)
|
||||
setWeatherError(null)
|
||||
loadWeather()
|
||||
}}
|
||||
expandedDayIndex={expandedDayIndex}
|
||||
onDayToggle={setExpandedDayIndex}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Analysis Tab */}
|
||||
{activeTab === 'analysis' && (
|
||||
<AnalysisTab
|
||||
loading={analysisLoading}
|
||||
error={analysisError}
|
||||
dailyReport={dailyReport}
|
||||
onRetry={() => {
|
||||
setDailyReport(null)
|
||||
setAnalysisError(null)
|
||||
loadAnalysis()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,22 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
|
||||
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
if (typeof window === 'undefined') return null as string | null
|
||||
return new URLSearchParams(window.location.search).get(name)
|
||||
}
|
||||
|
||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
export default function DayDetailsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [items, setItems] = useState<{ persianDate: string; count: number }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||
const year = Number(useQueryParam('year') ?? getCurrentPersianYear())
|
||||
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth())
|
||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||
const year = Number(searchParams.get('year') ?? getCurrentPersianYear())
|
||||
const month = Number(searchParams.get('month') ?? getCurrentPersianMonth())
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -120,10 +118,14 @@ export default function DayDetailsPage() {
|
||||
const recordCount = dataByDay.get(day) || 0
|
||||
|
||||
if (hasData) {
|
||||
const dayStr = String(day).padStart(2, '0')
|
||||
const monthStr = String(month).padStart(2, '0')
|
||||
const dateStr = `${year}/${monthStr}/${dayStr}`
|
||||
|
||||
return (
|
||||
<Link
|
||||
<button
|
||||
key={day}
|
||||
href={`/telemetry?deviceId=${deviceId}&date=${encodeURIComponent(`${year}/${month}/${day}`)}`}
|
||||
onClick={() => router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)}
|
||||
className="group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
|
||||
@@ -131,7 +133,7 @@ export default function DayDetailsPage() {
|
||||
<Database className="w-3 h-3" />
|
||||
{recordCount}
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
|
||||
@@ -121,13 +121,22 @@ export default function DeviceSettingsPage() {
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به انتخاب دستگاه
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-md">
|
||||
<Settings className="w-6 h-6 text-white" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-md">
|
||||
<Settings className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
تنظیمات {deviceName}
|
||||
</h1>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
تنظیمات {deviceName}
|
||||
</h1>
|
||||
<Link
|
||||
href={`/alert-settings?deviceId=${deviceId}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api, DeviceDto, PagedResult } from '@/lib/api'
|
||||
import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
|
||||
|
||||
export default function DevicesPage() {
|
||||
const router = useRouter()
|
||||
@@ -35,8 +36,9 @@ export default function DevicesPage() {
|
||||
if (result.items.length === 0 && page === 1) {
|
||||
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
|
||||
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
|
||||
// Single device - redirect to calendar
|
||||
router.push(`/calendar?deviceId=${result.items[0].id}`)
|
||||
// Single device - redirect to today's daily report
|
||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||
router.push(`/daily-report?deviceId=${result.items[0].id}&date=${encodeURIComponent(today)}`)
|
||||
return
|
||||
} else {
|
||||
setPagedResult(result)
|
||||
@@ -190,34 +192,37 @@ export default function DevicesPage() {
|
||||
{pagedResult && pagedResult.items.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{pagedResult.items.map((device) => (
|
||||
<Link
|
||||
key={device.id}
|
||||
href={`/calendar?deviceId=${device.id}`}
|
||||
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
|
||||
<Settings className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||
{device.deviceName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{device.location || 'بدون موقعیت'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>{device.userName} {device.userFamily}</span>
|
||||
{pagedResult.items.map((device) => {
|
||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||
return (
|
||||
<Link
|
||||
key={device.id}
|
||||
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
|
||||
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
|
||||
<Settings className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||
{device.deviceName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{device.location || 'بدون موقعیت'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>{device.userName} {device.userFamily}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</Link>
|
||||
))}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
|
||||
@@ -36,3 +36,75 @@ body {
|
||||
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;
|
||||
}
|
||||
10
src/app/telemetry/GreenHomeBack.code-workspace
Normal file
10
src/app/telemetry/GreenHomeBack.code-workspace
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "../../../../GreenHomeBack"
|
||||
},
|
||||
{
|
||||
"path": "../../.."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<TelemetryDto[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('1day')
|
||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||
const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}`
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(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 <Loading message="در حال بارگذاری دادهها..." />
|
||||
}
|
||||
|
||||
if (!selectedDate) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||
<Link
|
||||
href={`/calendar?deviceId=${deviceId}`}
|
||||
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به تقویم
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/calendar?deviceId=${deviceId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به تقویم
|
||||
</Link>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl shadow-md">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
جزئیات دادههای {selectedDate}
|
||||
</h1>
|
||||
{lastUpdate && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
آخرین بهروزرسانی: {lastUpdate.toLocaleTimeString('fa-IR')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{TIME_RANGE_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => loadData(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg"
|
||||
title="بهروزرسانی دستی"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm">بهروزرسانی</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardGrid>
|
||||
<Card title="نور (Lux)" icon="💡" value={lux.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...lux)} | حداقل: ${Math.min(0, ...lux)}`} />
|
||||
<Card title="گاز CO (ppm)" icon="🫁" value={gas.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...gas)} | حداقل: ${Math.min(0, ...gas)}`} />
|
||||
<Card title="رطوبت خاک (%)" icon="🌱" value={soil.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...soil)} | حداقل: ${Math.min(0, ...soil)}`} />
|
||||
<Card title="رطوبت (%)" icon="💧" value={hum.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...hum)} | حداقل: ${Math.min(0, ...hum)}`} />
|
||||
<Card title="دما (°C)" icon="🌡️" value={temp.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...temp)} | حداقل: ${Math.min(0, ...temp)}`} />
|
||||
<Card title="تعداد داده" icon="📊" value={total} subtitle="در روز انتخابی" />
|
||||
</DashboardGrid>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Panel title="رطوبت خاک">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت خاک (%)', data: soil, borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="رطوبت">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت (%)', data: hum, borderColor: '#3b82f6', backgroundColor: '#dbeafe', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="دما">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'دما (°C)', data: temp, borderColor: '#ef4444', backgroundColor: '#fee2e2', fill: true }]}
|
||||
yAxisMin={tempMinMax.min}
|
||||
yAxisMax={tempMinMax.max}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="نور">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'Lux', data: lux, borderColor: '#a855f7', backgroundColor: '#f3e8ff', fill: true }]}
|
||||
yAxisMin={luxMinMax.min}
|
||||
yAxisMax={luxMinMax.max}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="گاز CO">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'CO (ppm)', data: gas, borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="persian-number">
|
||||
<div className="persian-number -mx-2">
|
||||
<Line
|
||||
data={{
|
||||
labels,
|
||||
@@ -55,26 +140,29 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||
backgroundColor: s.backgroundColor ?? s.borderColor,
|
||||
fill: s.fill ?? false,
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
pointRadius: 1.5,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
spanGaps: false // Don't connect points across null values (gaps)
|
||||
}))
|
||||
}}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 10,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
font: {
|
||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
font: {
|
||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
||||
size: 12
|
||||
},
|
||||
padding: 15
|
||||
}
|
||||
display: false // Hide legend
|
||||
},
|
||||
tooltip: {
|
||||
titleFont: {
|
||||
@@ -91,6 +179,9 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||
return `ساعت: ${context[0].label}`
|
||||
}
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: gapAnnotations
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
@@ -99,16 +190,32 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||
ticks: {
|
||||
font: {
|
||||
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
||||
size: 11
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 0,
|
||||
minRotation: 0,
|
||||
autoSkip: false,
|
||||
callback: function(value, index) {
|
||||
const label = labels[index]
|
||||
return label ? toPersianDigits(label) : ''
|
||||
// Get hour for this index
|
||||
const hour = labelHours[index]
|
||||
if (hour === null) return ''
|
||||
|
||||
// Find if this is the first occurrence of this hour
|
||||
const firstIndexOfHour = labelHours.findIndex(h => 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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
330
src/components/Gauges.tsx
Normal file
330
src/components/Gauges.tsx
Normal file
@@ -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 (
|
||||
<div className="relative w-36 h-24">
|
||||
<svg viewBox="0 0 150 100" className="w-full h-full">
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#1e40af" />
|
||||
<stop offset="20%" stopColor="#3b82f6" />
|
||||
<stop offset="40%" stopColor="#22d3ee" />
|
||||
<stop offset="50%" stopColor="#a3e635" />
|
||||
<stop offset="65%" stopColor="#facc15" />
|
||||
<stop offset="80%" stopColor="#f97316" />
|
||||
<stop offset="100%" stopColor="#dc2626" />
|
||||
</linearGradient>
|
||||
<filter id={glowId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Colored arc (full gradient) */}
|
||||
<path
|
||||
d={`M ${cx - radius} ${cy} A ${radius} ${radius} 0 0 1 ${cx + radius} ${cy}`}
|
||||
fill="none"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={gaugeWidth}
|
||||
/>
|
||||
|
||||
{/* Small rounded caps at the ends */}
|
||||
<circle cx={cx - radius} cy={cy} r={gaugeWidth / 2 - 1} fill="#1e40af" />
|
||||
<circle cx={cx + radius} cy={cy} r={gaugeWidth / 2 - 1} fill="#dc2626" />
|
||||
|
||||
{/* Tick marks and labels */}
|
||||
{ticks.map(({ temp, outerX, outerY, innerX, innerY, labelX, labelY }) => (
|
||||
<g key={temp}>
|
||||
<line
|
||||
x1={innerX}
|
||||
y1={innerY}
|
||||
x2={outerX}
|
||||
y2={outerY}
|
||||
stroke="#9ca3af"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY}
|
||||
fontSize="7"
|
||||
fill="#6b7280"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{toPersianDigits(temp)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Triangle pointer pointing inward */}
|
||||
<polygon
|
||||
points={`${p1x},${p1y} ${p2x},${p2y} ${p3x},${p3y}`}
|
||||
fill="#1f2937"
|
||||
filter={`url(#${glowId})`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center value */}
|
||||
<div className="absolute bottom-1 left-1/2 transform -translate-x-1/2 text-center">
|
||||
<span className="text-xl font-bold text-gray-800">{toPersianDigits(value.toFixed(1))}</span>
|
||||
<span className="text-xs text-gray-500 mr-0.5">°C</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="relative w-20 h-24 flex flex-col items-center">
|
||||
<svg viewBox="0 0 60 75" className="w-full h-full">
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stopColor={fillColor} />
|
||||
<stop offset="100%" stopColor={fillColor} stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
<clipPath id={clipId}>
|
||||
<path d="M30 5 C30 5, 5 35, 5 50 C5 63, 16 72, 30 72 C44 72, 55 63, 55 50 C55 35, 30 5, 30 5 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Background droplet */}
|
||||
<path
|
||||
d="M30 5 C30 5, 5 35, 5 50 C5 63, 16 72, 30 72 C44 72, 55 63, 55 50 C55 35, 30 5, 30 5 Z"
|
||||
fill={bgColor}
|
||||
stroke={fillColor}
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
|
||||
{/* Filled portion */}
|
||||
<rect
|
||||
x="0"
|
||||
y={72 - (percentage * 0.67)}
|
||||
width="60"
|
||||
height={percentage * 0.67 + 5}
|
||||
fill={`url(#${gradientId})`}
|
||||
clipPath={`url(#${clipId})`}
|
||||
/>
|
||||
|
||||
{/* Percentage text inside droplet - larger and with stroke for visibility */}
|
||||
<text
|
||||
x="30"
|
||||
y="48"
|
||||
fontSize="16"
|
||||
fontWeight="bold"
|
||||
fill={textColor}
|
||||
stroke="#fff"
|
||||
strokeWidth="3"
|
||||
paintOrder="stroke"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{toPersianDigits(Math.round(percentage))}%
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="relative w-20 h-24 flex flex-col items-center justify-center">
|
||||
<svg viewBox="0 0 80 80" className="w-16 h-16">
|
||||
{/* Rays */}
|
||||
{Array.from({ length: numRays }).map((_, i) => {
|
||||
const angle = (i * 360) / numRays - 90
|
||||
const isActive = i < activeRays
|
||||
const x1 = 40 + 22 * Math.cos((angle * Math.PI) / 180)
|
||||
const y1 = 40 + 22 * Math.sin((angle * Math.PI) / 180)
|
||||
const x2 = 40 + 35 * Math.cos((angle * Math.PI) / 180)
|
||||
const y2 = 40 + 35 * Math.sin((angle * Math.PI) / 180)
|
||||
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={isActive ? '#f59e0b' : '#e5e7eb'}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center circle */}
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="18"
|
||||
fill={percentage > 0 ? '#fbbf24' : '#f3f4f6'}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* Inner glow */}
|
||||
{percentage > 20 && (
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="12"
|
||||
fill="#fde047"
|
||||
opacity={percentage / 100}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Value below */}
|
||||
<div className="text-center mt-1">
|
||||
<span className="text-lg font-bold text-gray-800">{toPersianDigits(Math.round(value))}</span>
|
||||
<span className="text-[10px] text-gray-500 mr-0.5">Lux</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="relative w-20 h-24 flex flex-col items-center justify-center">
|
||||
<svg viewBox="0 0 80 80" className="w-16 h-16 -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="35"
|
||||
fill="none"
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="35"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pt-1">
|
||||
<span className="text-lg font-bold text-gray-800">{toPersianDigits(Math.round(value))}</span>
|
||||
<span className="text-[10px] text-gray-500">ppm</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
70
src/components/MiniChart.tsx
Normal file
70
src/components/MiniChart.tsx
Normal file
@@ -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 (
|
||||
<div className="w-full h-full">
|
||||
<Line
|
||||
data={{
|
||||
labels: sampledData.map((_, i) => 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
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
80
src/components/daily-report/AnalysisTab.tsx
Normal file
80
src/components/daily-report/AnalysisTab.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<Loader2 className="w-12 h-12 text-indigo-500 animate-spin mb-4" />
|
||||
<p className="text-gray-600">در حال دریافت تحلیل...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!dailyReport) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<p className="text-gray-600">تحلیلی برای نمایش وجود ندارد</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Report Info */}
|
||||
<div className="bg-gradient-to-l from-indigo-50 to-purple-50 rounded-xl p-4 border border-indigo-100">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">تعداد رکوردها</p>
|
||||
<p className="text-lg font-bold text-gray-800">{toPersianDigits(dailyReport.recordCount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">رکوردهای نمونه</p>
|
||||
<p className="text-lg font-bold text-gray-800">{toPersianDigits(dailyReport.sampledRecordCount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">توکنهای مصرفی</p>
|
||||
<p className="text-lg font-bold text-gray-800">{toPersianDigits(dailyReport.totalTokens)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">منبع</p>
|
||||
<p className="text-lg font-bold text-gray-800">{dailyReport.fromCache ? '💾 کش' : '🆕 جدید'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Content */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 prose prose-sm max-w-none markdown-content">
|
||||
<ReactMarkdown>{dailyReport.analysis}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/components/daily-report/ChartsTab.tsx
Normal file
104
src/components/daily-report/ChartsTab.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Time Range Selector */}
|
||||
<TimeRangeSelector
|
||||
startMinute={chartStartMinute}
|
||||
endMinute={chartEndMinute}
|
||||
onStartMinuteChange={onStartMinuteChange}
|
||||
onEndMinuteChange={onEndMinuteChange}
|
||||
totalRecords={totalRecords}
|
||||
dataGaps={dataGaps}
|
||||
/>
|
||||
|
||||
{/* Charts Grid */}
|
||||
{totalRecords === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<BarChart3 className="w-12 h-12 text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">دادهای برای این بازه زمانی موجود نیست</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Panel title="رطوبت خاک">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت خاک (%)', data: soil as (number | null)[], borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="رطوبت">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت (%)', data: humidity as (number | null)[], borderColor: '#3b82f6', backgroundColor: '#dbeafe', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="دما">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'دما (°C)', data: temperature as (number | null)[], borderColor: '#ef4444', backgroundColor: '#fee2e2', fill: true }]}
|
||||
yAxisMin={tempMinMax.min}
|
||||
yAxisMax={tempMinMax.max}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="نور">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'Lux', data: lux as (number | null)[], borderColor: '#a855f7', backgroundColor: '#f3e8ff', fill: true }]}
|
||||
yAxisMin={luxMinMax.min}
|
||||
yAxisMax={luxMinMax.max}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="گاز CO">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'CO (ppm)', data: gas as (number | null)[], borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
82
src/components/daily-report/SummaryCard.tsx
Normal file
82
src/components/daily-report/SummaryCard.tsx
Normal file
@@ -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 <TemperatureGauge value={currentValue} min={-20} max={80} />
|
||||
case 'humidity':
|
||||
return <HumidityGauge value={currentValue} type="air" />
|
||||
case 'soil':
|
||||
return <HumidityGauge value={currentValue} type="soil" />
|
||||
case 'lux':
|
||||
return <LuxGauge value={600} max={2000} />
|
||||
case 'gas':
|
||||
return <GasGauge value={currentValue} max={100} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Gauge on left, Stats on right */}
|
||||
<div className="px-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Gauge on left */}
|
||||
<div className="flex-shrink-0">
|
||||
{renderGauge()}
|
||||
</div>
|
||||
|
||||
{/* Stats on right - stacked */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-500 leading-tight">حداکثر</span>
|
||||
<span className="text-xs font-bold text-gray-800 leading-tight">
|
||||
{toPersianDigits(maxValue.toFixed(1))} {config.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
|
||||
<TrendingDown className="w-3.5 h-3.5 text-red-600 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-500 leading-tight">حداقل</span>
|
||||
<span className="text-xs font-bold text-gray-800 leading-tight">
|
||||
{toPersianDigits(minValue.toFixed(1))} {config.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Chart */}
|
||||
<div className="h-14">
|
||||
<MiniLineChart data={data} color={config.chartColor} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
77
src/components/daily-report/SummaryTab.tsx
Normal file
77
src/components/daily-report/SummaryTab.tsx
Normal file
@@ -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 (
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
param="temperature"
|
||||
currentValue={temperature.current}
|
||||
minValue={temperature.min}
|
||||
maxValue={temperature.max}
|
||||
data={temperature.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="humidity"
|
||||
currentValue={humidity.current}
|
||||
minValue={humidity.min}
|
||||
maxValue={humidity.max}
|
||||
data={humidity.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="soil"
|
||||
currentValue={soil.current}
|
||||
minValue={soil.min}
|
||||
maxValue={soil.max}
|
||||
data={soil.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="gas"
|
||||
currentValue={gas.current}
|
||||
minValue={gas.min}
|
||||
maxValue={gas.max}
|
||||
data={gas.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="lux"
|
||||
currentValue={lux.current}
|
||||
minValue={lux.min}
|
||||
maxValue={lux.max}
|
||||
data={lux.data}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
265
src/components/daily-report/TimeRangeSelector.tsx
Normal file
265
src/components/daily-report/TimeRangeSelector.tsx
Normal file
@@ -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 (
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium text-lg">محدوده زمانی</span>
|
||||
{dataGaps.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span>{toPersianDigits(dataGaps.length)} گپ در دادهها</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onStartMinuteChange(0)
|
||||
onEndMinuteChange(1439) // 23:59
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
کل روز
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline Selector */}
|
||||
<div className="relative h-32 pt-10 mb-4 select-none">
|
||||
{/* Track background */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20 rounded-lg bg-slate-200 dark:bg-slate-700">
|
||||
{/* Sunrise dashed line */}
|
||||
<div
|
||||
className="absolute top-[-20px] bottom-[-20px] inset-y-0 w-0 border-r-2 border-dashed border-slate-400 dark:border-slate-500 z-10"
|
||||
style={{ right: `${sunrisePercent}%` }}
|
||||
></div>
|
||||
|
||||
{/* Sunset dashed line */}
|
||||
<div
|
||||
className="absolute top-[-20px] bottom-[-20px] inset-y-0 w-0 border-r-2 border-dashed border-slate-400 dark:border-slate-500 z-10"
|
||||
style={{ right: `${sunsetPercent}%` }}
|
||||
></div>
|
||||
|
||||
<div
|
||||
className="absolute bottom-[-30px] z-30 pointer-events-none bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
|
||||
style={{ right: `${sunrisePercent}%`, transform: 'translateX(50%)' }}
|
||||
>
|
||||
طلوع {toPersianDigits(sunrise.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunrise.minute.toString().padStart(2, '0'))}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 bottom-0 bg-gray-300"
|
||||
style={{width: `${sunsetPercent}%`}}>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 top-0 bottom-0 bg-gray-300"
|
||||
style={{width: `calc(${100 -sunrisePercent}% - 1px)`}}>
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div key={idx}>
|
||||
{/* Gap area */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-red-400/30 dark:bg-red-500/40 border-r-2 border-l-2 border-red-500 dark:border-red-400 z-15"
|
||||
style={{
|
||||
right: `${gapEndPercent}%`,
|
||||
width: `${gapWidth}%`
|
||||
}}
|
||||
title={`گپ ${toPersianDigits(gapHours)}:${toPersianDigits(gapMins.toString().padStart(2, '0'))}`}
|
||||
>
|
||||
{/* Warning icon in gap */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap tooltip */}
|
||||
{gapWidth > 5 && (
|
||||
<div
|
||||
className="absolute top-[-30px] z-30 pointer-events-none bg-red-500 text-white px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
|
||||
style={{ right: `${gapEndPercent + gapWidth / 2}%`, transform: 'translateX(50%)' }}
|
||||
>
|
||||
گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div
|
||||
className="absolute bottom-[-30px] z-30 pointer-events-none bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
|
||||
style={{ right: `${sunsetPercent}%`, transform: 'translateX(50%)' }}
|
||||
>
|
||||
غروب {toPersianDigits(sunset.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunset.minute.toString().padStart(2, '0'))}
|
||||
</div>
|
||||
|
||||
{/* Hour markers inside track */}
|
||||
{[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map(hour => (
|
||||
<div
|
||||
key={hour}
|
||||
className="absolute bottom-0 z-20"
|
||||
style={{ right: `${((23 - hour) / 23) * 100}%`, transform: 'translateX(50%)' }}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-[10px] text-slate-600 dark:text-slate-400 font-mono">
|
||||
{toPersianDigits(hour.toString().padStart(2, '0'))}
|
||||
</span>
|
||||
<div className="w-px h-2 bg-slate-400 dark:bg-slate-500 mb-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Start time label - above handle */}
|
||||
<div
|
||||
className="absolute top-[-8px] z-30 pointer-events-none"
|
||||
style={{ right: `calc(${((1439 - startMinute) / 1439) * 100}% - 4px)`, transform: 'translateX(50%)' }}
|
||||
>
|
||||
<div className="bg-emerald-500 text-white px-2 py-1 rounded text-sm font-mono whitespace-nowrap">
|
||||
{toPersianDigits(startHour.toString().padStart(2, '0'))}:{toPersianDigits(startMin.toString().padStart(2, '0'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* End time label - above handle */}
|
||||
<div
|
||||
className="absolute top-[-8px] z-30 pointer-events-none"
|
||||
style={{ right: `calc(${((1439 - endMinute) / 1439) * 100}% + 4px)`, transform: 'translateX(50%)' }}
|
||||
>
|
||||
<div className="bg-rose-500 text-white px-2 py-1 rounded text-sm font-mono whitespace-nowrap">
|
||||
{toPersianDigits(endHour.toString().padStart(2, '0'))}:{toPersianDigits(endMin.toString().padStart(2, '0'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Range inputs container */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20">
|
||||
{/* Start time slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1439"
|
||||
value={1439 - startMinute}
|
||||
onChange={(e) => {
|
||||
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 */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1439"
|
||||
value={1439 - endMinute}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info section */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="text-slate-600 dark:text-slate-400">
|
||||
<span className="text-2xl font-bold text-slate-800 dark:text-slate-200 font-mono">
|
||||
{toPersianDigits(Math.floor((endMinute - startMinute + 1) / 60))}:{toPersianDigits(((endMinute - startMinute + 1) % 60).toString().padStart(2, '0'))}
|
||||
</span>
|
||||
<span className="text-sm mr-2">بازه انتخاب شده</span>
|
||||
</div>
|
||||
<div className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
{totalRecords > 0
|
||||
? <>{toPersianDigits(totalRecords)} رکورد</>
|
||||
: <>بدون رکورد</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
516
src/components/daily-report/WeatherTab.tsx
Normal file
516
src/components/daily-report/WeatherTab.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<Loader2 className="w-12 h-12 text-sky-500 animate-spin mb-4" />
|
||||
<p className="text-gray-600">در حال دریافت اطلاعات آب و هوا...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!weatherData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const alerts = getGreenhouseAlerts(weatherData)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Location Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<MapPin className="w-5 h-5 text-sky-500" />
|
||||
<span className="font-medium">قم، ایران</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isToday ? 'پیشبینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Greenhouse Alerts - Only for today */}
|
||||
{isToday && alerts.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">🌱 هشدارها و توصیههای گلخانه</h3>
|
||||
{alerts.map((alert, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-xl border-r-4 ${
|
||||
alert.type === 'danger' ? 'bg-red-50 border-red-500' :
|
||||
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' :
|
||||
alert.type === 'info' ? 'bg-blue-50 border-blue-500' :
|
||||
'bg-green-50 border-green-500'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-semibold text-sm ${
|
||||
alert.type === 'danger' ? 'text-red-700' :
|
||||
alert.type === 'warning' ? 'text-amber-700' :
|
||||
alert.type === 'info' ? 'text-blue-700' :
|
||||
'text-green-700'
|
||||
}`}>{alert.title}</p>
|
||||
<p className="text-sm text-gray-600 mt-1">{alert.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Today's Status Card */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
||||
{/* Current Weather Header - Only for today */}
|
||||
{isToday && (
|
||||
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-white">
|
||||
<p className="text-lg opacity-90 mb-1">🌡️ الان</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-6xl font-bold">
|
||||
{toPersianDigits(Math.round(weatherData.current.temperature))}
|
||||
</span>
|
||||
<span className="text-3xl">درجه</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-white">
|
||||
{(() => {
|
||||
const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
|
||||
return <IconComponent className="w-20 h-20" />
|
||||
})()}
|
||||
<p className="text-lg mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Past Date Header */}
|
||||
{!isToday && (
|
||||
<div className="bg-gradient-to-r from-blue-500 to-indigo-500 p-6">
|
||||
<div className="text-center text-white">
|
||||
<p className="text-lg opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
|
||||
<p className="text-2xl font-bold">{selectedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Grid */}
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
{/* Temperature Card */}
|
||||
<div className={`rounded-2xl p-4 ${
|
||||
weatherData.daily[0]?.tempMin < 5 ? 'bg-blue-100 border-2 border-blue-300' :
|
||||
weatherData.daily[0]?.tempMax > 35 ? 'bg-red-100 border-2 border-red-300' :
|
||||
'bg-green-100 border-2 border-green-300'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||
weatherData.daily[0]?.tempMin < 5 ? 'bg-blue-500' :
|
||||
weatherData.daily[0]?.tempMax > 35 ? 'bg-red-500' :
|
||||
'bg-green-500'
|
||||
}`}>
|
||||
<Thermometer className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
|
||||
<p className={`text-sm ${
|
||||
weatherData.daily[0]?.tempMin < 5 ? 'text-blue-600' :
|
||||
weatherData.daily[0]?.tempMax > 35 ? 'text-red-600' :
|
||||
'text-green-600'
|
||||
}`}>
|
||||
{weatherData.daily[0]?.tempMin < 5 ? '❄️ سرد!' :
|
||||
weatherData.daily[0]?.tempMax > 35 ? '🔥 گرم!' :
|
||||
'✅ مناسب'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">🌙 شب</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(weatherData.daily[0]?.tempMin || 0))}°</p>
|
||||
</div>
|
||||
<div className="text-3xl text-gray-300">←</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">☀️ روز</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(weatherData.daily[0]?.tempMax || 0))}°</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rain Card */}
|
||||
{isToday ? (
|
||||
/* Forecast: احتمال بارش */
|
||||
<div className={`rounded-2xl p-4 ${
|
||||
(weatherData.daily[0]?.precipitationProbability || 0) > 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'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||
(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? 'bg-blue-500' :
|
||||
(weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-400' :
|
||||
'bg-amber-400'
|
||||
}`}>
|
||||
{(weatherData.daily[0]?.precipitationProbability || 0) > 30 ?
|
||||
<CloudRain className="w-8 h-8 text-white" /> :
|
||||
<Sun className="w-8 h-8 text-white" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-sm ${
|
||||
(weatherData.daily[0]?.precipitationProbability || 0) > 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 ? '🌦️ شاید ببارد' :
|
||||
'☀️ خشک است'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(weatherData.daily[0]?.precipitationProbability || 0)}%</p>
|
||||
<p className="text-sm text-gray-500">احتمال بارش</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Historical: میزان بارش واقعی */
|
||||
<div className={`rounded-2xl p-4 ${
|
||||
(weatherData.daily[0]?.precipitation || 0) > 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'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||
(weatherData.daily[0]?.precipitation || 0) > 5 ? 'bg-blue-500' :
|
||||
(weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-400' :
|
||||
'bg-amber-400'
|
||||
}`}>
|
||||
{(weatherData.daily[0]?.precipitation || 0) > 0 ?
|
||||
<CloudRain className="w-8 h-8 text-white" /> :
|
||||
<Sun className="w-8 h-8 text-white" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-sm ${
|
||||
(weatherData.daily[0]?.precipitation || 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 ? '🌦️ بارش کم' :
|
||||
'☀️ بدون بارش'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits((weatherData.daily[0]?.precipitation || 0).toFixed(1))}</p>
|
||||
<p className="text-sm text-gray-500">میلیمتر بارش</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sunlight Card */}
|
||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-2xl p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-14 h-14 rounded-xl bg-yellow-400 flex items-center justify-center">
|
||||
<Sun className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
|
||||
<p className="text-sm text-yellow-600">
|
||||
{(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 8 ? '☀️ آفتاب زیاد' :
|
||||
(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 4 ? '🌤️ آفتاب متوسط' :
|
||||
'☁️ کمآفتاب'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(Math.round((weatherData.daily[0]?.sunshineDuration || 0) / 3600))}</p>
|
||||
<p className="text-sm text-gray-500">ساعت آفتاب</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-2xl font-bold text-orange-500">{toPersianDigits(Math.round(weatherData.daily[0]?.uvIndexMax || 0))}</p>
|
||||
<p className="text-xs text-gray-500">شاخص UV</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wind & Humidity Card */}
|
||||
<div className="bg-cyan-50 border-2 border-cyan-200 rounded-2xl p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-14 h-14 rounded-xl bg-cyan-400 flex items-center justify-center">
|
||||
<Wind className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
|
||||
<p className="text-sm text-cyan-600">
|
||||
{(weatherData.daily[0]?.windSpeedMax || 0) > 40 ? '💨 باد شدید!' :
|
||||
(weatherData.daily[0]?.windSpeedMax || 0) > 20 ? '🍃 وزش باد' :
|
||||
'😌 آرام'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(weatherData.daily[0]?.windSpeedMax || 0))}</p>
|
||||
<p className="text-xs text-gray-500">کیلومتر/ساعت باد</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-3xl font-bold text-blue-500">{toPersianDigits(weatherData.current.humidity)}%</p>
|
||||
<p className="text-xs text-gray-500">رطوبت هوا</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly Forecast - Only for today */}
|
||||
{isToday && (
|
||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 p-4">
|
||||
<h4 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
🕐 وضعیت ساعت به ساعت امروز
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex gap-2" style={{ minWidth: 'max-content' }}>
|
||||
{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 (
|
||||
<div
|
||||
key={hour.time}
|
||||
className={`flex flex-col items-center p-3 rounded-xl min-w-[85px] transition-all ${
|
||||
isNow
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-500 text-white shadow-lg scale-105'
|
||||
: isHot
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: isCold
|
||||
? 'bg-blue-50 border border-blue-200'
|
||||
: 'bg-gray-50 border border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<p className={`text-sm font-bold ${isNow ? 'text-white' : 'text-gray-600'}`}>
|
||||
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
|
||||
</p>
|
||||
|
||||
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
||||
<IconComponent className={`w-6 h-6 ${
|
||||
isNow ? 'text-white' :
|
||||
isHot ? 'text-red-500' :
|
||||
isCold ? 'text-blue-500' :
|
||||
'text-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<p className={`text-2xl font-bold ${
|
||||
isNow ? 'text-white' :
|
||||
isHot ? 'text-red-600' :
|
||||
isCold ? 'text-blue-600' :
|
||||
'text-gray-800'
|
||||
}`}>
|
||||
{toPersianDigits(Math.round(hour.temperature))}°
|
||||
</p>
|
||||
|
||||
<div className={`flex items-center gap-1 mt-2 text-xs ${isNow ? 'text-white/80' : 'text-blue-500'}`}>
|
||||
<Droplets className="w-3 h-3" />
|
||||
<span>{toPersianDigits(hour.humidity)}%</span>
|
||||
</div>
|
||||
|
||||
{hour.precipitation > 0 && (
|
||||
<div className={`mt-1 px-2 py-0.5 rounded-full text-xs ${isNow ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-600'}`}>
|
||||
🌧️ {toPersianDigits(hour.precipitation.toFixed(1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center mt-3">👈 برای دیدن ساعتهای بیشتر به چپ بکشید</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 7-Day Forecast - Only for today */}
|
||||
{isToday && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-emerald-500" />
|
||||
پیشبینی ۷ روز آینده
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={day.date} className="overflow-hidden rounded-xl">
|
||||
<button
|
||||
onClick={() => onDayToggle(isExpanded ? null : index)}
|
||||
className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
|
||||
isExpanded
|
||||
? 'bg-emerald-500 text-white'
|
||||
: isToday
|
||||
? 'bg-emerald-50 hover:bg-emerald-100'
|
||||
: hasFrost
|
||||
? 'bg-blue-50 hover:bg-blue-100'
|
||||
: hasHeat
|
||||
? 'bg-red-50 hover:bg-red-100'
|
||||
: 'bg-gray-50 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
isExpanded ? 'bg-white/20' : 'bg-white'
|
||||
}`}>
|
||||
<IconComponent className={`w-6 h-6 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div className="text-right flex-1">
|
||||
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||
{isToday ? 'امروز' : getPersianDayName(day.date)}
|
||||
</p>
|
||||
<p className={`text-sm ${isExpanded ? 'text-white/80' : 'text-gray-500'}`}>
|
||||
{weatherInfo.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||
{toPersianDigits(Math.round(day.tempMax))}°
|
||||
</p>
|
||||
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
|
||||
{toPersianDigits(Math.round(day.tempMin))}°
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-white p-4 border-t border-emerald-100">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Thermometer className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs text-gray-600 font-medium">دما</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
|
||||
<p className="text-sm text-gray-500">حداکثر</p>
|
||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
|
||||
<p className="text-sm text-gray-500">حداقل</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CloudRain className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs text-gray-600 font-medium">بارش</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
|
||||
<p className="text-sm text-gray-500">احتمال</p>
|
||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
|
||||
<p className="text-sm text-gray-500">میلیمتر</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sun className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
|
||||
<p className="text-sm text-gray-500">ساعت</p>
|
||||
<p className="text-xl font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
|
||||
<p className="text-sm text-gray-500">UV Index</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wind className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-xs text-gray-600 font-medium">باد</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
|
||||
<p className="text-sm text-gray-500">کیلومتر/ساعت</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
10
src/components/daily-report/index.ts
Normal file
10
src/components/daily-report/index.ts
Normal file
@@ -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'
|
||||
|
||||
43
src/components/daily-report/types.ts
Normal file
43
src/components/daily-report/types.ts
Normal file
@@ -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: 'تحلیل' },
|
||||
]
|
||||
|
||||
171
src/components/daily-report/utils.ts
Normal file
171
src/components/daily-report/utils.ts
Normal file
@@ -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<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
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<string, {
|
||||
title: string;
|
||||
unit: string;
|
||||
bgColor: string;
|
||||
chartColor: string;
|
||||
}> = {
|
||||
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<T>(
|
||||
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
|
||||
}
|
||||
|
||||
111
src/components/daily-report/weather-helpers.ts
Normal file
111
src/components/daily-report/weather-helpers.ts
Normal file
@@ -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: 'تهویه استاندارد کافی است' }
|
||||
}
|
||||
|
||||
@@ -79,6 +79,55 @@ export type PagedResult<T> = {
|
||||
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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<PagedResult<DeviceDto>>(`${API_BASE}/api/devices/filtered?${params.toString()}`)
|
||||
}
|
||||
},
|
||||
|
||||
// Daily Report
|
||||
getDailyReport: (deviceId: number, persianDate: string) =>
|
||||
http<DailyReportDto>(`${API_BASE}/api/DailyReport?deviceId=${deviceId}&persianDate=${encodeURIComponent(persianDate)}`),
|
||||
|
||||
// Alert Conditions
|
||||
getAlertConditions: (deviceId?: number) => {
|
||||
const params = deviceId ? `?deviceId=${deviceId}` : ''
|
||||
return http<AlertConditionDto[]>(`${API_BASE}/api/alertconditions${params}`)
|
||||
},
|
||||
getAlertCondition: (id: number) =>
|
||||
http<AlertConditionDto>(`${API_BASE}/api/alertconditions/${id}`),
|
||||
createAlertCondition: (dto: CreateAlertConditionDto) =>
|
||||
http<number>(`${API_BASE}/api/alertconditions`, { method: 'POST', body: JSON.stringify(dto) }),
|
||||
updateAlertCondition: (dto: UpdateAlertConditionDto) =>
|
||||
http<void>(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }),
|
||||
deleteAlertCondition: (id: number) =>
|
||||
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user