new ui and daily report
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 2s
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 2s
This commit is contained in:
726
src/app/alert-settings/page.tsx
Normal file
726
src/app/alert-settings/page.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
api,
|
||||
AlertConditionDto,
|
||||
CreateAlertConditionDto,
|
||||
UpdateAlertConditionDto,
|
||||
AlertRuleDto
|
||||
} from '@/lib/api'
|
||||
import {
|
||||
Bell,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Thermometer,
|
||||
Droplets,
|
||||
Leaf,
|
||||
Wind,
|
||||
Sun,
|
||||
Check,
|
||||
Phone,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Moon,
|
||||
Zap,
|
||||
Maximize2,
|
||||
ArrowLeftRight
|
||||
} from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
|
||||
type SensorType = 0 | 1 | 2 | 3 | 4
|
||||
type ComparisonType = 0 | 1 | 2 | 3
|
||||
type NotificationType = 0 | 1
|
||||
type TimeType = 0 | 1 | 2
|
||||
|
||||
const SENSOR_TYPES: { value: SensorType; label: string; icon: any; unit: string }[] = [
|
||||
{ value: 0, label: 'دما', icon: Thermometer, unit: '°C' },
|
||||
{ value: 1, label: 'رطوبت هوا', icon: Droplets, unit: '%' },
|
||||
{ value: 2, label: 'رطوبت خاک', icon: Leaf, unit: '%' },
|
||||
{ value: 3, label: 'گاز', icon: Wind, unit: 'PPM' },
|
||||
{ value: 4, label: 'نور', icon: Sun, unit: 'Lux' }
|
||||
]
|
||||
|
||||
const COMPARISON_TYPES: { value: ComparisonType; label: string; icon: any; needsMax: boolean }[] = [
|
||||
{ value: 0, label: 'بزرگتر از', icon: () => <span className="text-2xl font-bold">></span>, needsMax: false },
|
||||
{ value: 1, label: 'کوچکتر از', icon: () => <span className="text-2xl font-bold"><</span>, needsMax: false },
|
||||
{ value: 2, label: 'بین', icon: ArrowLeftRight, needsMax: true },
|
||||
{ value: 3, label: 'خارج از محدوده', icon: Maximize2, needsMax: true }
|
||||
]
|
||||
|
||||
const NOTIFICATION_TYPES: { value: NotificationType; label: string; icon: any }[] = [
|
||||
{ value: 0, label: 'تماس تلفنی', icon: Phone },
|
||||
{ value: 1, label: 'پیامک', icon: MessageSquare }
|
||||
]
|
||||
|
||||
const TIME_TYPES: { value: TimeType; label: string; icon: any; description: string }[] = [
|
||||
{ value: 0, label: 'روز', icon: Sun, description: 'فقط در روز بررسی شود' },
|
||||
{ value: 1, label: 'شب', icon: Moon, description: 'فقط در شب بررسی شود' },
|
||||
{ value: 2, label: 'همیشه', icon: Zap, description: 'در هر زمان بررسی شود' }
|
||||
]
|
||||
|
||||
export default function AlertSettingsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||
|
||||
const [alerts, setAlerts] = useState<AlertConditionDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingAlert, setEditingAlert] = useState<AlertConditionDto | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<CreateAlertConditionDto>({
|
||||
deviceId: deviceId,
|
||||
notificationType: 0,
|
||||
timeType: 2,
|
||||
isActive: true,
|
||||
rules: []
|
||||
})
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getAlertConditions(deviceId)
|
||||
setAlerts(data)
|
||||
} catch (error) {
|
||||
console.error('Error loading alerts:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts()
|
||||
}, [loadAlerts])
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingAlert(null)
|
||||
setFormData({
|
||||
deviceId: deviceId,
|
||||
notificationType: 0,
|
||||
timeType: 2,
|
||||
isActive: true,
|
||||
rules: [{
|
||||
sensorType: 0,
|
||||
comparisonType: 0,
|
||||
threshold: 30
|
||||
}]
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openEditModal = (alert: AlertConditionDto) => {
|
||||
setEditingAlert(alert)
|
||||
setFormData({
|
||||
deviceId: alert.deviceId,
|
||||
notificationType: alert.notificationType,
|
||||
timeType: alert.timeType,
|
||||
isActive: alert.isActive,
|
||||
rules: alert.rules.map(r => ({ ...r }))
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setEditingAlert(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (formData.rules.length === 0) {
|
||||
alert('لطفاً حداقل یک قانون اضافه کنید')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (editingAlert) {
|
||||
// Update
|
||||
const updateDto: UpdateAlertConditionDto = {
|
||||
id: editingAlert.id,
|
||||
...formData
|
||||
}
|
||||
await api.updateAlertCondition(updateDto)
|
||||
} else {
|
||||
// Create
|
||||
await api.createAlertCondition(formData)
|
||||
}
|
||||
|
||||
await loadAlerts()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
console.error('Error saving alert:', error)
|
||||
alert('خطا در ذخیره هشدار. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('آیا از حذف این هشدار اطمینان دارید؟')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteAlertCondition(id)
|
||||
await loadAlerts()
|
||||
} catch (error) {
|
||||
console.error('Error deleting alert:', error)
|
||||
alert('خطا در حذف هشدار. لطفاً دوباره تلاش کنید.')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleActive = async (alert: AlertConditionDto) => {
|
||||
try {
|
||||
const updateDto: UpdateAlertConditionDto = {
|
||||
id: alert.id,
|
||||
deviceId: alert.deviceId,
|
||||
notificationType: alert.notificationType,
|
||||
timeType: alert.timeType,
|
||||
isActive: !alert.isActive,
|
||||
rules: alert.rules
|
||||
}
|
||||
await api.updateAlertCondition(updateDto)
|
||||
await loadAlerts()
|
||||
} catch (error) {
|
||||
console.error('Error toggling alert:', error)
|
||||
alert('خطا در تغییر وضعیت هشدار.')
|
||||
}
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
rules: [
|
||||
...formData.rules,
|
||||
{
|
||||
sensorType: 0,
|
||||
comparisonType: 0,
|
||||
threshold: 30
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
rules: formData.rules.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
const updateRule = (index: number, updates: Partial<AlertRuleDto>) => {
|
||||
const newRules = [...formData.rules]
|
||||
newRules[index] = { ...newRules[index], ...updates }
|
||||
setFormData({ ...formData, rules: newRules })
|
||||
}
|
||||
|
||||
const getSensorIcon = (type: SensorType) => {
|
||||
const sensor = SENSOR_TYPES.find(s => s.value === type)
|
||||
return sensor ? sensor.icon : AlertTriangle
|
||||
}
|
||||
|
||||
const getSensorLabel = (type: SensorType) => {
|
||||
const sensor = SENSOR_TYPES.find(s => s.value === type)
|
||||
return sensor?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const getSensorUnit = (type: SensorType) => {
|
||||
const sensor = SENSOR_TYPES.find(s => s.value === type)
|
||||
return sensor?.unit ?? ''
|
||||
}
|
||||
|
||||
const getComparisonLabel = (type: ComparisonType) => {
|
||||
const comp = COMPARISON_TYPES.find(c => c.value === type)
|
||||
return comp?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const getNotificationLabel = (type: NotificationType) => {
|
||||
const notif = NOTIFICATION_TYPES.find(n => n.value === type)
|
||||
return notif?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const getTimeTypeLabel = (type: TimeType) => {
|
||||
const time = TIME_TYPES.find(t => t.value === type)
|
||||
return time?.label ?? type.toString()
|
||||
}
|
||||
|
||||
const formatRuleText = (rule: AlertRuleDto) => {
|
||||
const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
|
||||
if (comp?.needsMax) {
|
||||
return `${getComparisonLabel(rule.comparisonType)}: ${rule.threshold} - ${rule.thresholdMax ?? '?'} ${getSensorUnit(rule.sensorType)}`
|
||||
}
|
||||
const symbol = rule.comparisonType === 0 ? '>' : rule.comparisonType === 1 ? '<' : '?'
|
||||
return `${symbol} ${rule.threshold} ${getSensorUnit(rule.sensorType)}`
|
||||
}
|
||||
|
||||
const generatePreviewText = () => {
|
||||
if (formData.rules.length === 0) {
|
||||
return 'هنوز هیچ شرطی تعریف نشده است.'
|
||||
}
|
||||
|
||||
// نوع اطلاعرسانی
|
||||
const notifText = formData.notificationType === 0 ? 'تماس تلفنی' : 'پیامک'
|
||||
|
||||
// زمان
|
||||
const timeText = formData.timeType === 0
|
||||
? 'در روز'
|
||||
: formData.timeType === 1
|
||||
? 'در شب'
|
||||
: 'در هر زمان'
|
||||
|
||||
// قوانین
|
||||
const rulesText = formData.rules.map(rule => {
|
||||
const sensorName = getSensorLabel(rule.sensorType)
|
||||
const comp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
|
||||
|
||||
if (comp?.needsMax) {
|
||||
// بین یا خارج از محدوده
|
||||
const compText = rule.comparisonType === 2 ? 'بین' : 'خارج از'
|
||||
return `${sensorName} ${compText} ${rule.threshold} تا ${rule.thresholdMax ?? '؟'} ${getSensorUnit(rule.sensorType)}`
|
||||
} else {
|
||||
// بزرگتر یا کوچکتر
|
||||
const compText = rule.comparisonType === 0 ? 'بیشتر از' : 'کمتر از'
|
||||
return `${sensorName} ${compText} ${rule.threshold} ${getSensorUnit(rule.sensorType)}`
|
||||
}
|
||||
})
|
||||
|
||||
// ساخت جمله نهایی
|
||||
if (rulesText.length === 1) {
|
||||
return `ارسال ${notifText} برای زمانی که ${timeText} ${rulesText[0]} باشد.`
|
||||
} else {
|
||||
const lastRule = rulesText[rulesText.length - 1]
|
||||
const otherRules = rulesText.slice(0, -1).join(' و ')
|
||||
return `ارسال ${notifText} برای زمانی که ${timeText} ${otherRules} و ${lastRule} باشند.`
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-xl shadow-md">
|
||||
<Bell className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
تنظیمات هشدارها
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
مدیریت شرایط و هشدارهای دستگاه
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
افزودن هشدار
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
لیست هشدارها ({alerts.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">هیچ هشداری ثبت نشده است</p>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
افزودن اولین هشدار
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} className={`p-6 hover:bg-gray-50 transition-colors ${!alert.isActive ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">
|
||||
{NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && (
|
||||
<span className="w-4 h-4">
|
||||
{(() => {
|
||||
const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon
|
||||
return <Icon className="w-4 h-4" />
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{getNotificationLabel(alert.notificationType)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
||||
{TIME_TYPES.find(t => t.value === alert.timeType)?.icon && (
|
||||
<span className="w-4 h-4">
|
||||
{(() => {
|
||||
const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon
|
||||
return <Icon className="w-4 h-4" />
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{getTimeTypeLabel(alert.timeType)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleActive(alert)}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
alert.isActive
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{alert.isActive ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" />
|
||||
فعال
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-3 h-3" />
|
||||
غیرفعال
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-gray-700">شرایط:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{alert.rules.map((rule, idx) => {
|
||||
const Icon = getSensorIcon(rule.sensorType)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-gray-100 rounded-lg text-sm">
|
||||
<Icon className="w-4 h-4 text-gray-600" />
|
||||
<span className="font-medium">{getSensorLabel(rule.sensorType)}</span>
|
||||
<span className="font-mono text-gray-700">{formatRuleText(rule)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(alert)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(alert.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
{editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Notification Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
نوع اطلاعرسانی
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{NOTIFICATION_TYPES.map(notif => {
|
||||
const Icon = notif.icon
|
||||
const isSelected = formData.notificationType === notif.value
|
||||
return (
|
||||
<button
|
||||
key={notif.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, notificationType: notif.value })}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50 text-orange-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${isSelected ? 'text-orange-500' : 'text-gray-400'}`} />
|
||||
<span className="text-sm font-medium">{notif.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
زمان بررسی شرایط
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
مشخص کنید شرایط در چه زمانی بررسی و هشدار ارسال شود
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{TIME_TYPES.map(time => {
|
||||
const Icon = time.icon
|
||||
const isSelected = formData.timeType === time.value
|
||||
return (
|
||||
<button
|
||||
key={time.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, timeType: time.value })}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
title={time.description}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${isSelected ? 'text-blue-500' : 'text-gray-400'}`} />
|
||||
<span className="text-sm font-medium">{time.label}</span>
|
||||
<span className="text-xs text-gray-500">{time.description}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block">
|
||||
شرایط هشدار
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{formData.rules.length === 0
|
||||
? 'هیچ شرطی تعریف نشده است'
|
||||
: formData.rules.length === 1
|
||||
? 'یک شرط تعریف شده است'
|
||||
: `${formData.rules.length} شرط تعریف شده است (همه باید برقرار باشند)`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
افزودن شرط
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{formData.rules.map((rule, index) => {
|
||||
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
||||
return (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-4 space-y-4 relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{formData.rules.length === 1 ? 'شرط' : `شرط ${index + 1}`}
|
||||
</span>
|
||||
{formData.rules.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(index)}
|
||||
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="حذف این شرط"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sensor Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
کدام سنسور را میخواهید بررسی کنید؟
|
||||
</label>
|
||||
<div className="grid grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{SENSOR_TYPES.map(sensor => {
|
||||
const Icon = sensor.icon
|
||||
const isSelected = rule.sensorType === sensor.value
|
||||
return (
|
||||
<button
|
||||
key={sensor.value}
|
||||
type="button"
|
||||
onClick={() => updateRule(index, { sensorType: sensor.value })}
|
||||
className={`flex flex-col items-center gap-1 p-2 rounded border transition-all ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isSelected ? 'text-orange-500' : 'text-gray-400'}`} />
|
||||
<span className="text-xs">{sensor.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
چه زمانی باید هشدار داد؟
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{COMPARISON_TYPES.map(comp => {
|
||||
const Icon = comp.icon
|
||||
const isSelected = rule.comparisonType === comp.value
|
||||
return (
|
||||
<button
|
||||
key={comp.value}
|
||||
type="button"
|
||||
onClick={() => updateRule(index, { comparisonType: comp.value })}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded border transition-all ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isSelected ? 'text-orange-500' : 'text-gray-400'}`} />
|
||||
<div className="text-xs text-center leading-tight">{comp.label}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Threshold */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
{needsMax ? 'از چه مقداری' : 'چه مقداری'} ({getSensorUnit(rule.sensorType)})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.threshold}
|
||||
onChange={(e) => updateRule(index, { threshold: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{needsMax && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
تا چه مقداری ({getSensorUnit(rule.sensorType)})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.thresholdMax ?? ''}
|
||||
onChange={(e) => updateRule(index, { thresholdMax: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Bell className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-blue-600 mb-1">پیشنمایش هشدار</div>
|
||||
<div className="text-sm text-gray-800 leading-relaxed">
|
||||
{generatePreviewText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="w-4 h-4 text-orange-600 border-gray-300 rounded focus:ring-orange-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
هشدار فعال باشد
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear } from '@/lib/persian-date'
|
||||
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
|
||||
@@ -22,6 +23,7 @@ function useQueryParam(name: string) {
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const router = useRouter()
|
||||
const deviceIdParam = useQueryParam('deviceId')
|
||||
const [deviceId, setDeviceId] = useState<number>(1)
|
||||
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
||||
@@ -131,9 +133,10 @@ export default function CalendarPage() {
|
||||
const isActive = activeMonths.includes(m)
|
||||
const stats = monthDays[m]
|
||||
return (
|
||||
<Link
|
||||
<button
|
||||
key={m}
|
||||
href={`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`}
|
||||
onClick={() => isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)}
|
||||
disabled={!isActive}
|
||||
className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${
|
||||
isActive
|
||||
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
|
||||
@@ -159,7 +162,7 @@ export default function CalendarPage() {
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 rounded-b-xl transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
)}
|
||||
</Link>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
593
src/app/daily-report/page.tsx
Normal file
593
src/app/daily-report/page.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api, TelemetryDto, DailyReportDto } from '@/lib/api'
|
||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date'
|
||||
import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
import {
|
||||
SummaryTab,
|
||||
ChartsTab,
|
||||
WeatherTab,
|
||||
AnalysisTab,
|
||||
TABS,
|
||||
TabType,
|
||||
WeatherData,
|
||||
ensureDateFormat,
|
||||
formatPersianDate,
|
||||
QOM_LAT,
|
||||
QOM_LON,
|
||||
detectDataGaps,
|
||||
DataGap
|
||||
} from '@/components/daily-report'
|
||||
|
||||
export default function DailyReportPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('summary')
|
||||
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
|
||||
const [analysisLoading, setAnalysisLoading] = useState(false)
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
const [weatherError, setWeatherError] = useState<string | null>(null)
|
||||
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
|
||||
const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00
|
||||
const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59
|
||||
|
||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
if (!dateParam) return null
|
||||
try {
|
||||
const decodedDate = decodeURIComponent(dateParam)
|
||||
// Ensure date is in yyyy/MM/dd format
|
||||
return ensureDateFormat(decodedDate)
|
||||
} catch (error) {
|
||||
console.error('Error decoding date parameter:', error)
|
||||
return null
|
||||
}
|
||||
}, [dateParam])
|
||||
|
||||
// Navigate to previous day
|
||||
const goToPreviousDay = useCallback(() => {
|
||||
if (!selectedDate) return
|
||||
const prevDay = getPreviousPersianDay(selectedDate)
|
||||
if (prevDay) {
|
||||
router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(prevDay)}`)
|
||||
}
|
||||
}, [selectedDate, deviceId, router])
|
||||
|
||||
// Navigate to next day
|
||||
const goToNextDay = useCallback(() => {
|
||||
if (!selectedDate) return
|
||||
const nextDay = getNextPersianDay(selectedDate)
|
||||
if (nextDay) {
|
||||
router.push(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(nextDay)}`)
|
||||
}
|
||||
}, [selectedDate, deviceId, router])
|
||||
|
||||
// Navigate to calendar to select a date
|
||||
const goToCalendar = useCallback(() => {
|
||||
router.push(`/calendar?deviceId=${deviceId}`)
|
||||
}, [deviceId, router])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!selectedDate) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||
const startDate = persianToGregorian(year, month, day)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
|
||||
const startUtc = startDate.toISOString()
|
||||
const endUtc = endDate.toISOString()
|
||||
|
||||
const result = await api.listTelemetry({ deviceId, startUtc, endUtc, pageSize: 100000 })
|
||||
setTelemetry(result.items)
|
||||
} catch (error) {
|
||||
console.error('Error loading telemetry:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceId, selectedDate])
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
if (!selectedDate || dailyReport) return
|
||||
|
||||
setAnalysisLoading(true)
|
||||
setAnalysisError(null)
|
||||
|
||||
try {
|
||||
const report = await api.getDailyReport(deviceId, selectedDate)
|
||||
setDailyReport(report)
|
||||
} catch (error) {
|
||||
console.error('Error loading analysis:', error)
|
||||
setAnalysisError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setAnalysisLoading(false)
|
||||
}
|
||||
}, [deviceId, selectedDate, dailyReport])
|
||||
|
||||
const loadWeather = useCallback(async () => {
|
||||
if (weatherData) return
|
||||
|
||||
setWeatherLoading(true)
|
||||
setWeatherError(null)
|
||||
|
||||
try {
|
||||
if (!selectedDate) {
|
||||
setWeatherError('تاریخ انتخاب نشده است')
|
||||
return
|
||||
}
|
||||
|
||||
// تبدیل تاریخ شمسی به میلادی
|
||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||
const gregorianDate = persianToGregorian(year, month, day)
|
||||
|
||||
// بررسی اینکه تاریخ امروز است یا گذشته
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
gregorianDate.setHours(0, 0, 0, 0)
|
||||
|
||||
const isPast = gregorianDate.getTime() < today.getTime()
|
||||
|
||||
let weather: WeatherData
|
||||
|
||||
if (isPast) {
|
||||
// استفاده از Historical API برای روزهای گذشته
|
||||
const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD
|
||||
const response = await fetch(
|
||||
`https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch historical weather data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// ساختار داده برای روزهای گذشته (بدون current و hourly)
|
||||
weather = {
|
||||
current: {
|
||||
temperature: data.daily.temperature_2m_max?.[0] || 0,
|
||||
humidity: 0, // Historical API رطوبت ندارد
|
||||
windSpeed: data.daily.wind_speed_10m_max?.[0] || 0,
|
||||
weatherCode: data.daily.weather_code?.[0] || 0,
|
||||
},
|
||||
hourly: [], // برای گذشته hourly نداریم
|
||||
daily: [{
|
||||
date: data.daily.time?.[0] || dateStr,
|
||||
tempMax: data.daily.temperature_2m_max?.[0] || 0,
|
||||
tempMin: data.daily.temperature_2m_min?.[0] || 0,
|
||||
weatherCode: data.daily.weather_code?.[0] || 0,
|
||||
precipitation: data.daily.precipitation_sum?.[0] || 0,
|
||||
precipitationProbability: 0,
|
||||
uvIndexMax: 0,
|
||||
sunshineDuration: data.daily.sunshine_duration?.[0] || 0,
|
||||
windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0,
|
||||
}]
|
||||
}
|
||||
} else {
|
||||
// استفاده از Forecast API برای امروز و آینده
|
||||
const response = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max&timezone=Asia/Tehran&forecast_days=7`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch weather data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Get only today's hourly data (first 24 hours)
|
||||
const todayHourly = data.hourly.time.slice(0, 24).map((time: string, i: number) => ({
|
||||
time,
|
||||
temperature: data.hourly.temperature_2m[i],
|
||||
humidity: data.hourly.relative_humidity_2m[i],
|
||||
weatherCode: data.hourly.weather_code[i],
|
||||
precipitation: data.hourly.precipitation[i],
|
||||
}))
|
||||
|
||||
weather = {
|
||||
current: {
|
||||
temperature: data.current.temperature_2m,
|
||||
humidity: data.current.relative_humidity_2m,
|
||||
windSpeed: data.current.wind_speed_10m,
|
||||
weatherCode: data.current.weather_code,
|
||||
},
|
||||
hourly: todayHourly,
|
||||
daily: data.daily.time.map((date: string, i: number) => ({
|
||||
date,
|
||||
tempMax: data.daily.temperature_2m_max[i],
|
||||
tempMin: data.daily.temperature_2m_min[i],
|
||||
weatherCode: data.daily.weather_code[i],
|
||||
precipitation: data.daily.precipitation_sum[i],
|
||||
precipitationProbability: data.daily.precipitation_probability_max[i],
|
||||
uvIndexMax: data.daily.uv_index_max[i],
|
||||
sunshineDuration: data.daily.sunshine_duration[i],
|
||||
windSpeedMax: data.daily.wind_speed_10m_max[i],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
setWeatherData(weather)
|
||||
} catch (error) {
|
||||
console.error('Error loading weather:', error)
|
||||
setWeatherError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
setWeatherLoading(false)
|
||||
}
|
||||
}, [weatherData, selectedDate])
|
||||
|
||||
useEffect(() => {
|
||||
// Reset states when date or device changes
|
||||
setDailyReport(null)
|
||||
setWeatherData(null)
|
||||
setAnalysisError(null)
|
||||
setWeatherError(null)
|
||||
loadData()
|
||||
}, [loadData, deviceId, selectedDate])
|
||||
|
||||
// Load analysis when switching to analysis tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'analysis') {
|
||||
loadAnalysis()
|
||||
}
|
||||
}, [activeTab, loadAnalysis])
|
||||
|
||||
// Load weather when switching to weather tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'weather') {
|
||||
loadWeather()
|
||||
}
|
||||
}, [activeTab, loadWeather])
|
||||
|
||||
const sortedTelemetry = useMemo(() => {
|
||||
return [...telemetry].sort((a, b) => {
|
||||
const aTime = a.serverTimestampUtc || a.timestampUtc
|
||||
const bTime = b.serverTimestampUtc || b.timestampUtc
|
||||
return new Date(aTime).getTime() - new Date(bTime).getTime()
|
||||
})
|
||||
}, [telemetry])
|
||||
|
||||
// Data arrays
|
||||
const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry])
|
||||
const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry])
|
||||
const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry])
|
||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
||||
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
||||
|
||||
// Min/Max calculations
|
||||
const tempMinMax = useMemo(() => {
|
||||
const min = Math.min(...temp)
|
||||
const max = Math.max(...temp)
|
||||
return {
|
||||
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
|
||||
max: max > 40 ? Math.floor(max / 10) * 10 : 40
|
||||
}
|
||||
}, [temp])
|
||||
|
||||
const luxMinMax = useMemo(() => {
|
||||
const max = Math.max(...lux)
|
||||
return {
|
||||
min: 0,
|
||||
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
|
||||
}
|
||||
}, [lux])
|
||||
|
||||
// Detect data gaps in the full day data
|
||||
const dataGaps = useMemo(() => {
|
||||
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||
return detectDataGaps(timestamps, 30) // 30 minutes threshold
|
||||
}, [sortedTelemetry])
|
||||
|
||||
// Filtered telemetry for charts based on minute range
|
||||
const filteredTelemetryForCharts = useMemo(() => {
|
||||
return sortedTelemetry.filter(t => {
|
||||
const timestamp = t.serverTimestampUtc || t.timestampUtc
|
||||
const date = new Date(timestamp)
|
||||
const minuteOfDay = date.getHours() * 60 + date.getMinutes()
|
||||
return minuteOfDay >= chartStartMinute && minuteOfDay <= chartEndMinute
|
||||
})
|
||||
}, [sortedTelemetry, chartStartMinute, chartEndMinute])
|
||||
|
||||
// Detect gaps in filtered data
|
||||
const filteredDataGaps = useMemo(() => {
|
||||
const timestamps = filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||
return detectDataGaps(timestamps, 30)
|
||||
}, [filteredTelemetryForCharts])
|
||||
|
||||
// Filtered chart labels
|
||||
const chartLabels = useMemo(() => {
|
||||
return filteredTelemetryForCharts.map(t => {
|
||||
const timestamp = t.serverTimestampUtc || t.timestampUtc
|
||||
const date = new Date(timestamp)
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
})
|
||||
}, [filteredTelemetryForCharts])
|
||||
|
||||
// Helper function to insert nulls for gaps
|
||||
const insertGapsInData = (data: number[], timestamps: string[], gaps: DataGap[]): (number | null)[] => {
|
||||
if (gaps.length === 0 || data.length < 2) return data
|
||||
|
||||
const result: (number | null)[] = []
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
result.push(data[i])
|
||||
|
||||
// Check if there's a gap after this point
|
||||
if (i < data.length - 1) {
|
||||
const currentTime = new Date(timestamps[i])
|
||||
const nextTime = new Date(timestamps[i + 1])
|
||||
const currentMinute = currentTime.getHours() * 60 + currentTime.getMinutes()
|
||||
const nextMinute = nextTime.getHours() * 60 + nextTime.getMinutes()
|
||||
|
||||
// Find if any gap exists between current and next
|
||||
const hasGap = gaps.some(gap =>
|
||||
currentMinute <= gap.startMinute && nextMinute >= gap.endMinute
|
||||
)
|
||||
|
||||
if (hasGap) {
|
||||
result.push(null) // Insert null to break the line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Filtered data arrays for charts (with gaps as null)
|
||||
const filteredTimestamps = useMemo(() =>
|
||||
filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc),
|
||||
[filteredTelemetryForCharts]
|
||||
)
|
||||
|
||||
const chartSoil = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.soilPercent ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartTemp = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.temperatureC ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartHum = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.humidityPercent ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartGas = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.gasPPM ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
const chartLux = useMemo(() => {
|
||||
const data = filteredTelemetryForCharts.map(t => Number(t.lux ?? 0))
|
||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
||||
|
||||
// Min/Max calculations for filtered charts (filter out nulls)
|
||||
const chartTempMinMax = useMemo(() => {
|
||||
const validTemps = chartTemp.filter((t): t is number => t !== null)
|
||||
if (validTemps.length === 0) return { min: 0, max: 40 }
|
||||
const min = Math.min(...validTemps)
|
||||
const max = Math.max(...validTemps)
|
||||
return {
|
||||
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
|
||||
max: max > 40 ? Math.floor(max / 10) * 10 : 40
|
||||
}
|
||||
}, [chartTemp])
|
||||
|
||||
const chartLuxMinMax = useMemo(() => {
|
||||
const validLux = chartLux.filter((l): l is number => l !== null)
|
||||
if (validLux.length === 0) return { min: 0, max: 2000 }
|
||||
const max = Math.max(...validLux)
|
||||
return {
|
||||
min: 0,
|
||||
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
|
||||
}
|
||||
}, [chartLux])
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="در حال بارگذاری دادهها..." />
|
||||
}
|
||||
|
||||
if (!selectedDate) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||
<button
|
||||
onClick={goToCalendar}
|
||||
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به تقویم
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-md">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
گزارش روزانه {selectedDate}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
مشاهده خلاصه و نمودارهای روز
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/alert-settings?deviceId=${deviceId}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Navigation Buttons */}
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<button
|
||||
onClick={goToPreviousDay}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
روز قبل
|
||||
</button>
|
||||
<button
|
||||
onClick={goToCalendar}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
||||
>
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
انتخاب تاریخ
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNextDay}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
روز بعد
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`flex-1 min-w-[120px] px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
||||
activeTab === tab.value
|
||||
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Summary Tab */}
|
||||
{activeTab === 'summary' && (
|
||||
<SummaryTab
|
||||
temperature={{
|
||||
current: temp.at(-1) ?? 0,
|
||||
min: Math.min(...temp),
|
||||
max: Math.max(...temp),
|
||||
data: temp
|
||||
}}
|
||||
humidity={{
|
||||
current: hum.at(-1) ?? 0,
|
||||
min: Math.min(...hum),
|
||||
max: Math.max(...hum),
|
||||
data: hum
|
||||
}}
|
||||
soil={{
|
||||
current: soil.at(-1) ?? 0,
|
||||
min: Math.min(...soil),
|
||||
max: Math.max(...soil),
|
||||
data: soil
|
||||
}}
|
||||
gas={{
|
||||
current: gas.at(-1) ?? 0,
|
||||
min: Math.min(...gas),
|
||||
max: Math.max(...gas),
|
||||
data: gas
|
||||
}}
|
||||
lux={{
|
||||
current: lux.at(-1) ?? 0,
|
||||
min: Math.min(...lux),
|
||||
max: Math.max(...lux),
|
||||
data: lux
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Charts Tab */}
|
||||
{activeTab === 'charts' && (
|
||||
<ChartsTab
|
||||
chartStartMinute={chartStartMinute}
|
||||
chartEndMinute={chartEndMinute}
|
||||
onStartMinuteChange={setChartStartMinute}
|
||||
onEndMinuteChange={setChartEndMinute}
|
||||
labels={chartLabels}
|
||||
soil={chartSoil}
|
||||
humidity={chartHum}
|
||||
temperature={chartTemp}
|
||||
lux={chartLux}
|
||||
gas={chartGas}
|
||||
tempMinMax={chartTempMinMax}
|
||||
luxMinMax={chartLuxMinMax}
|
||||
totalRecords={filteredTelemetryForCharts.length}
|
||||
dataGaps={dataGaps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Weather Tab */}
|
||||
{activeTab === 'weather' && (
|
||||
<WeatherTab
|
||||
loading={weatherLoading}
|
||||
error={weatherError}
|
||||
weatherData={weatherData}
|
||||
onRetry={() => {
|
||||
setWeatherData(null)
|
||||
setWeatherError(null)
|
||||
loadWeather()
|
||||
}}
|
||||
expandedDayIndex={expandedDayIndex}
|
||||
onDayToggle={setExpandedDayIndex}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Analysis Tab */}
|
||||
{activeTab === 'analysis' && (
|
||||
<AnalysisTab
|
||||
loading={analysisLoading}
|
||||
error={analysisError}
|
||||
dailyReport={dailyReport}
|
||||
onRetry={() => {
|
||||
setDailyReport(null)
|
||||
setAnalysisError(null)
|
||||
loadAnalysis()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,22 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
|
||||
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
if (typeof window === 'undefined') return null as string | null
|
||||
return new URLSearchParams(window.location.search).get(name)
|
||||
}
|
||||
|
||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
export default function DayDetailsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [items, setItems] = useState<{ persianDate: string; count: number }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||
const year = Number(useQueryParam('year') ?? getCurrentPersianYear())
|
||||
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth())
|
||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||
const year = Number(searchParams.get('year') ?? getCurrentPersianYear())
|
||||
const month = Number(searchParams.get('month') ?? getCurrentPersianMonth())
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
@@ -120,10 +118,14 @@ export default function DayDetailsPage() {
|
||||
const recordCount = dataByDay.get(day) || 0
|
||||
|
||||
if (hasData) {
|
||||
const dayStr = String(day).padStart(2, '0')
|
||||
const monthStr = String(month).padStart(2, '0')
|
||||
const dateStr = `${year}/${monthStr}/${dayStr}`
|
||||
|
||||
return (
|
||||
<Link
|
||||
<button
|
||||
key={day}
|
||||
href={`/telemetry?deviceId=${deviceId}&date=${encodeURIComponent(`${year}/${month}/${day}`)}`}
|
||||
onClick={() => router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)}
|
||||
className="group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md"
|
||||
>
|
||||
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
|
||||
@@ -131,7 +133,7 @@ export default function DayDetailsPage() {
|
||||
<Database className="w-3 h-3" />
|
||||
{recordCount}
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
|
||||
@@ -121,13 +121,22 @@ export default function DeviceSettingsPage() {
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به انتخاب دستگاه
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-md">
|
||||
<Settings className="w-6 h-6 text-white" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-md">
|
||||
<Settings className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
تنظیمات {deviceName}
|
||||
</h1>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
تنظیمات {deviceName}
|
||||
</h1>
|
||||
<Link
|
||||
href={`/alert-settings?deviceId=${deviceId}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api, DeviceDto, PagedResult } from '@/lib/api'
|
||||
import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
|
||||
|
||||
export default function DevicesPage() {
|
||||
const router = useRouter()
|
||||
@@ -35,8 +36,9 @@ export default function DevicesPage() {
|
||||
if (result.items.length === 0 && page === 1) {
|
||||
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
|
||||
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
|
||||
// Single device - redirect to calendar
|
||||
router.push(`/calendar?deviceId=${result.items[0].id}`)
|
||||
// Single device - redirect to today's daily report
|
||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||
router.push(`/daily-report?deviceId=${result.items[0].id}&date=${encodeURIComponent(today)}`)
|
||||
return
|
||||
} else {
|
||||
setPagedResult(result)
|
||||
@@ -190,34 +192,37 @@ export default function DevicesPage() {
|
||||
{pagedResult && pagedResult.items.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{pagedResult.items.map((device) => (
|
||||
<Link
|
||||
key={device.id}
|
||||
href={`/calendar?deviceId=${device.id}`}
|
||||
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
|
||||
<Settings className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||
{device.deviceName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{device.location || 'بدون موقعیت'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>{device.userName} {device.userFamily}</span>
|
||||
{pagedResult.items.map((device) => {
|
||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||
return (
|
||||
<Link
|
||||
key={device.id}
|
||||
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
|
||||
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
|
||||
<Settings className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||
{device.deviceName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{device.location || 'بدون موقعیت'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>{device.userName} {device.userFamily}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</Link>
|
||||
))}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
|
||||
@@ -35,4 +35,76 @@ body {
|
||||
font-feature-settings: 'ss01';
|
||||
font-variant-numeric: persian;
|
||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Markdown content styles */
|
||||
.markdown-content {
|
||||
line-height: 1.8;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4 {
|
||||
color: #1f2937;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.markdown-content h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin: 0.75em 0;
|
||||
padding-right: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.markdown-content strong {
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125em 0.375em;
|
||||
border-radius: 0.25em;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-right: 4px solid #6366f1;
|
||||
padding-right: 1em;
|
||||
margin: 1em 0;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
10
src/app/telemetry/GreenHomeBack.code-workspace
Normal file
10
src/app/telemetry/GreenHomeBack.code-workspace
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "../../../../GreenHomeBack"
|
||||
},
|
||||
{
|
||||
"path": "../../.."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
|
||||
import { api, TelemetryDto } from '@/lib/api'
|
||||
import { Card, DashboardGrid } from '@/components/DashboardCards'
|
||||
import { LineChart, Panel } from '@/components/Charts'
|
||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
|
||||
import { BarChart3, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Loading from '@/components/Loading'
|
||||
|
||||
// زمان بهروزرسانی خودکار (به میلیثانیه) - میتوانید این مقدار را تغییر دهید
|
||||
const AUTO_REFRESH_INTERVAL = 10 * 1000 // 10 ثانیه
|
||||
|
||||
type TimeRange = 'today' | '1day' | '1hour' | '2hours' | '6hours' | '10hours'
|
||||
|
||||
const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
|
||||
{ value: 'today', label: 'امروز' },
|
||||
{ value: '1day', label: '۲۴ ساعت اخیر' },
|
||||
{ value: '10hours', label: '۱۰ ساعت اخیر' },
|
||||
{ value: '6hours', label: '۶ ساعت اخیر' },
|
||||
{ value: '2hours', label: 'دو ساعت اخیر' },
|
||||
{ value: '1hour', label: 'یک ساعت اخیر' },
|
||||
]
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
if (typeof window === 'undefined') return null as string | null
|
||||
return new URLSearchParams(window.location.search).get(name)
|
||||
}
|
||||
|
||||
export default function TelemetryPage() {
|
||||
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('1day')
|
||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||
const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}`
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const selectedDate = useMemo(() => {
|
||||
if (!dateParam) return null
|
||||
try {
|
||||
const decodedDate = decodeURIComponent(dateParam)
|
||||
return decodedDate
|
||||
} catch (error) {
|
||||
console.error('Error decoding date parameter:', error)
|
||||
return null
|
||||
}
|
||||
}, [dateParam])
|
||||
|
||||
// تابع بارگذاری دادهها
|
||||
const loadData = useCallback(async (showLoading = true) => {
|
||||
if (!selectedDate) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
let startDate: Date
|
||||
let endDate: Date
|
||||
|
||||
if (timeRange === 'today') {
|
||||
// برای یک روز، از تاریخ انتخابی استفاده میکنیم
|
||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||
startDate = persianToGregorian(year, month, day)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
endDate = new Date(startDate)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
} else {
|
||||
// برای بازههای زمانی دیگر، از زمان فعلی به عقب میرویم
|
||||
endDate = new Date()
|
||||
const now = new Date()
|
||||
|
||||
switch (timeRange) {
|
||||
case '1day':
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '1hour':
|
||||
startDate = new Date(now.getTime() - 60 * 60 * 1000)
|
||||
break
|
||||
case '2hours':
|
||||
startDate = new Date(now.getTime() - 2 * 60 * 60 * 1000)
|
||||
break
|
||||
case '6hours':
|
||||
startDate = new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
||||
break
|
||||
case '10hours':
|
||||
startDate = new Date(now.getTime() - 10 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const startUtc = startDate.toISOString();
|
||||
const endUtc = endDate.toISOString();
|
||||
|
||||
const result = await api.listTelemetry({ deviceId, startUtc, endUtc, pageSize: 100000 })
|
||||
setTelemetry(result.items)
|
||||
setTotal(result.totalCount)
|
||||
setLastUpdate(new Date())
|
||||
} catch (error) {
|
||||
console.error('Error loading telemetry:', error)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [deviceId, selectedDate, timeRange])
|
||||
|
||||
// بارگذاری اولیه
|
||||
useEffect(() => {
|
||||
loadData(true)
|
||||
}, [loadData])
|
||||
|
||||
// تنظیم بهروزرسانی خودکار
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return
|
||||
|
||||
// پاک کردن interval قبلی
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
// تنظیم interval جدید
|
||||
intervalRef.current = setInterval(() => {
|
||||
loadData(false) // بدون نمایش loading
|
||||
}, AUTO_REFRESH_INTERVAL)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [selectedDate, loadData])
|
||||
|
||||
const sortedTelemetry = useMemo(() => {
|
||||
return [...telemetry].sort((a, b) => {
|
||||
const aTime = a.serverTimestampUtc || a.timestampUtc
|
||||
const bTime = b.serverTimestampUtc || b.timestampUtc
|
||||
return new Date(aTime).getTime() - new Date(bTime).getTime()
|
||||
})
|
||||
}, [telemetry])
|
||||
|
||||
// تبدیل timestamp به ساعت (HH:MM:SS)
|
||||
const labels = useMemo(() => {
|
||||
return sortedTelemetry.map(t => {
|
||||
const timestamp = t.serverTimestampUtc || t.timestampUtc
|
||||
const date = new Date(timestamp)
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
})
|
||||
}, [sortedTelemetry])
|
||||
const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry])
|
||||
const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry])
|
||||
const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry])
|
||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
||||
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
||||
|
||||
|
||||
const tempMinMax = useMemo(() => {
|
||||
const min = Math.min(...temp);
|
||||
const max = Math.max(...temp);
|
||||
return {
|
||||
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
|
||||
max: max > 40 ? Math.floor(max / 10) * 10 : 40
|
||||
}
|
||||
}, [temp]);
|
||||
|
||||
const luxMinMax = useMemo(() => {
|
||||
const max = Math.max(...lux);
|
||||
return {
|
||||
min: 0,
|
||||
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
|
||||
}
|
||||
}, [lux]);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <Loading message="در حال بارگذاری دادهها..." />
|
||||
}
|
||||
|
||||
if (!selectedDate) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||
<Link
|
||||
href={`/calendar?deviceId=${deviceId}`}
|
||||
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به تقویم
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/calendar?deviceId=${deviceId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به تقویم
|
||||
</Link>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl shadow-md">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
جزئیات دادههای {selectedDate}
|
||||
</h1>
|
||||
{lastUpdate && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
آخرین بهروزرسانی: {lastUpdate.toLocaleTimeString('fa-IR')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as TimeRange)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-xl bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{TIME_RANGE_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => loadData(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg"
|
||||
title="بهروزرسانی دستی"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm">بهروزرسانی</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardGrid>
|
||||
<Card title="نور (Lux)" icon="💡" value={lux.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...lux)} | حداقل: ${Math.min(0, ...lux)}`} />
|
||||
<Card title="گاز CO (ppm)" icon="🫁" value={gas.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...gas)} | حداقل: ${Math.min(0, ...gas)}`} />
|
||||
<Card title="رطوبت خاک (%)" icon="🌱" value={soil.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...soil)} | حداقل: ${Math.min(0, ...soil)}`} />
|
||||
<Card title="رطوبت (%)" icon="💧" value={hum.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...hum)} | حداقل: ${Math.min(0, ...hum)}`} />
|
||||
<Card title="دما (°C)" icon="🌡️" value={temp.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...temp)} | حداقل: ${Math.min(0, ...temp)}`} />
|
||||
<Card title="تعداد داده" icon="📊" value={total} subtitle="در روز انتخابی" />
|
||||
</DashboardGrid>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Panel title="رطوبت خاک">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت خاک (%)', data: soil, borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="رطوبت">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت (%)', data: hum, borderColor: '#3b82f6', backgroundColor: '#dbeafe', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="دما">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'دما (°C)', data: temp, borderColor: '#ef4444', backgroundColor: '#fee2e2', fill: true }]}
|
||||
yAxisMin={tempMinMax.min}
|
||||
yAxisMax={tempMinMax.max}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="نور">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'Lux', data: lux, borderColor: '#a855f7', backgroundColor: '#f3e8ff', fill: true }]}
|
||||
yAxisMin={luxMinMax.min}
|
||||
yAxisMax={luxMinMax.max}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="گاز CO">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'CO (ppm)', data: gas, borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user