This commit is contained in:
2025-10-31 15:05:47 +03:30
parent 37c501890f
commit a96fbfa91c
23 changed files with 5324 additions and 295 deletions

28
Dockerfile Normal file
View 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
View 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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

@@ -0,0 +1 @@
PNG_PLACEHOLDER_192

1
public/icon-512.png Normal file
View File

@@ -0,0 +1 @@
PNG_PLACEHOLDER_512

20
public/manifest.json Normal file
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

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

View File

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

View File

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

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

View File

@@ -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
View 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 } }
}}
/>
)
}

View 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
View 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
View 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 (06) 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}`
}