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 = {
|
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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"build": "next build --turbopack",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"next": "15.5.4",
|
||||||
|
"next-pwa": "^5.6.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"next": "15.5.4"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.4",
|
"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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
/* --background: #ffffff; */
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
/* --color-background: var(--background); */
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
@@ -14,13 +14,14 @@
|
|||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
/* --background: #0a0a0a; */
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
/* background: var(--background); */
|
||||||
|
background: linear-gradient(180deg, #eef7ff, #f6fbff);
|
||||||
color: var(--foreground);
|
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 type { Metadata } from 'next'
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import './globals.css'
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'GreenHome',
|
||||||
description: "Generated by create next app",
|
description: 'GreenHome PWA'
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
children,
|
return (
|
||||||
}: Readonly<{
|
<html lang="fa" dir="rtl">
|
||||||
children: React.ReactNode;
|
<head>
|
||||||
}>) {
|
<link rel="manifest" href="/manifest.json" />
|
||||||
return (
|
<meta name="theme-color" content="#16a34a" />
|
||||||
<html lang="en">
|
</head>
|
||||||
<body
|
<body>{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
</html>
|
||||||
>
|
)
|
||||||
{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() {
|
export default function Home() {
|
||||||
return (
|
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="p-6">
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
<h1 className="text-2xl mb-4">GreenHome</h1>
|
||||||
<Image
|
<ul className="list-disc pl-5 space-y-2">
|
||||||
className="dark:invert"
|
<li><a className="text-green-700 underline" href="/devices">انتخاب دستگاه</a></li>
|
||||||
src="/next.svg"
|
<li><a className="text-green-700 underline" href="/calendar?deviceId=1">Calendar (انتخاب ماه)</a></li>
|
||||||
alt="Next.js logo"
|
<li><a className="text-green-700 underline" href="/day_details?deviceId=1&year=1403&month=1">Day Details نمونه</a></li>
|
||||||
width={180}
|
<li><a className="text-green-700 underline" href="/month_select?deviceId=1">Month Select/Telemetry</a></li>
|
||||||
height={38}
|
</ul>
|
||||||
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>
|
</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