fix ui and add pwa
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 5s

This commit is contained in:
2025-11-15 17:27:10 +03:30
parent 38bc2b4ac2
commit 3870d66f7d
37 changed files with 1777 additions and 811 deletions

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from '@/lib/api'
import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
import Loading from '@/components/Loading'
function useQueryParam(name: string) {
if (typeof window === 'undefined') return null as string | null
@@ -14,11 +15,14 @@ export default function MonthPage() {
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth().toString())
const [daysWithCounts, setDaysWithCounts] = useState<{ day: number; count: number }[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
api.monthDays(deviceId, year, month)
.then(list => setDaysWithCounts(list.map(x => ({ day: Number(x.persianDate.split('/')[2]), count: x.count }))))
.catch(console.error)
.finally(() => setLoading(false))
}, [deviceId, year, month])
const maxDay = useMemo(() => {
@@ -32,6 +36,10 @@ export default function MonthPage() {
const days = useMemo(() => Array.from({ length: maxDay }, (_, i) => i + 1), [maxDay])
const countByDay = useMemo(() => new Map(daysWithCounts.map(d => [d.day, d.count])), [daysWithCounts])
if (loading) {
return <Loading message="در حال بارگذاری تقویم ماه..." />
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-3">
@@ -51,7 +59,7 @@ export default function MonthPage() {
const c = countByDay.get(d) ?? 0
const hasData = c > 0
return (
<a key={d} href={`/day_details?deviceId=${deviceId}&year=${year}&month=${month}&day=${d}`} className={`min-h-[84px] bg-white p-2 flex flex-col border-l-0 ${hasData ? 'bg-green-50 hover:bg-green-100' : ''}`}>
<a key={d} href={`/day-details?deviceId=${deviceId}&year=${year}&month=${month}&day=${d}`} className={`min-h-[84px] bg-white p-2 flex flex-col border-l-0 ${hasData ? 'bg-green-50 hover:bg-green-100' : ''}`}>
<div className="text-xs text-gray-500 mb-1">{d}</div>
{hasData && (
<span className="self-start text-[10px] bg-green-600 text-white rounded-full px-1.5 py-0.5">{c}</span>

View File

@@ -2,6 +2,9 @@
import { useEffect, useMemo, useState } from 'react'
import { api } from '@/lib/api'
import { getCurrentPersianYear } from '@/lib/persian-date'
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading'
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
@@ -24,6 +27,7 @@ export default function CalendarPage() {
const [year, setYear] = useState<number>(getCurrentPersianYear())
const [activeMonths, setActiveMonths] = useState<number[]>([])
const [monthDays, setMonthDays] = useState<Record<number, { days: number; records: number }>>({})
const [loading, setLoading] = useState(true)
const years = useMemo(() => Array.from({ length: 10 }, (_, i) => getCurrentPersianYear() - 2 + i), [])
// وقتی deviceIdParam تغییر کرد، deviceId را به روز کن
@@ -37,6 +41,7 @@ export default function CalendarPage() {
let mounted = true
setMonthDays({})
setActiveMonths([])
setLoading(true)
api.activeMonths(deviceId, year)
.then(async (months) => {
@@ -51,44 +56,113 @@ export default function CalendarPage() {
setMonthDays(Object.fromEntries(entries))
})
.catch(console.error)
.finally(() => {
if (mounted) setLoading(false)
})
return () => { mounted = false }
}, [deviceId, year])
const totalDays = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.days ?? 0), 0), [monthDays])
const totalRecords = useMemo(() => Object.values(monthDays).reduce((s, v) => s + (v?.records ?? 0), 0), [monthDays])
if (loading) {
return <Loading message="در حال بارگذاری تقویم..." />
}
return (
<div className="p-6">
<div className="rounded-2xl border bg-white shadow-sm p-6">
<div className="text-center text-xl font-semibold mb-4">انتخاب سال و ماه 📅</div>
<div className="flex flex-wrap items-center justify-center gap-3 mb-4">
<label className="text-sm text-gray-600">انتخاب سال:</label>
<select className="border rounded-md px-2 py-1" value={year} onChange={e => setYear(Number(e.target.value))}>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
href="/"
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 gap-3 mb-2">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
<CalendarIcon className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">انتخاب سال و ماه</h1>
</div>
</div>
<div className="text-center text-sm text-gray-600 mb-5">
خلاصه {year}: {totalDays} روز دارای دیتا · {totalRecords} رکورد
</div>
{/* Main Card */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
{/* Year Selector */}
<div className="flex flex-wrap items-center justify-center gap-4 mb-6">
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-gray-500" />
انتخاب سال:
</label>
<select
className="px-4 py-2 border-2 border-gray-200 rounded-xl focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all bg-white font-medium"
value={year}
onChange={e => setYear(Number(e.target.value))}
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{monthNames.map((name, idx) => {
const m = idx + 1
const isActive = activeMonths.includes(m)
const stats = monthDays[m]
return (
<a key={m} href={`/day_details?deviceId=${deviceId}&year=${year}&month=${m}`} className={`rounded-xl border p-4 text-center transition ${isActive ? 'bg-white hover:bg-gray-50' : 'opacity-50'}`}>
<div className="text-sm">{name}</div>
{isActive && stats && (
<div className="inline-block mt-3 text-xs bg-green-600 text-white rounded-full px-2 py-1">
{stats.days} روز · {stats.records} رکورد
{/* Summary Stats */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 mb-6">
<div className="flex items-center justify-center gap-6 flex-wrap">
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-green-600" />
<span className="text-sm text-gray-700">
<span className="font-semibold text-green-700">{totalDays}</span> روز دارای داده
</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-green-600" />
<span className="text-sm text-gray-700">
<span className="font-semibold text-green-700">{totalRecords}</span> رکورد
</span>
</div>
</div>
</div>
{/* Months Grid */}
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-4">
{monthNames.map((name, idx) => {
const m = idx + 1
const isActive = activeMonths.includes(m)
const stats = monthDays[m]
return (
<Link
key={m}
href={`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`}
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'
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed'
}`}
>
<div className={`text-lg font-semibold mb-2 ${isActive ? 'text-gray-900' : 'text-gray-400'}`}>
{name}
</div>
)}
</a>
)
})}
{isActive && stats ? (
<div className="space-y-1">
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-full px-3 py-1.5 font-medium">
<Database className="w-3 h-3" />
{stats.days} روز
</div>
<div className="text-xs text-gray-600 mt-2">
{stats.records} رکورد
</div>
</div>
) : (
<div className="text-xs text-gray-400">بدون داده</div>
)}
{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>
)
})}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,164 @@
"use client"
import { useEffect, useState, useMemo } from 'react'
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 [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())
useEffect(() => {
setLoading(true)
api.monthDays(deviceId, year, month)
.then(setItems)
.catch(console.error)
.finally(() => setLoading(false))
}, [deviceId, year, month])
// Create a map of day -> count for quick lookup
const dataByDay = useMemo(() => {
const map = new Map<number, number>()
items.forEach(item => {
const day = parseInt(item.persianDate.split('/')[2])
map.set(day, item.count)
})
return map
}, [items])
// Calculate total days in the month
const totalDays = useMemo(() => {
return getPersianMonthDays(year, month)
}, [year, month])
// Get the starting weekday of the month (0 = Saturday)
const startWeekday = useMemo(() => {
const sw = getPersianMonthStartWeekday(year, month)
return sw;
}, [year, month])
// Generate calendar grid with proper spacing
const calendarGrid = useMemo(() => {
const grid = []
// Add empty cells for days before the month starts
for (let i = 0; i < startWeekday; i++) {
grid.push({ type: 'empty', day: null })
}
// Add all days of the month
for (let day = 1; day <= totalDays; day++) {
grid.push({ type: 'day', day })
}
return grid
}, [startWeekday, totalDays])
if (loading) {
return <Loading message="در حال بارگذاری تقویم..." />
}
return (
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-6xl mx-auto">
{/* 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 gap-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
<CalendarIcon className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
{monthNames[month - 1]} {year}
</h1>
</div>
</div>
{/* Calendar Grid */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
{/* Weekday Headers */}
<div className="grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
{['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
<div key={day} className="p-3 md:p-4 text-sm font-semibold text-gray-700">
{day}
</div>
))}
</div>
{/* Days Grid */}
<div className="grid grid-cols-7 gap-1 p-1 bg-gray-50">
{calendarGrid.map((cell, index) => {
if (cell.type === 'empty') {
return (
<div
key={`empty-${index}`}
className="min-h-[90px] md:min-h-[100px] bg-transparent"
/>
)
}
const day = cell.day!
const hasData = dataByDay.has(day)
const recordCount = dataByDay.get(day) || 0
if (hasData) {
return (
<Link
key={day}
href={`/telemetry?deviceId=${deviceId}&date=${encodeURIComponent(`${year}/${month}/${day}`)}`}
className="group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md"
>
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
<div className="flex items-center gap-1 bg-gradient-to-r from-green-500 to-green-600 text-white text-xs rounded-full px-2.5 py-1 font-medium">
<Database className="w-3 h-3" />
{recordCount}
</div>
</Link>
)
} else {
return (
<div
key={day}
className="min-h-[90px] md:min-h-[100px] bg-gray-50 border border-gray-200 rounded-lg p-2 md:p-3 flex items-center justify-center text-gray-400"
>
<div className="text-sm md:text-base">{day}</div>
</div>
)
}
})}
</div>
</div>
{/* Summary */}
<div className="mt-6 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
<div className="flex items-center justify-center gap-2 text-sm text-gray-700">
<Database className="w-4 h-4 text-green-600" />
<span>
<span className="font-semibold text-green-700">{items.length}</span> روز دارای داده از{' '}
<span className="font-semibold text-green-700">{totalDays}</span> روز ماه
</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,141 +0,0 @@
"use client"
import { useEffect, useState, useMemo } from 'react'
import { api } from '@/lib/api'
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
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 [items, setItems] = useState<{ persianDate: string; count: number }[]>([])
const deviceId = Number(useQueryParam('deviceId') ?? '1')
const year = Number(useQueryParam('year') ?? getCurrentPersianYear())
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth())
useEffect(() => {
api.monthDays(deviceId, year, month).then(setItems).catch(console.error)
}, [deviceId, year, month])
// Create a map of day -> count for quick lookup
const dataByDay = useMemo(() => {
const map = new Map<number, number>()
items.forEach(item => {
const day = parseInt(item.persianDate.split('/')[2])
map.set(day, item.count)
})
return map
}, [items])
// Calculate total days in the month
const totalDays = useMemo(() => {
return getPersianMonthDays(year, month)
}, [year, month])
// Get the starting weekday of the month (0 = Saturday)
const startWeekday = useMemo(() => {
const sw = getPersianMonthStartWeekday(year, month)//- 1;
//if (sw < 0) sw += 7;
return sw;
}, [year, month])
// Generate calendar grid with proper spacing
const calendarGrid = useMemo(() => {
const grid = []
// Add empty cells for days before the month starts
for (let i = 0; i < startWeekday; i++) {
grid.push({ type: 'empty', day: null })
}
// Add all days of the month
for (let day = 1; day <= totalDays; day++) {
grid.push({ type: 'day', day })
}
return grid
}, [startWeekday, totalDays])
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<a
href={`/calendar?deviceId=${deviceId}`}
className="border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
← بازگشت به تقویم
</a>
<h1 className="text-xl font-bold text-gray-900">
{monthNames[month - 1]} {year}
</h1>
<div></div> {/* Spacer for centering */}
</div>
{/* Calendar Grid */}
<div className="rounded-2xl border bg-white shadow-sm overflow-hidden">
{/* Weekday Headers */}
<div className="grid grid-cols-7 text-center bg-gray-100 text-sm font-medium">
{['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
<div key={day} className="p-3 border-b border-gray-200 text-gray-700">
{day}
</div>
))}
</div>
{/* Days Grid */}
<div className="grid grid-cols-7 gap-px bg-gray-200">
{calendarGrid.map((cell, index) => {
if (cell.type === 'empty') {
return (
<div
key={`empty-${index}`}
className="min-h-[80px] bg-gray-100 p-3 flex items-center justify-center border-l-0"
>
</div>
)
}
const day = cell.day!
const hasData = dataByDay.has(day)
const recordCount = dataByDay.get(day) || 0
if (hasData) {
return (
<a
key={day}
href={`/month_select?deviceId=${deviceId}&date=${encodeURIComponent(`${year}/${month}/${day}`)}`}
className="min-h-[80px] bg-white p-3 flex flex-col items-center justify-center border border-transparent hover:border-green-400 hover:bg-green-50 transition-all cursor-pointer group rounded-xl"
>
<div className="text-sm font-medium text-gray-900 mb-1">{day}</div>
<div className="text-xs bg-green-600 text-white rounded-full px-2 py-1">
{recordCount} رکورد
</div>
</a>
)
} else {
return (
<div
key={day}
className="min-h-[80px] bg-gray-50 p-3 flex items-center justify-center border-l-0 text-gray-400 cursor-not-allowed"
>
<div className="text-sm">{day}</div>
</div>
)
}
})}
</div>
</div>
{/* Summary */}
<div className="mt-6 text-center text-sm text-gray-600">
{items.length} روز دارای داده از {totalDays} روز ماه
</div>
</div>
)
}

View File

@@ -0,0 +1,345 @@
"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 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)
}
export default function DeviceSettingsPage() {
const [settings, setSettings] = useState<DeviceSettingsDto | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const deviceId = Number(useQueryParam('deviceId') ?? '1')
const deviceName = useQueryParam('deviceName') ?? 'دستگاه'
const loadSettings = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await api.getDeviceSettings(deviceId)
setSettings(data)
} catch (err) {
console.error('Error loading settings:', err)
setError('خطا در بارگذاری تنظیمات')
} finally {
setLoading(false)
}
}, [deviceId])
useEffect(() => {
loadSettings()
}, [loadSettings])
const handleSave = async () => {
if (!settings) return
try {
setSaving(true)
setError(null)
setSuccess(null)
if (settings.id === 0) {
await api.createDeviceSettings(settings)
setSuccess('تنظیمات با موفقیت ایجاد شد')
} else {
await api.updateDeviceSettings(settings)
setSuccess('تنظیمات با موفقیت به‌روزرسانی شد')
}
} catch (err) {
console.error('Error saving settings:', err)
setError('خطا در ذخیره تنظیمات')
} finally {
setSaving(false)
}
}
const handleInputChange = (field: keyof DeviceSettingsDto, value: number) => {
if (!settings) return
setSettings({ ...settings, [field]: value })
}
const initializeDefaultSettings = () => {
const defaultSettings: DeviceSettingsDto = {
id: 0,
deviceId: deviceId,
deviceName: deviceName,
dangerMaxTemperature: 40,
dangerMinTemperature: 5,
maxTemperature: 35,
minTemperature: 10,
maxGasPPM: 1000,
minGasPPM: 0,
maxLux: 100000,
minLux: 0,
maxHumidityPercent: 80,
minHumidityPercent: 20,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
setSettings(defaultSettings)
}
if (loading) {
return <Loading message="در حال بارگذاری تنظیمات..." />
}
if (!deviceId) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">شناسه دستگاه مشخص نشده است</div>
<Link
href="/devices"
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-5xl mx-auto">
{/* Header */}
<div className="mb-6">
<Link
href={`/devices`}
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 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>
</div>
{/* Messages */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 flex items-center gap-2">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
{error}
</div>
)}
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
{success}
</div>
)}
{!settings ? (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 text-center">
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<div className="text-lg text-gray-600 mb-6">
تنظیمات برای این دستگاه وجود ندارد
</div>
<button
onClick={initializeDefaultSettings}
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
>
ایجاد تنظیمات پیش‌فرض
</button>
</div>
) : (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
<div className="grid gap-8 md:grid-cols-2">
{/* Temperature Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-orange-500 rounded-lg flex items-center justify-center">
<Thermometer className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات دما
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر دما (°C)
</label>
<input
type="number"
step="0.1"
value={settings.maxTemperature}
onChange={(e) => handleInputChange('maxTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل دما (°C)
</label>
<input
type="number"
step="0.1"
value={settings.minTemperature}
onChange={(e) => handleInputChange('minTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
{/* Gas Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg flex items-center justify-center">
<Wind className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات گاز
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر گاز CO (ppm)
</label>
<input
type="number"
value={settings.maxGasPPM}
onChange={(e) => handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل گاز CO (ppm)
</label>
<input
type="number"
value={settings.minGasPPM}
onChange={(e) => handleInputChange('minGasPPM', parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
{/* Light Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-lg flex items-center justify-center">
<Sun className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات نور
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر نور (Lux)
</label>
<input
type="number"
step="0.1"
value={settings.maxLux}
onChange={(e) => handleInputChange('maxLux', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل نور (Lux)
</label>
<input
type="number"
step="0.1"
value={settings.minLux}
onChange={(e) => handleInputChange('minLux', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
{/* Humidity Settings */}
<div className="space-y-5">
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-lg flex items-center justify-center">
<Droplets className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
تنظیمات رطوبت هوا
</h3>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداکثر رطوبت (%)
</label>
<input
type="number"
step="0.1"
value={settings.maxHumidityPercent}
onChange={(e) => handleInputChange('maxHumidityPercent', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
حداقل رطوبت (%)
</label>
<input
type="number"
step="0.1"
value={settings.minHumidityPercent}
onChange={(e) => handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)}
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
/>
</div>
</div>
</div>
{/* Save Button */}
<div className="mt-8 flex justify-center">
<button
onClick={handleSave}
disabled={saving}
className={`px-8 py-4 rounded-xl font-medium transition-all flex items-center gap-2 shadow-md ${
saving
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white hover:shadow-lg transform hover:-translate-y-0.5'
}`}
>
{saving ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
در حال ذخیره...
</>
) : (
<>
<Save className="w-5 h-5" />
ذخیره تنظیمات
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,330 +0,0 @@
"use client"
import { useEffect, useState, useCallback } from 'react'
import { api, DeviceSettingsDto } from '@/lib/api'
function useQueryParam(name: string) {
if (typeof window === 'undefined') return null as string | null
return new URLSearchParams(window.location.search).get(name)
}
export default function DeviceSettingsPage() {
const [settings, setSettings] = useState<DeviceSettingsDto | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const deviceId = Number(useQueryParam('deviceId') ?? '1')
const deviceName = useQueryParam('deviceName') ?? 'دستگاه'
const loadSettings = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await api.getDeviceSettings(deviceId)
setSettings(data)
} catch (err) {
console.error('Error loading settings:', err)
setError('خطا در بارگذاری تنظیمات')
} finally {
setLoading(false)
}
}, [deviceId])
useEffect(() => {
loadSettings()
}, [loadSettings])
const handleSave = async () => {
if (!settings) return
try {
setSaving(true)
setError(null)
setSuccess(null)
if (settings.id === 0) {
// Create new settings
await api.createDeviceSettings(settings)
setSuccess('تنظیمات با موفقیت ایجاد شد')
} else {
// Update existing settings
await api.updateDeviceSettings(settings)
setSuccess('تنظیمات با موفقیت به‌روزرسانی شد')
}
} catch (err) {
console.error('Error saving settings:', err)
setError('خطا در ذخیره تنظیمات')
} finally {
setSaving(false)
}
}
const handleInputChange = (field: keyof DeviceSettingsDto, value: number) => {
if (!settings) return
setSettings({ ...settings, [field]: value })
}
const initializeDefaultSettings = () => {
const defaultSettings: DeviceSettingsDto = {
id: 0,
deviceId: deviceId,
deviceName: deviceName,
dangerMaxTemperature: 40,
dangerMinTemperature: 5,
maxTemperature: 35,
minTemperature: 10,
maxGasPPM: 1000,
minGasPPM: 0,
maxLux: 100000,
minLux: 0,
maxHumidityPercent: 80,
minHumidityPercent: 20,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
setSettings(defaultSettings)
}
if (loading) {
return (
<div className="p-6 text-center">
<div className="text-lg">در حال بارگذاری...</div>
</div>
)
}
if (!deviceId) {
return (
<div className="p-6 text-center">
<div className="text-lg text-red-600">شناسه دستگاه مشخص نشده است</div>
<a
href="/devices"
className="mt-4 inline-block border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
← بازگشت به انتخاب دستگاه
</a>
</div>
)
}
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<a
href={`/devices`}
className="border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
← بازگشت به انتخاب دستگاه
</a>
<h1 className="text-2xl font-bold text-gray-900">
⚙️ تنظیمات {deviceName}
</h1>
<div></div> {/* Spacer for centering */}
</div>
{/* Messages */}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{success && (
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-green-700">
{success}
</div>
)}
{!settings ? (
<div className="text-center py-8">
<div className="text-lg text-gray-600 mb-4">
تنظیمات برای این دستگاه وجود ندارد
</div>
<button
onClick={initializeDefaultSettings}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg transition-colors"
>
ایجاد تنظیمات پیش‌فرض
</button>
</div>
) : (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="grid gap-6 md:grid-cols-2">
{/* Temperature Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
🌡️ تنظیمات دما
</h3>
{/* <div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداکثر دمای خطرناک (°C)
</label>
<input
type="number"
step="0.1"
value={settings.dangerMaxTemperature}
onChange={(e) => handleInputChange('dangerMaxTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداقل دمای خطرناک (°C)
</label>
<input
type="number"
step="0.1"
value={settings.dangerMinTemperature}
onChange={(e) => handleInputChange('dangerMinTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div> */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداکثر دما (°C)
</label>
<input
type="number"
step="0.1"
value={settings.maxTemperature}
onChange={(e) => handleInputChange('maxTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداقل دما (°C)
</label>
<input
type="number"
step="0.1"
value={settings.minTemperature}
onChange={(e) => handleInputChange('minTemperature', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Gas Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
🫁 تنظیمات گاز
</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداکثر گاز CO (ppm)
</label>
<input
type="number"
value={settings.maxGasPPM}
onChange={(e) => handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداقل گاز CO (ppm)
</label>
<input
type="number"
value={settings.minGasPPM}
onChange={(e) => handleInputChange('minGasPPM', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Light Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
💡 تنظیمات نور
</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداکثر نور (Lux)
</label>
<input
type="number"
step="0.1"
value={settings.maxLux}
onChange={(e) => handleInputChange('maxLux', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداقل نور (Lux)
</label>
<input
type="number"
step="0.1"
value={settings.minLux}
onChange={(e) => handleInputChange('minLux', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Humidity Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 border-b pb-2">
💧 تنظیمات رطوبت هوا
</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداکثر رطوبت (%)
</label>
<input
type="number"
step="0.1"
value={settings.maxHumidityPercent}
onChange={(e) => handleInputChange('maxHumidityPercent', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
حداقل رطوبت (%)
</label>
<input
type="number"
step="0.1"
value={settings.minHumidityPercent}
onChange={(e) => handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Save Button */}
<div className="mt-8 flex justify-center">
<button
onClick={handleSave}
disabled={saving}
className={`px-8 py-3 rounded-lg font-medium transition-colors ${saving
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{saving ? 'در حال ذخیره...' : '💾 ذخیره تنظیمات'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,9 @@
"use client"
import { useMemo, useState, useRef, useEffect } from 'react'
import { api, DeviceDto } from '@/lib/api'
import { Settings, CheckCircle2, ArrowRight, Calendar, BarChart3, Activity, RotateCcw } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading'
export default function DevicesPage() {
const [loading, setLoading] = useState(false)
@@ -94,140 +97,173 @@ export default function DevicesPage() {
if (passwordRef.current) passwordRef.current.value = ''
}
if (loading) return <div className="p-6">در حال بررسی دستگاه...</div>
if (loading) {
return <Loading message="در حال بررسی دستگاه..." />
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Main Card */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl shadow-lg p-8 border border-blue-200">
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
{/* Title Section */}
<div className="text-center mb-6">
<div className="flex items-center justify-center mb-2">
<span className="text-red-500 text-xl mr-2">🚀</span>
<h1 className="text-xl font-bold text-gray-900">
{selected ? 'دستگاه فعال' : 'انتخاب دستگاه'}
</h1>
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl shadow-lg mb-4">
{selected ? (
<CheckCircle2 className="w-8 h-8 text-white" />
) : (
<Settings className="w-8 h-8 text-white" />
)}
</div>
<p className="text-sm text-gray-700">
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{selected ? 'دستگاه فعال' : 'انتخاب دستگاه'}
</h1>
<p className="text-sm text-gray-600">
{selected
? `دستگاه "${selected.deviceName}" با موفقیت تأیید شد`
: 'نام دستگاه و رمز عبور را وارد کنید تا عملیات فعال شود.'
: 'نام دستگاه و رمز عبور را وارد کنید'
}
</p>
</div>
{/* نمایش فرم فقط وقتی دستگاه انتخاب نشده */}
{!selected ? (
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Input Fields */}
<div className="space-y-3">
<input
ref={deviceNameRef}
name="deviceName"
className="w-full px-4 py-3 rounded-lg border-2 bg-gray-50 border-gray-200 focus:border-blue-300 focus:outline-none transition-all duration-200"
placeholder="نام دستگاه"
defaultValue={deviceName}
onInput={handleInputChange}
disabled={loading}
/>
<input
ref={passwordRef}
name="password"
className="w-full px-4 py-3 rounded-lg border-2 bg-gray-50 border-gray-200 focus:border-blue-300 focus:outline-none transition-all duration-200"
placeholder="رمز عبور"
type="password"
defaultValue={password}
onInput={handleInputChange}
disabled={loading}
/>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
نام دستگاه
</label>
<input
ref={deviceNameRef}
name="deviceName"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
placeholder="نام دستگاه را وارد کنید"
defaultValue={deviceName}
onInput={handleInputChange}
disabled={loading}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
رمز عبور
</label>
<input
ref={passwordRef}
name="password"
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
placeholder="رمز عبور را وارد کنید"
type="password"
defaultValue={password}
onInput={handleInputChange}
disabled={loading}
/>
</div>
</div>
{/* Error Message */}
{error && (
<div className="text-red-600 text-sm text-center">{error}</div>
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
{/* Confirm Button - همیشه فعال */}
<div className="flex justify-center">
<button
type="submit"
disabled={loading}
className={`px-6 py-3 rounded-lg text-white font-medium transition-all duration-200 flex items-center gap-2 ${!loading
? 'bg-blue-600 hover:bg-blue-700 shadow-md hover:shadow-lg'
: 'bg-gray-400 cursor-not-allowed'
}`}
>
{loading ? (
<>
<span className="animate-spin">⏳</span>
در حال بررسی...
</>
) : (
<>
<span className="text-green-400">✓</span>
تایید
</>
)}
</button>
</div>
{/* Confirm Button */}
<button
type="submit"
disabled={loading || !canSubmit}
className={`w-full py-3 rounded-xl text-white font-medium transition-all duration-200 flex items-center justify-center gap-2 shadow-md ${
!loading && canSubmit
? 'bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 hover:shadow-lg transform hover:-translate-y-0.5'
: 'bg-gray-300 cursor-not-allowed'
}`}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
در حال بررسی...
</>
) : (
<>
<CheckCircle2 className="w-5 h-5" />
تایید و ادامه
</>
)}
</button>
</form>
) : (
/* نمایش دکمه‌های عملیات وقتی دستگاه انتخاب شده */
<div className="space-y-4">
<div className="space-y-5">
{/* اطلاعات دستگاه فعال */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-center">
<p className="text-yellow-800 font-medium">{selected.deviceName}</p>
<p className="text-yellow-600 text-sm">دستگاه فعال</p>
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<CheckCircle2 className="w-5 h-5 text-green-600" />
<p className="text-green-800 font-semibold">{selected.deviceName}</p>
</div>
<p className="text-green-600 text-sm">دستگاه فعال و آماده استفاده</p>
</div>
{/* Main Action Button */}
<div className="flex justify-center">
<a
href={`/month_select?deviceId=${selected.id}`}
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-medium py-4 px-6 rounded-lg shadow-md hover:shadow-lg transition-all duration-200 flex items-center justify-center gap-2"
>
<span className="text-white">💎</span>
داده امروز
</a>
</div>
<Link
href={`/telemetry?deviceId=${selected.id}`}
className="block w-full bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-medium py-4 px-6 rounded-xl shadow-md hover:shadow-lg transition-all duration-200 flex items-center justify-center gap-2 transform hover:-translate-y-0.5"
>
<Activity className="w-5 h-5" />
داده‌های امروز
</Link>
{/* Secondary Action Buttons */}
<div className="flex gap-3">
<a
href={`/device_settings?deviceId=${selected.id}&deviceName=${encodeURIComponent(selected.deviceName)}`}
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 flex items-center justify-center gap-2"
<div className="grid grid-cols-1 gap-3">
<Link
href={`/device-settings?deviceId=${selected.id}&deviceName=${encodeURIComponent(selected.deviceName)}`}
className="flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white font-medium py-3 px-4 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
>
<span className="text-white">⚙️</span>
تنظیمات
</a>
<a
href={`/calendar/month?deviceId=${selected.id}&year=1403&month=1`}
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 text-center"
>
تقویم ماه جاری
</a>
<a
href={`/calendar?deviceId=${selected.id}`}
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 text-center"
>
انتخاب سال و ماه
</a>
<Settings className="w-5 h-5" />
تنظیمات دستگاه
</Link>
<div className="grid grid-cols-2 gap-3">
<Link
href={`/calendar/month?deviceId=${selected.id}&year=1403&month=1`}
className="flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 text-white font-medium py-3 px-4 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg text-sm"
>
<Calendar className="w-4 h-4" />
تقویم ماه
</Link>
<Link
href={`/calendar?deviceId=${selected.id}`}
className="flex items-center justify-center gap-2 bg-orange-500 hover:bg-orange-600 text-white font-medium py-3 px-4 rounded-xl transition-all duration-200 shadow-md hover:shadow-lg text-sm"
>
<BarChart3 className="w-4 h-4" />
انتخاب ماه
</Link>
</div>
</div>
{/* دکمه تغییر دستگاه */}
<div className="flex justify-center pt-4">
<div className="pt-4 border-t border-gray-200">
<button
onClick={handleReset}
className="px-6 py-2 bg-gray-500 hover:bg-gray-600 text-white font-medium rounded-lg transition-all duration-200 flex items-center gap-2"
className="w-full flex items-center justify-center gap-2 px-6 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-xl transition-all duration-200"
>
<span>🔄</span>
<RotateCcw className="w-4 h-4" />
تغییر دستگاه
</button>
</div>
</div>
)}
</div>
{/* Back to Home */}
<div className="mt-6 text-center">
<Link
href="/"
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowRight className="w-4 h-4" />
بازگشت به صفحه اصلی
</Link>
</div>
</div>
</div>
)

View File

@@ -8,8 +8,8 @@
@theme inline {
/* --color-background: var(--background); */
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
--font-mono: 'Vazirmatn', monospace;
}
@media (prefers-color-scheme: dark) {
@@ -19,9 +19,20 @@
}
}
* {
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
}
body {
/* background: var(--background); */
background: linear-gradient(180deg, #eef7ff, #f6fbff);
color: var(--foreground);
font-family: vazir,Arial, Helvetica, sans-serif;
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
font-weight: 400;
}
.persian-number {
font-feature-settings: 'ss01';
font-variant-numeric: persian;
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
}

View File

@@ -1,21 +1,41 @@
import type { Metadata } from 'next'
import './globals.css'
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
export const metadata: Metadata = {
title: 'GreenHome',
description: 'GreenHome PWA',
manifest: 'manifest.json',
description: 'مدیریت هوشمند گلخانه',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'GreenHome'
}
}
export const viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="fa" dir="rtl">
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#16a34a" />
<meta name="application-name" content="GreenHome" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="GreenHome" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/icon-512.png" />
<link rel="stylesheet" href="/fonts/vazirmatn/style.css" />
</head>
<body>{children}</body>
<body className='persian-number'>
<ServiceWorkerRegistration />
{children}
</body>
</html>
)
}

27
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,27 @@
import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'GreenHome',
short_name: 'GreenHome',
description: 'مدیریت هوشمند گلخانه',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#16a34a',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
}
}

View File

@@ -1,144 +0,0 @@
"use client"
import { useEffect, useMemo, useState } 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'
function useQueryParam(name: string) {
if (typeof window === 'undefined') return null as string | null
return new URLSearchParams(window.location.search).get(name)
}
export default function MonthSelectPage() {
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const deviceId = Number(useQueryParam('deviceId') ?? '1')
const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}` // e.g., "1404/7/14" or "1404%2F7%2F14"
// Decode the date parameter
const selectedDate = useMemo(() => {
if (!dateParam) return null
try {
// Decode URL-encoded date (e.g., "1404%2F7%2F14" -> "1404/7/14")
const decodedDate = decodeURIComponent(dateParam)
return decodedDate
} catch (error) {
console.error('Error decoding date parameter:', error)
return null
}
}, [dateParam])
useEffect(() => {
if (!selectedDate) {
setLoading(false)
return
}
setLoading(true)
try {
// Parse the Persian date (e.g., "1404/7/14")
const [year, month, day] = selectedDate.split('/').map(Number)
// Convert to Gregorian date for start of day
const startDate = persianToGregorian(year, month, day)
startDate.setHours(0, 0, 0, 0)
// End of day
const endDate = new Date(startDate)
endDate.setHours(23, 59, 59, 999)
// Convert to UTC strings
const startUtc = startDate.toISOString()
const endUtc = endDate.toISOString()
// Load telemetry data for the specific date range
api.listTelemetry({ deviceId, startUtc, endUtc }).then(r => {
setTelemetry(r.items)
setTotal(r.totalCount)
}).catch(console.error).finally(() => setLoading(false))
} catch (error) {
console.error('Error parsing date:', error)
setLoading(false)
}
}, [deviceId, selectedDate])
// Sort telemetry data by timestamp to ensure proper chronological order
const sortedTelemetry = useMemo(() => {
return [...telemetry].sort((a, b) => new Date(a.timestampUtc).getTime() - new Date(b.timestampUtc).getTime())
}, [telemetry])
const labels = useMemo(() => sortedTelemetry.map(t => t.persianDate), [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])
if (loading) {
return (
<div className="p-6 text-center">
<div className="text-lg">در حال بارگذاری...</div>
</div>
)
}
if (!selectedDate) {
return (
<div className="p-6 text-center">
<div className="text-lg text-red-600">تاریخ انتخاب نشده است</div>
<a
href={`/calendar?deviceId=${deviceId}`}
className="mt-4 inline-block border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
← بازگشت به تقویم
</a>
</div>
)
}
return (
<div className="p-4 space-y-4">
{/* Header with navigation */}
<div className="rounded-xl border border-slate-300 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between mb-4">
<a
href={`/calendar?deviceId=${deviceId}`}
className="border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
← بازگشت به تقویم
</a>
<h1 className="text-2xl font-bold text-gray-900">
📊 جزئیات داده‌های {selectedDate}
</h1>
<div></div> {/* Spacer for centering */}
</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)" value={gas.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...gas)} | حداقل: ${Math.min(0, ...gas)}`} />
<Card title="🌱 رطوبت خاک (%)" value={soil.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...soil)} | حداقل: ${Math.min(0, ...soil)}`} />
<Card title="💧 رطوبت (%)" 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="📊 تعداد داده" 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 }]} />
</Panel>
<Panel title="دما و رطوبت">
<LineChart labels={labels} series={[{ label: 'دما (°C)', data: temp, borderColor: '#ef4444' }, { label: 'رطوبت (%)', data: hum, borderColor: '#3b82f6' }]} />
</Panel>
<Panel title="نور">
<LineChart labels={labels} series={[{ label: 'Lux', data: lux, borderColor: '#a855f7' }]} />
</Panel>
<Panel title="گاز CO">
<LineChart labels={labels} series={[{ label: 'CO (ppm)', data: gas, borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]} />
</Panel>
</div>
</div>
)
}

View File

@@ -1,13 +1,90 @@
import { Home as HomeIcon, Settings, Calendar, BarChart3, Leaf } from 'lucide-react'
import Link from 'next/link'
export default function Home() {
const menuItems = [
{
title: 'انتخاب دستگاه',
description: 'اتصال به دستگاه گلخانه',
href: '/devices',
icon: Settings,
color: 'from-blue-500 to-blue-600',
iconColor: 'text-blue-500'
},
{
title: 'تقویم',
description: 'مشاهده داده‌های ماهانه',
href: '/calendar?deviceId=1',
icon: Calendar,
color: 'from-green-500 to-green-600',
iconColor: 'text-green-500'
},
{
title: 'جزئیات روز',
description: 'داده‌های روزانه',
href: '/day-details?deviceId=1&year=1403&month=1',
icon: BarChart3,
color: 'from-purple-500 to-purple-600',
iconColor: 'text-purple-500'
},
{
title: 'داده‌های تله‌متری',
description: 'مشاهده داده‌های لحظه‌ای',
href: '/telemetry?deviceId=1',
icon: Leaf,
color: 'from-orange-500 to-orange-600',
iconColor: 'text-orange-500'
}
]
return (
<main className="p-6">
<h1 className="text-2xl mb-4">GreenHome</h1>
<ul className="list-disc pl-5 space-y-2">
<li><a className="text-green-700 underline" href="/devices">انتخاب دستگاه</a></li>
<li><a className="text-green-700 underline" href="/calendar?deviceId=1">Calendar (انتخاب ماه)</a></li>
<li><a className="text-green-700 underline" href="/day_details?deviceId=1&year=1403&month=1">Day Details نمونه</a></li>
<li><a className="text-green-700 underline" href="/month_select?deviceId=1">Month Select/Telemetry</a></li>
</ul>
<main className="min-h-screen p-4 md:p-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-12 mt-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-green-500 to-green-600 rounded-2xl shadow-lg mb-4">
<HomeIcon className="w-10 h-10 text-white" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-3">GreenHome</h1>
<p className="text-lg text-gray-600">مدیریت هوشمند گلخانه</p>
</div>
{/* Menu Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{menuItems.map((item) => {
const Icon = item.icon
return (
<Link
key={item.href}
href={item.href}
className="group relative bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100"
>
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br ${item.color} flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300`}>
<Icon 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">
{item.title}
</h3>
<p className="text-sm text-gray-500">{item.description}</p>
</div>
</div>
</div>
<div className={`absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r ${item.color} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`} />
</Link>
)
})}
</div>
{/* Footer Info */}
<div className="mt-12 text-center">
<p className="text-sm text-gray-500">
سیستم مدیریت و مانیتورینگ گلخانه هوشمند
</p>
</div>
</div>
</main>
)
}

95
src/app/service-worker.ts Normal file
View File

@@ -0,0 +1,95 @@
/// <reference lib="webworker" />
export type {};
declare let self: ServiceWorkerGlobalScope;
const CACHE_NAME = 'greenhome-v1';
// Add list of files to cache here.
const FILES_TO_CACHE = [
'/',
'/manifest.json',
'/icon-192.png',
'/icon-512.png',
];
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(FILES_TO_CACHE);
await self.skipWaiting();
})()
);
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
await self.clients.claim();
// Remove old caches
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})()
);
});
self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') {
event.respondWith(
(async () => {
try {
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}
return await fetch(event.request);
} catch (e) {
const cache = await caches.open(CACHE_NAME);
return await cache.match('/') || new Response('', {
status: 404,
statusText: 'Not Found',
});
}
})()
);
return;
}
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(event.request);
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
if (event.request.url.startsWith('http')) {
const responseToCache = response.clone();
await cache.put(event.request, responseToCache);
}
return response;
} catch (e) {
return new Response('', {
status: 404,
statusText: 'Not Found',
});
}
})()
);
});

