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

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

42
CI_README.md Normal file
View File

@@ -0,0 +1,42 @@
# Gitea Actions CI/CD (build -> push -> deploy)
This project includes a Gitea Actions workflow at `.gitea/workflows/deploy.yml` which:
- Builds a Docker image and tags it `latest` as `${REGISTRY_HOST}/${REGISTRY_NAMESPACE}/${REGISTRY_REPO}:latest`.
- Pushes the image to your container registry (supports `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` if needed).
- SSHes to the deployment server and writes `docker-compose.yml` into `/home/services/myapp`, then runs `docker-compose up -d`.
Required repository secrets (add in Gitea repo settings -> Secrets):
- DEPLOY_HOST: IP or hostname of the server
- DEPLOY_USER: SSH user
- DEPLOY_KEY: Private SSH key for DEPLOY_USER (no passphrase or use agent)
- REGISTRY_HOST: Registry host (e.g. docker.io or registry.example.com)
- REGISTRY_NAMESPACE: Namespace/org or username
- REGISTRY_REPO: Image/repo name
- (optional) REGISTRY_USERNAME and REGISTRY_PASSWORD for private registries
How to trigger:
- The workflow triggers on push to `main` and can be triggered manually via `workflow_dispatch`.
Manual deploy (example):
```powershell
# Build and push locally
$env:REGISTRY_HOST='registry.example.com'
$env:REGISTRY_NAMESPACE='myuser'
$env:REGISTRY_REPO='greenhomeui'
docker build -t $env:REGISTRY_HOST/$env:REGISTRY_NAMESPACE/$env:REGISTRY_REPO:latest .
docker push $env:REGISTRY_HOST/$env:REGISTRY_NAMESPACE/$env:REGISTRY_REPO:latest
# Copy docker-compose and run on server
scp docker-compose.yml user@yourserver:/home/services/myapp/docker-compose.yml
ssh user@yourserver "cd /home/services/myapp; docker pull $env:REGISTRY_HOST/$env:REGISTRY_NAMESPACE/$env:REGISTRY_REPO:latest; docker-compose up -d --remove-orphans"
```
Manual server helper:
- `scripts/remote-deploy.sh` can be copied to the server and used to pull+run the image. It respects env vars `REGISTRY_HOST`, `REGISTRY_NAMESPACE`, `REGISTRY_REPO` when present.
Notes:
- The workflow uses `appleboy/ssh-action` to SSH into the server. That action needs the private key provided in `DEPLOY_KEY`.
- The workflow writes a `docker-compose.yml` based on the repo's compose config and uses the `latest` tag. If you prefer not to overwrite server-side compose files, modify the workflow to only run `docker pull` and `docker-compose up -d`.

View File

@@ -1,7 +1,4 @@
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 = {
reactStrictMode: true, reactStrictMode: true,
@@ -9,9 +6,4 @@ const nextConfig: NextConfig = {
turbopack: { root: __dirname } turbopack: { root: __dirname }
} }
export default withPWA({ export default nextConfig
dest: 'public',
disable: !isProd,
register: true,
skipWaiting: true
})(nextConfig)

9
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"jalaali-js": "^1.2.8", "jalaali-js": "^1.2.8",
"lucide-react": "^0.553.0",
"next": "15.5.4", "next": "15.5.4",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"react": "19.1.0", "react": "19.1.0",
@@ -7115,6 +7116,14 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/lucide-react": {
"version": "0.553.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.19", "version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"jalaali-js": "^1.2.8", "jalaali-js": "^1.2.8",
"lucide-react": "^0.553.0",
"next": "15.5.4", "next": "15.5.4",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"react": "19.1.0", "react": "19.1.0",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,72 @@
/* Generated by script */
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-Thin.woff2') format('woff2');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-ExtraLight.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-ExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: Vazirmatn;
src: url('Vazirmatn-Black.woff2') format('woff2');
font-weight: 900;
font-style: normal;
font-display: swap;
}

View File

