new ui and daily report
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 2s

This commit is contained in:
2025-12-17 19:15:28 +03:30
parent c5a69cfbfa
commit 4678207081
26 changed files with 4715 additions and 392 deletions

1129
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,17 @@
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.5.0", "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", "jalaali-js": "^1.2.8",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"next": "15.5.4", "next": "15.5.4",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"react": "19.1.0", "react": "19.1.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0" "react-dom": "19.1.0",
"react-markdown": "^10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View 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">&gt;</span>, needsMax: false },
{ value: 1, label: 'کوچکتر از', icon: () => <span className="text-2xl font-bold">&lt;</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>
)
}

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getCurrentPersianYear } from '@/lib/persian-date' import { getCurrentPersianYear } from '@/lib/persian-date'
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react' import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
@@ -22,6 +23,7 @@ function useQueryParam(name: string) {
} }
export default function CalendarPage() { export default function CalendarPage() {
const router = useRouter()
const deviceIdParam = useQueryParam('deviceId') const deviceIdParam = useQueryParam('deviceId')
const [deviceId, setDeviceId] = useState<number>(1) const [deviceId, setDeviceId] = useState<number>(1)
const [year, setYear] = useState<number>(getCurrentPersianYear()) const [year, setYear] = useState<number>(getCurrentPersianYear())
@@ -131,9 +133,10 @@ export default function CalendarPage() {
const isActive = activeMonths.includes(m) const isActive = activeMonths.includes(m)
const stats = monthDays[m] const stats = monthDays[m]
return ( return (
<Link <button
key={m} 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 ${ className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${
isActive isActive
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1' ? '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 && ( {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" /> <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> </div>

View 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}&current=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>
)
}

View File

@@ -1,24 +1,22 @@
"use client" "use client"
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date' import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react' import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import Loading from '@/components/Loading' 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 = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
export default function DayDetailsPage() { export default function DayDetailsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [items, setItems] = useState<{ persianDate: string; count: number }[]>([]) const [items, setItems] = useState<{ persianDate: string; count: number }[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const deviceId = Number(useQueryParam('deviceId') ?? '1') const deviceId = Number(searchParams.get('deviceId') ?? '1')
const year = Number(useQueryParam('year') ?? getCurrentPersianYear()) const year = Number(searchParams.get('year') ?? getCurrentPersianYear())
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth()) const month = Number(searchParams.get('month') ?? getCurrentPersianMonth())
useEffect(() => { useEffect(() => {
setLoading(true) setLoading(true)
@@ -120,10 +118,14 @@ export default function DayDetailsPage() {
const recordCount = dataByDay.get(day) || 0 const recordCount = dataByDay.get(day) || 0
if (hasData) { if (hasData) {
const dayStr = String(day).padStart(2, '0')
const monthStr = String(month).padStart(2, '0')
const dateStr = `${year}/${monthStr}/${dayStr}`
return ( return (
<Link <button
key={day} 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" 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> <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" /> <Database className="w-3 h-3" />
{recordCount} {recordCount}
</div> </div>
</Link> </button>
) )
} else { } else {
return ( return (

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { api, DeviceSettingsDto } from '@/lib/api' 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 Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
@@ -121,13 +121,22 @@ export default function DeviceSettingsPage() {
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
بازگشت به انتخاب دستگاه بازگشت به انتخاب دستگاه
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center justify-between">
<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"> <div className="flex items-center gap-3">
<Settings className="w-6 h-6 text-white" /> <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> </div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900"> <Link
تنظیمات {deviceName} href={`/alert-settings?deviceId=${deviceId}`}
</h1> 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>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { api, DeviceDto, PagedResult } from '@/lib/api'
import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react' import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
export default function DevicesPage() { export default function DevicesPage() {
const router = useRouter() const router = useRouter()
@@ -35,8 +36,9 @@ export default function DevicesPage() {
if (result.items.length === 0 && page === 1) { if (result.items.length === 0 && page === 1) {
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.') setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
} else if (result.items.length === 1 && result.totalCount === 1 && !search) { } else if (result.items.length === 1 && result.totalCount === 1 && !search) {
// Single device - redirect to calendar // Single device - redirect to today's daily report
router.push(`/calendar?deviceId=${result.items[0].id}`) 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 return
} else { } else {
setPagedResult(result) setPagedResult(result)
@@ -190,34 +192,37 @@ export default function DevicesPage() {
{pagedResult && pagedResult.items.length > 0 ? ( {pagedResult && pagedResult.items.length > 0 ? (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{pagedResult.items.map((device) => ( {pagedResult.items.map((device) => {
<Link const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
key={device.id} return (
href={`/calendar?deviceId=${device.id}`} <Link
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative" key={device.id}
> href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
<div className="flex items-start gap-4"> 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-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 className="flex items-start gap-4">
</div> <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">
<div className="flex-1"> <Settings className="w-7 h-7 text-white" />
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors"> </div>
{device.deviceName} <div className="flex-1">
</h3> <h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
<p className="text-sm text-gray-500 mb-2"> {device.deviceName}
{device.location || 'بدون موقعیت'} </h3>
</p> <p className="text-sm text-gray-500 mb-2">
<div className="flex items-center gap-2 text-xs text-gray-400"> {device.location || 'بدون موقعیت'}
<span>{device.userName} {device.userFamily}</span> </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> </div>
<div className="flex-shrink-0"> <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" />
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" /> </Link>
</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> </div>
{/* Pagination */} {/* Pagination */}

View File

@@ -36,3 +36,75 @@ body {
font-variant-numeric: persian; font-variant-numeric: persian;
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; 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;
}

View File

@@ -0,0 +1,10 @@
{
"folders": [
{
"path": "../../../../GreenHomeBack"
},
{
"path": "../../.."
}
]
}

View File

@@ -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>
)
}

View File

@@ -1,4 +1,5 @@
"use client" "use client"
import React from 'react'
import { Line } from 'react-chartjs-2' import { Line } from 'react-chartjs-2'
import { import {
Chart, Chart,
@@ -10,8 +11,9 @@ import {
Tooltip, Tooltip,
Filler Filler
} from 'chart.js' } 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 { 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)]) 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 = { type Props = {
labels: string[] labels: string[]
@@ -27,24 +34,102 @@ type Props = {
title?: string title?: string
yAxisMin?: number yAxisMin?: number
yAxisMax?: number yAxisMax?: number
dataGaps?: DataGapAnnotation[] // Indices where gaps occur
} }
export function Panel({ title, children }: { title: string; children: React.ReactNode }) { export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return ( 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-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"> <div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3> <h3 className="text-base font-semibold text-gray-900">{title}</h3>
</div> </div>
<div className="p-5"> <div className="p-4">
{children} {children}
</div> </div>
</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 ( return (
<div className="persian-number"> <div className="persian-number -mx-2">
<Line <Line
data={{ data={{
labels, labels,
@@ -55,26 +140,29 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
backgroundColor: s.backgroundColor ?? s.borderColor, backgroundColor: s.backgroundColor ?? s.borderColor,
fill: s.fill ?? false, fill: s.fill ?? false,
tension: 0.3, tension: 0.3,
pointRadius: 2 pointRadius: 1.5,
pointHoverRadius: 4,
borderWidth: 2,
spanGaps: false // Don't connect points across null values (gaps)
})) }))
}} }}
options={{ options={{
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
layout: {
padding: {
left: 0,
right: 0,
top: 10,
bottom: 0
}
},
font: { font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
}, },
plugins: { plugins: {
legend: { legend: {
display: true, display: false // Hide legend
position: 'bottom',
labels: {
font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
size: 12
},
padding: 15
}
}, },
tooltip: { tooltip: {
titleFont: { titleFont: {
@@ -91,6 +179,9 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
return `ساعت: ${context[0].label}` return `ساعت: ${context[0].label}`
} }
} }
},
annotation: {
annotations: gapAnnotations
} }
}, },
scales: { scales: {
@@ -99,16 +190,32 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
ticks: { ticks: {
font: { font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif", family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
size: 11 size: 10
}, },
maxRotation: 0,
minRotation: 0,
autoSkip: false,
callback: function(value, index) { callback: function(value, index) {
const label = labels[index] // Get hour for this index
return label ? toPersianDigits(label) : '' 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: { grid: {
display: false // Remove grid lines
},
border: {
display: true, display: true,
color: 'rgba(0, 0, 0, 0.05)' color: 'rgba(0, 0, 0, 0.1)'
} }
}, },
y: { y: {
@@ -118,15 +225,19 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
ticks: { ticks: {
font: { font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif", family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
size: 11 size: 10
}, },
padding: 8,
callback: function(value) { callback: function(value) {
return toPersianDigits(value.toString()) return toPersianDigits(value.toString())
} }
}, },
grid: { grid: {
display: false // Remove grid lines
},
border: {
display: true, display: true,
color: 'rgba(0, 0, 0, 0.05)' color: 'rgba(0, 0, 0, 0.1)'
} }
} }
} }

330
src/components/Gauges.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'

View 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: 'تحلیل' },
]

View 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
}

View 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: 'تهویه استاندارد کافی است' }
}

View File

@@ -79,6 +79,55 @@ export type PagedResult<T> = {
pageSize: number 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' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
async function http<T>(url: string, init?: RequestInit): Promise<T> { async function http<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } }) 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.page) params.set('page', String(q.page))
if (q.pageSize) params.set('pageSize', String(q.pageSize)) if (q.pageSize) params.set('pageSize', String(q.pageSize))
return http<PagedResult<DeviceDto>>(`${API_BASE}/api/devices/filtered?${params.toString()}`) 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' })
} }

View File

@@ -70,3 +70,57 @@ export function getPersianTodayString(): string {
const persianWeekday = daysOfWeek[(now.getDay() + 1) % 7] // نگاشت درست روز هفته const persianWeekday = daysOfWeek[(now.getDay() + 1) % 7] // نگاشت درست روز هفته
return `${persianWeekday} ${persian.day} ${months[persian.month - 1]} ${persian.year}` 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)
}