207
src/app/telemetry/page.tsx Normal file
View File

@@ -0,0 +1,207 @@
"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 ثانیه
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 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 {
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 })
setTelemetry(result.items)
setTotal(result.totalCount)
setLastUpdate(new Date())
} catch (error) {
console.error('Error loading telemetry:', error)
} finally {
if (showLoading) {
setLoading(false)
}
}
}, [deviceId, selectedDate])
// بارگذاری اولیه
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) => new Date(a.timestampUtc).getTime() - new Date(b.timestampUtc).getTime())
}, [telemetry])
// تبدیل timestamp به ساعت (HH:MM:SS)
const labels = useMemo(() => {
return sortedTelemetry.map(t => {
const date = new Date(t.timestampUtc)
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])
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">
<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>
<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>
<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 }]} />
</Panel>
<Panel title="دما و رطوبت">
<LineChart labels={labels} series={[{ label: 'دما (°C)', data: temp, borderColor: '#ef4444' }, { label: 'رطوبت (%)', data: hum, borderColor: '#3b82f6' }]} />
</Panel>
<Panel title="نور">
<LineChart labels={labels} series={[{ label: 'Lux', data: lux, borderColor: '#a855f7' }]} />
</Panel>
<Panel title="گاز CO">
<LineChart labels={labels} series={[{ label: 'CO (ppm)', data: gas, borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]} />
</Panel>
</div>
</div>
</div>
)
}

