push
This commit is contained in:
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# ---- مرحله build ----
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- مرحله runtime ----
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# فقط فایلهای لازم
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
RUN npm install --omit=dev --legacy-peer-deps
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
nextapp:
|
||||
build: .
|
||||
container_name: nextapp
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.nextapp.rule=Host(`green.nabaksoft.ir`)"
|
||||
- "traefik.http.routers.nextapp.entrypoints=websecure"
|
||||
- "traefik.http.routers.nextapp.tls=true"
|
||||
- "traefik.http.routers.nextapp.tls.certresolver=myresolver"
|
||||
- "traefik.http.services.nextapp.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- traefik-net
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next'
|
||||
import withPWA from 'next-pwa'
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
reactStrictMode: true,
|
||||
experimental: {},
|
||||
turbopack: { root: __dirname }
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
export default withPWA({
|
||||
dest: 'public',
|
||||
disable: !isProd,
|
||||
register: true,
|
||||
skipWaiting: true
|
||||
})(nextConfig)
|
||||
|
||||
4086
package-lock.json
generated
4086
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -3,25 +3,28 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.0",
|
||||
"next": "15.5.4",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.4"
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.4",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
1
public/icon-192.png
Normal file
1
public/icon-192.png
Normal file
@@ -0,0 +1 @@
|
||||
PNG_PLACEHOLDER_192
|
||||
1
public/icon-512.png
Normal file
1
public/icon-512.png
Normal file
@@ -0,0 +1 @@
|
||||
PNG_PLACEHOLDER_512
|
||||
20
public/manifest.json
Normal file
20
public/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "GreenHome",
|
||||
"short_name": "GreenHome",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#16a34a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
public/sw.js
Normal file
1
public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
public/workbox-4754cb34.js
Normal file
1
public/workbox-4754cb34.js
Normal file
File diff suppressed because one or more lines are too long
68
src/app/calendar/month/page.tsx
Normal file
68
src/app/calendar/month/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { 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 MonthPage() {
|
||||
const deviceId = Number(useQueryParam('deviceId') ?? '1')
|
||||
const year = Number(useQueryParam('year') ?? getCurrentPersianYear().toString())
|
||||
const month = Number(useQueryParam('month') ?? getCurrentPersianMonth().toString())
|
||||
|
||||
const [daysWithCounts, setDaysWithCounts] = useState<{ day: number; count: number }[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.monthDays(deviceId, year, month)
|
||||
.then(list => setDaysWithCounts(list.map(x => ({ day: Number(x.persianDate.split('/')[2]), count: x.count }))))
|
||||
.catch(console.error)
|
||||
}, [deviceId, year, month])
|
||||
|
||||
const maxDay = useMemo(() => {
|
||||
const fromApi = Math.max(0, ...daysWithCounts.map(d => d.day))
|
||||
if (fromApi > 0) return fromApi
|
||||
if (month <= 6) return 31
|
||||
if (month <= 11) return 30
|
||||
return 29
|
||||
}, [daysWithCounts, month])
|
||||
|
||||
const days = useMemo(() => Array.from({ length: maxDay }, (_, i) => i + 1), [maxDay])
|
||||
const countByDay = useMemo(() => new Map(daysWithCounts.map(d => [d.day, d.count])), [daysWithCounts])
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<a className="border px-3 py-1 rounded-md" href={`/calendar?deviceId=${deviceId}`}>ماه قبل ←</a>
|
||||
<div className="text-lg font-semibold">تقویم دادههای گلخانه · {year}/{month}</div>
|
||||
<a className="border px-3 py-1 rounded-md" href={`/calendar?deviceId=${deviceId}`}>→ ماه بعد</a>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-white shadow-sm overflow-hidden">
|
||||
<div className="grid grid-cols-7 text-center bg-gray-100 text-sm">
|
||||
{['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(h => (
|
||||
<div key={h} className="p-2 border-b">{h}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-px bg-gray-200">
|
||||
{days.map(d => {
|
||||
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' : ''}`}>
|
||||
<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>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
96
src/app/calendar/page.tsx
Normal file
96
src/app/calendar/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear } from '@/lib/persian-date'
|
||||
|
||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
const [value, setValue] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
setValue(params.get(name))
|
||||
}
|
||||
}, [name])
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const deviceIdParam = useQueryParam('deviceId')
|
||||
const [deviceId, setDeviceId] = useState<number>(1)
|
||||
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
||||
const [activeMonths, setActiveMonths] = useState<number[]>([])
|
||||
const [monthDays, setMonthDays] = useState<Record<number, { days: number; records: number }>>({})
|
||||
const years = useMemo(() => Array.from({ length: 10 }, (_, i) => getCurrentPersianYear() - 2 + i), [])
|
||||
|
||||
// وقتی deviceIdParam تغییر کرد، deviceId را به روز کن
|
||||
useEffect(() => {
|
||||
if (deviceIdParam) {
|
||||
setDeviceId(Number(deviceIdParam))
|
||||
}
|
||||
}, [deviceIdParam])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
setMonthDays({})
|
||||
setActiveMonths([])
|
||||
|
||||
api.activeMonths(deviceId, year)
|
||||
.then(async (months) => {
|
||||
if (!mounted) return
|
||||
setActiveMonths(months)
|
||||
const entries = await Promise.all(months.map(async m => {
|
||||
const days = await api.monthDays(deviceId, year, m)
|
||||
const records = days.reduce((s, d) => s + d.count, 0)
|
||||
return [m, { days: days.length, records }] as const
|
||||
}))
|
||||
if (!mounted) return
|
||||
setMonthDays(Object.fromEntries(entries))
|
||||
})
|
||||
.catch(console.error)
|
||||
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])
|
||||
|
||||
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>
|
||||
|
||||
<div className="text-center text-sm text-gray-600 mb-5">
|
||||
خلاصه {year}: {totalDays} روز دارای دیتا · {totalRecords} رکورد
|
||||
</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} رکورد
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
src/app/day_details/page.tsx
Normal file
141
src/app/day_details/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
330
src/app/device_settings/page.tsx
Normal file
330
src/app/device_settings/page.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
234
src/app/devices/page.tsx
Normal file
234
src/app/devices/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
import { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { api, DeviceDto } from '@/lib/api'
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [selected, setSelected] = useState<DeviceDto | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const deviceNameRef = useRef<HTMLInputElement>(null)
|
||||
const passwordRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const canSubmit = useMemo(() => deviceName.trim().length > 0 && password.trim().length > 0, [deviceName, password])
|
||||
|
||||
// بررسی auto-fill پس از لود صفحه
|
||||
useEffect(() => {
|
||||
const checkAutoFill = () => {
|
||||
setTimeout(() => {
|
||||
if (deviceNameRef.current) {
|
||||
const filledDeviceName = deviceNameRef.current.value
|
||||
if (filledDeviceName && filledDeviceName !== deviceName) {
|
||||
setDeviceName(filledDeviceName)
|
||||
}
|
||||
}
|
||||
if (passwordRef.current) {
|
||||
const filledPassword = passwordRef.current.value
|
||||
if (filledPassword && filledPassword !== password) {
|
||||
setPassword(filledPassword)
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
checkAutoFill()
|
||||
window.addEventListener('load', checkAutoFill)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('load', checkAutoFill)
|
||||
}
|
||||
}, [deviceName, password])
|
||||
|
||||
// بررسی تغییرات در فیلدها (برای مرورگرهایی که بعد از تعامل auto-fill میکنند)
|
||||
const handleInputChange = (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.name === 'deviceName') {
|
||||
setDeviceName(target.value)
|
||||
} else if (target.name === 'password') {
|
||||
setPassword(target.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// گرفتن آخرین مقادیر از refها در صورت لزوم
|
||||
const currentDeviceName = deviceNameRef.current?.value || deviceName
|
||||
const currentPassword = passwordRef.current?.value || password
|
||||
|
||||
if (!currentDeviceName.trim() || !currentPassword.trim()) {
|
||||
setError('لطفاً نام دستگاه و رمز عبور را وارد کنید')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// فراخوانی API برای بررسی دستگاه
|
||||
const device = await api.CheckDevice(currentDeviceName.trim(), currentPassword.trim())
|
||||
|
||||
if (device) {
|
||||
setSelected(device)
|
||||
} else {
|
||||
setError('دستگاه یافت نشد یا رمز عبور نادرست است')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking device:', error)
|
||||
setError('خطا در ارتباط با سرور')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setSelected(null)
|
||||
setDeviceName('')
|
||||
setPassword('')
|
||||
setError(null)
|
||||
|
||||
// پاک کردن refها
|
||||
if (deviceNameRef.current) deviceNameRef.current.value = ''
|
||||
if (passwordRef.current) passwordRef.current.value = ''
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-6">در حال بررسی دستگاه...</div>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 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">
|
||||
{/* 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>
|
||||
<p className="text-sm text-gray-700">
|
||||
{selected
|
||||
? `دستگاه "${selected.deviceName}" با موفقیت تأیید شد`
|
||||
: 'نام دستگاه و رمز عبور را وارد کنید تا عملیات فعال شود.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* نمایش فرم فقط وقتی دستگاه انتخاب نشده */}
|
||||
{!selected ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 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>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="text-red-600 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>
|
||||
</form>
|
||||
) : (
|
||||
/* نمایش دکمههای عملیات وقتی دستگاه انتخاب شده */
|
||||
<div className="space-y-4">
|
||||
{/* اطلاعات دستگاه فعال */}
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* دکمه تغییر دستگاه */}
|
||||
<div className="flex justify-center pt-4">
|
||||
<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"
|
||||
>
|
||||
<span>🔄</span>
|
||||
تغییر دستگاه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
/* --background: #ffffff; */
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
/* --color-background: var(--background); */
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
@@ -14,13 +14,14 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
/* --background: #0a0a0a; */
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
/* background: var(--background); */
|
||||
background: linear-gradient(180deg, #eef7ff, #f6fbff);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: vazir,Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
title: 'GreenHome',
|
||||
description: 'GreenHome PWA'
|
||||
}
|
||||
|
||||
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" />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
144
src/app/month_select/page.tsx
Normal file
144
src/app/month_select/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
108
src/app/page.tsx
108
src/app/page.tsx
@@ -1,103 +1,13 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
<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>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
57
src/components/Charts.tsx
Normal file
57
src/components/Charts.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
import { Line } from 'react-chartjs-2'
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Legend,
|
||||
Tooltip,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
|
||||
Chart.register(LineElement, CategoryScale, LinearScale, PointElement, Legend, Tooltip, Filler)
|
||||
|
||||
type Series = { label: string; data: number[]; borderColor: string; backgroundColor?: string; fill?: boolean }
|
||||
|
||||
type Props = {
|
||||
labels: string[]
|
||||
series: Series[]
|
||||
title?: string
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 } }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
23
src/components/DashboardCards.tsx
Normal file
23
src/components/DashboardCards.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
import React from 'react'
|
||||
|
||||
type CardProps = {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string,
|
||||
icon?:string
|
||||
}
|
||||
|
||||
export function DashboardGrid({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid gap-4 md:grid-cols-3 xl:grid-cols-6">{children}</div>
|
||||
}
|
||||
|
||||
export function Card({ title, value, subtitle,icon }: CardProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
87
src/lib/api.ts
Normal file
87
src/lib/api.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
export type DeviceDto = {
|
||||
id: number
|
||||
deviceName: string
|
||||
owner: string
|
||||
mobile: string
|
||||
location: string
|
||||
neshanLocation: string
|
||||
}
|
||||
|
||||
export type TelemetryDto = {
|
||||
id: number
|
||||
deviceId: number
|
||||
timestampUtc: string
|
||||
temperatureC: number
|
||||
humidityPercent: number
|
||||
soilPercent: number
|
||||
gasPPM: number
|
||||
lux: number
|
||||
persianYear: number
|
||||
persianMonth: number
|
||||
persianDate: string
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
export type DeviceSettingsDto = {
|
||||
id: number
|
||||
deviceId: number
|
||||
deviceName: string
|
||||
dangerMaxTemperature: number
|
||||
dangerMinTemperature: number
|
||||
maxTemperature: number
|
||||
minTemperature: number
|
||||
maxGasPPM: number
|
||||
minGasPPM: number
|
||||
maxLux: number
|
||||
minLux: number
|
||||
maxHumidityPercent: number
|
||||
minHumidityPercent: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type PagedResult<T> = {
|
||||
items: T[]
|
||||
totalCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5064'
|
||||
|
||||
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
CheckDevice: (deviceName: string, password: string) => http<boolean>(`${API_BASE}/api/devices/CheckDevice?deviceName=${deviceName}&password=${password}`),
|
||||
listDevices: () => http<DeviceDto[]>(`${API_BASE}/api/devices`),
|
||||
createDevice: (dto: DeviceDto) => http<number>(`${API_BASE}/api/devices`, { method: 'POST', body: JSON.stringify(dto) }),
|
||||
listTelemetry: (q: { deviceId?: number; startUtc?: string; endUtc?: string; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q.deviceId) params.set('deviceId', String(q.deviceId))
|
||||
if (q.startUtc) params.set('startUtc', q.startUtc)
|
||||
if (q.endUtc) params.set('endUtc', q.endUtc)
|
||||
if (q.page) params.set('page', String(q.page))
|
||||
if (q.pageSize) params.set('pageSize', String(q.pageSize))
|
||||
return http<PagedResult<TelemetryDto>>(`${API_BASE}/api/telemetry?${params.toString()}`)
|
||||
},
|
||||
createTelemetry: (dto: TelemetryDto) => http<number>(`${API_BASE}/api/telemetry`, { method: 'POST', body: JSON.stringify(dto) }),
|
||||
minmax: (deviceId: number, startUtc?: string, endUtc?: string) => {
|
||||
const params = new URLSearchParams({ deviceId: String(deviceId) })
|
||||
if (startUtc) params.set('startUtc', startUtc)
|
||||
if (endUtc) params.set('endUtc', endUtc)
|
||||
return http<{ [k: string]: number }>(`${API_BASE}/api/telemetry/minmax?${params.toString()}`)
|
||||
},
|
||||
monthDays: (deviceId: number, year: number, month: number) => http<{ persianDate: string; count: number }[]>(`${API_BASE}/api/telemetry/days?deviceId=${deviceId}&year=${year}&month=${month}`),
|
||||
activeMonths: (deviceId: number, year: number) => http<number[]>(`${API_BASE}/api/telemetry/months?deviceId=${deviceId}&year=${year}`),
|
||||
|
||||
// Device Settings
|
||||
getDeviceSettings: (deviceId: number) => http<DeviceSettingsDto>(`${API_BASE}/api/devicesettings/${deviceId}`),
|
||||
createDeviceSettings: (dto: DeviceSettingsDto) => http<number>(`${API_BASE}/api/devicesettings`, { method: 'POST', body: JSON.stringify(dto) }),
|
||||
updateDeviceSettings: (dto: DeviceSettingsDto) => http<void>(`${API_BASE}/api/devicesettings`, { method: 'PUT', body: JSON.stringify(dto) })
|
||||
}
|
||||
72
src/lib/persian-date.ts
Normal file
72
src/lib/persian-date.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Persian (Jalali) date utilities — corrected
|
||||
*/
|
||||
import {
|
||||
toJalaali,
|
||||
toGregorian,
|
||||
isLeapJalaaliYear,
|
||||
jalaaliMonthLength,
|
||||
} from 'jalaali-js'
|
||||
|
||||
export interface PersianDate {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
}
|
||||
|
||||
export function gregorianToPersian(gDate: Date): PersianDate {
|
||||
const { jy, jm, jd } = toJalaali(gDate.getFullYear(), gDate.getMonth() + 1, gDate.getDate())
|
||||
return { year: jy, month: jm, day: jd }
|
||||
}
|
||||
|
||||
export function persianToGregorian(year: number, month: number, day: number): Date {
|
||||
const { gy, gm, gd } = toGregorian(year, month, day)
|
||||
return new Date(gy, gm - 1, gd)
|
||||
}
|
||||
|
||||
export function getCurrentPersianDate(): PersianDate {
|
||||
return gregorianToPersian(new Date())
|
||||
}
|
||||
|
||||
export function getCurrentPersianYear(): number {
|
||||
return getCurrentPersianDate().year
|
||||
}
|
||||
export function getCurrentPersianMonth(): number {
|
||||
return getCurrentPersianDate().month
|
||||
}
|
||||
export function getCurrentPersianDay(): number {
|
||||
return getCurrentPersianDate().day
|
||||
}
|
||||
|
||||
export function isPersianLeapYear(year: number): boolean {
|
||||
return isLeapJalaaliYear(year)
|
||||
}
|
||||
|
||||
export function getPersianMonthDays(year: number, month: number): number {
|
||||
return jalaaliMonthLength(year, month)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekday (0–6) for the first day of a Persian month
|
||||
* Returns: 0 = شنبه, 1 = یکشنبه, ..., 6 = جمعه
|
||||
*/
|
||||
export function getPersianMonthStartWeekday(year: number, month: number): number {
|
||||
const { gy, gm, gd } = toGregorian(year, month, 1)
|
||||
const gDate = new Date(gy, gm - 1, gd)
|
||||
// JS getDay: 0 = Sunday ... 6 = Saturday
|
||||
// We want: 0 = Saturday (شنبه), so map: persianIndex = (jsDay + 1) % 7
|
||||
return (gDate.getDay() + 1) % 7
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's Persian date as formatted string (e.g., "سهشنبه ۱۶ مهر ۱۴۰۴")
|
||||
*/
|
||||
export function getPersianTodayString(): string {
|
||||
const daysOfWeek = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']
|
||||
const months = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
const now = new Date()
|
||||
const persian = gregorianToPersian(now)
|
||||
const persianWeekday = daysOfWeek[(now.getDay() + 1) % 7] // نگاشت درست روز هفته
|
||||
return `${persianWeekday} ${persian.day} ${months[persian.month - 1]} ${persian.year}`
|
||||
}
|
||||
Reference in New Issue
Block a user