@@ -1,20 +0,0 @@
{
"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"
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,207 @@
"use client"
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import { api, TelemetryDto } from '@/lib/api'
import { Card, DashboardGrid } from '@/components/DashboardCards'
import { LineChart, Panel } from '@/components/Charts'
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
import { BarChart3, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading'
// زمان به‌روزرسانی خودکار (به میلی‌ثانیه) - می‌توانید این مقدار را تغییر دهید
const AUTO_REFRESH_INTERVAL = 10 * 1000 // 10 ثانیه
function useQueryParam(name: string) {
if (typeof window === 'undefined') return null as string | null
return new URLSearchParams(window.location.search).get(name)
}
export default function TelemetryPage() {
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
const deviceId = Number(useQueryParam('deviceId') ?? '1')
const dateParam = useQueryParam('date') ?? `${getCurrentPersianYear()}/${getCurrentPersianMonth()}/${getCurrentPersianDay()}`
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const selectedDate = useMemo(() => {
if (!dateParam) return null
try {
const decodedDate = decodeURIComponent(dateParam)
return decodedDate
} catch (error) {
console.error('Error decoding date parameter:', error)
return null
}
}, [dateParam])
// تابع بارگذاری داده‌ها
const loadData = useCallback(async (showLoading = true) => {
if (!selectedDate) {
setLoading(false)
return
}
if (showLoading) {
setLoading(true)
}
try {
const [year, month, day] = selectedDate.split('/').map(Number)
const startDate = persianToGregorian(year, month, day)
startDate.setHours(0, 0, 0, 0)
const endDate = new Date(startDate)
endDate.setHours(23, 59, 59, 999)
const startUtc = startDate.toISOString()
const endUtc = endDate.toISOString()
const result = await api.listTelemetry({ deviceId, startUtc, endUtc })
setTelemetry(result.items)
setTotal(result.totalCount)
setLastUpdate(new Date())
} catch (error) {
console.error('Error loading telemetry:', error)
} finally {
if (showLoading) {
setLoading(false)
}
}
}, [deviceId, selectedDate])
// بارگذاری اولیه
useEffect(() => {
loadData(true)
}, [loadData])
// تنظیم به‌روزرسانی خودکار
useEffect(() => {
if (!selectedDate) return
// پاک کردن interval قبلی
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
// تنظیم interval جدید
intervalRef.current = setInterval(() => {
loadData(false) // بدون نمایش loading
}, AUTO_REFRESH_INTERVAL)
// Cleanup
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [selectedDate, loadData])
const sortedTelemetry = useMemo(() => {
return [...telemetry].sort((a, b) => new Date(a.timestampUtc).getTime() - new Date(b.timestampUtc).getTime())
}, [telemetry])
// تبدیل timestamp به ساعت (HH:MM:SS)
const labels = useMemo(() => {
return sortedTelemetry.map(t => {
const date = new Date(t.timestampUtc)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
const seconds = date.getSeconds().toString().padStart(2, '0')
return `${hours}:${minutes}:${seconds}`
})
}, [sortedTelemetry])
const soil = useMemo(() => sortedTelemetry.map(t => Number(t.soilPercent ?? 0)), [sortedTelemetry])
const temp = useMemo(() => sortedTelemetry.map(t => Number(t.temperatureC ?? 0)), [sortedTelemetry])
const hum = useMemo(() => sortedTelemetry.map(t => Number(t.humidityPercent ?? 0)), [sortedTelemetry])
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
if (loading) {
return <Loading message="در حال بارگذاری داده‌ها..." />
}
if (!selectedDate) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center">
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
<Link
href={`/calendar?deviceId=${deviceId}`}
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<ChevronRight className="w-4 h-4" />
بازگشت به تقویم
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen p-4 md:p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="mb-6">
<Link
href={`/calendar?deviceId=${deviceId}`}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
>
<ChevronRight className="w-4 h-4" />
بازگشت به تقویم
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl shadow-md">
<BarChart3 className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
جزئیات دادههای {selectedDate}
</h1>
{lastUpdate && (
<p className="text-xs text-gray-500 mt-1">
آخرین بهروزرسانی: {lastUpdate.toLocaleTimeString('fa-IR')}
</p>
)}
</div>
</div>
<button
onClick={() => loadData(true)}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg"
title="به‌روزرسانی دستی"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span className="text-sm">بهروزرسانی</span>
</button>
</div>
</div>
<DashboardGrid>
<Card title="نور (Lux)" icon="💡" value={lux.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...lux)} | حداقل: ${Math.min(0, ...lux)}`} />
<Card title="گاز CO (ppm)" icon="🫁" value={gas.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...gas)} | حداقل: ${Math.min(0, ...gas)}`} />
<Card title="رطوبت خاک (%)" icon="🌱" value={soil.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...soil)} | حداقل: ${Math.min(0, ...soil)}`} />
<Card title="رطوبت (%)" icon="💧" value={hum.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...hum)} | حداقل: ${Math.min(0, ...hum)}`} />
<Card title="دما (°C)" icon="🌡️" value={temp.at(-1) ?? 0} subtitle={`حداکثر: ${Math.max(0, ...temp)} | حداقل: ${Math.min(0, ...temp)}`} />
<Card title="تعداد داده" icon="📊" value={total} subtitle="در روز انتخابی" />
</DashboardGrid>
<div className="grid gap-4 md:grid-cols-2">
<Panel title="رطوبت خاک">
<LineChart labels={labels} series={[{ label: 'رطوبت خاک (%)', data: soil, borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]} />
</Panel>
<Panel title="دما و رطوبت">
<LineChart labels={labels} series={[{ label: 'دما (°C)', data: temp, borderColor: '#ef4444' }, { label: 'رطوبت (%)', data: hum, borderColor: '#3b82f6' }]} />
</Panel>
<Panel title="نور">
<LineChart labels={labels} series={[{ label: 'Lux', data: lux, borderColor: '#a855f7' }]} />
</Panel>
<Panel title="گاز CO">
<LineChart labels={labels} series={[{ label: 'CO (ppm)', data: gas, borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]} />
</Panel>
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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