View File

@@ -13,6 +13,12 @@ import {
Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler)
// تابع تبدیل ارقام انگلیسی به فارسی
function toPersianDigits(str: string | number): string {
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
return str.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
}
type Series = { label: string; data: number[]; borderColor: string; backgroundColor?: string; fill?: boolean }
type Props = {
@@ -23,34 +29,106 @@ type Props = {
export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-slate-300 bg-white p-4 shadow-sm">
<div className="text-center font-medium mb-2">{title}</div>
{children}
<div className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-4">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div>
<div className="p-5">
{children}
</div>
</div>
)
}
export function LineChart({ labels, series }: Props) {
return (
<Line
data={{
labels,
datasets: series.map(s => ({
label: s.label,
data: s.data,
borderColor: s.borderColor,
backgroundColor: s.backgroundColor ?? s.borderColor,
fill: s.fill ?? false,
tension: 0.3,
pointRadius: 2
}))
}}
options={{
responsive: true,
plugins: { legend: { display: true, position: 'bottom' } },
scales: { x: { display: true }, y: { display: true } }
}}
/>
<div className="persian-number">
<Line
data={{
labels,
datasets: series.map(s => ({
label: s.label,
data: s.data,
borderColor: s.borderColor,
backgroundColor: s.backgroundColor ?? s.borderColor,
fill: s.fill ?? false,
tension: 0.3,
pointRadius: 2
}))
}}
options={{
responsive: true,
maintainAspectRatio: true,
font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
size: 12
},
padding: 15
}
},
tooltip: {
titleFont: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
},
bodyFont: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
},
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${toPersianDigits(context.parsed.y.toFixed(2))}`
},
title: function(context) {
return `ساعت: ${context[0].label}`
}
}
}
},
scales: {
x: {
display: true,
ticks: {
font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
size: 11
},
callback: function(value, index) {
const label = labels[index]
return label ? toPersianDigits(label) : ''
}
},
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.05)'
}
},
y: {
display: true,
ticks: {
font: {
family: "'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
size: 11
},
callback: function(value) {
return toPersianDigits(value.toString())
}
},
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.05)'
}
}
}
}}
/>
</div>
)
}

View File

@@ -1,23 +1,115 @@
"use client"
import React from 'react'
import { Sun, Wind, Droplets, Thermometer, Database, TrendingUp, TrendingDown } from 'lucide-react'
type CardProps = {
title: string
value: string | number
subtitle?: string,
icon?:string
subtitle?: string
icon?: string
color?: 'light' | 'gas' | 'soil' | 'humidity' | 'temperature' | 'data'
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
'💡': Sun,
'🫁': Wind,
'🌱': Droplets,
'💧': Droplets,
'🌡️': Thermometer,
'📊': Database,
}
const colorConfig: Record<string, { gradient: string; border: string; iconBg: string }> = {
light: {
gradient: 'from-yellow-500 to-orange-500',
border: 'border-yellow-200',
iconBg: 'from-yellow-500 to-orange-500'
},
gas: {
gradient: 'from-gray-600 to-gray-700',
border: 'border-gray-300',
iconBg: 'from-gray-600 to-gray-700'
},
soil: {
gradient: 'from-green-500 to-emerald-600',
border: 'border-green-200',
iconBg: 'from-green-500 to-emerald-600'
},
humidity: {
gradient: 'from-blue-500 to-cyan-500',
border: 'border-blue-200',
iconBg: 'from-blue-500 to-cyan-500'
},
temperature: {
gradient: 'from-red-500 to-orange-500',
border: 'border-red-200',
iconBg: 'from-red-500 to-orange-500'
},
data: {
gradient: 'from-purple-500 to-indigo-600',
border: 'border-purple-200',
iconBg: 'from-purple-500 to-indigo-600'
}
}
// Auto-detect color based on title
function detectColor(title: string, icon?: string): 'light' | 'gas' | 'soil' | 'humidity' | 'temperature' | 'data' {
const lowerTitle = title.toLowerCase()
if (lowerTitle.includes('نور') || icon === '💡') return 'light'
if (lowerTitle.includes('گاز') || icon === '🫁') return 'gas'
if (lowerTitle.includes('خاک') || icon === '🌱') return 'soil'
if (lowerTitle.includes('رطوبت') || icon === '💧') return 'humidity'
if (lowerTitle.includes('دما') || icon === '🌡️') return 'temperature'
if (lowerTitle.includes('داده') || icon === '📊') return 'data'
return 'data' // default
}
export function DashboardGrid({ children }: { children: React.ReactNode }) {
return <div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">{children}</div>
return <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">{children}</div>
}
export function Card({ title, value, subtitle,icon }: CardProps) {
export function Card({ title, value, subtitle, icon, color }: CardProps) {
const IconComponent = icon && iconMap[icon] ? iconMap[icon] : null
const cardColor = color || detectColor(title, icon)
const colors = colorConfig[cardColor]
// Extract max and min from subtitle if available
const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل')
return (
<div className="rounded-xl border border-slate-300 text-center bg-white p-4 shadow-sm">
<div className="text-xs text-gray-500 mb-2">{icon} {title}</div>
<div className="text-2xl font-semibold">{value}</div>
{subtitle && <div className="text-[11px] text-gray-400 mt-1">{subtitle}</div>}
<div className={`group relative bg-white rounded-xl border-2 ${colors.border} shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden`}>
<div className="p-5">
<div className="flex items-center justify-between mb-3">
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
{title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()}
</div>
{IconComponent && (
<div className={`w-8 h-8 bg-gradient-to-br ${colors.iconBg} rounded-lg flex items-center justify-center shadow-sm`}>
<IconComponent className="w-4 h-4 text-white" />
</div>
)}
</div>
<div className="text-3xl font-bold text-gray-900 mb-2">{value}</div>
{subtitle && (
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap">
{hasStats ? (
<>
<span className="flex items-center gap-1">
<TrendingUp className="w-3 h-3 text-green-600" />
{subtitle.match(/حداکثر:\s*([0-9.]+)/)?.[1]}
</span>
<span className="flex items-center gap-1">
<TrendingDown className="w-3 h-3 text-red-600" />
{subtitle.match(/حداقل:\s*([0-9.]+)/)?.[1]}
</span>
</>
) : (
subtitle
)}
</div>
)}
</div>
<div className={`absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r ${colors.gradient} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`} />
</div>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import { Loader2 } from 'lucide-react'
type LoadingProps = {
message?: string
fullScreen?: boolean
}
export default function Loading({ message = 'در حال بارگذاری...', fullScreen = true }: LoadingProps) {
const containerClass = fullScreen
? 'min-h-screen flex items-center justify-center p-4'
: 'flex items-center justify-center p-8'
return (
<div className={containerClass}>
<div className="text-center">
<div className="relative inline-block">
<Loader2 className="w-12 h-12 text-green-600 animate-spin mx-auto mb-4" />
<div className="absolute inset-0 w-12 h-12 border-4 border-green-200 border-t-green-600 rounded-full animate-spin mx-auto opacity-50" />
</div>
<p className="text-gray-600 font-medium">{message}</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect } from 'react';
export default function ServiceWorkerRegistration() {
useEffect(() => {
if (
typeof window !== 'undefined' &&
'serviceWorker' in navigator
) {
const registerServiceWorker = async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('Service Worker registered successfully:', registration.scope);
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('New service worker available');
}
});
}
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
};
// Register immediately if page is already loaded
if (document.readyState === 'complete') {
registerServiceWorker();
} else {
window.addEventListener('load', registerServiceWorker);
}
}
}, []);
return null;
}