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

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"
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>

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"
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 (

View File

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

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 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 */}

View File

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

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