optimization
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 1s

This commit is contained in:
2025-12-19 23:17:30 +03:30
parent 31b58b7151
commit 618960bb5c
101 changed files with 4812 additions and 2319 deletions

View File

@@ -2,7 +2,24 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
experimental: {}, experimental: {
optimizePackageImports: ['lucide-react', 'chart.js', 'react-chartjs-2'],
},
// Enable compression
compress: true,
// Optimize images
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
// Performance optimizations
swcMinify: true,
poweredByHeader: false,
// Better mobile experience
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
turbopack: { root: __dirname }, turbopack: { root: __dirname },
} }

View File

@@ -1,5 +1,5 @@
const CACHE_NAME = 'greenhome-1766074058129'; const CACHE_NAME = 'greenhome-1766173610406';
const STATIC_CACHE_NAME = 'greenhome-static-1766074058129'; const STATIC_CACHE_NAME = 'greenhome-static-1766173610406';
// Static assets to cache on install // Static assets to cache on install
const STATIC_FILES_TO_CACHE = [ const STATIC_FILES_TO_CACHE = [

View File

@@ -1,3 +1,3 @@
{ {
"version": "1766074058129" "version": "1766173610406"
} }

View File

@@ -31,6 +31,8 @@ import {
LucideIcon LucideIcon
} from 'lucide-react' } from 'lucide-react'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { PageHeader, EmptyState, Modal, IconButton, Badge } from '@/components/common'
import { confirmDialog } from '@/components/utils'
type SensorType = 0 | 1 | 2 | 3 | 4 type SensorType = 0 | 1 | 2 | 3 | 4
type ComparisonType = 0 | 1 | 2 | 3 type ComparisonType = 0 | 1 | 2 | 3
@@ -172,9 +174,14 @@ function AlertSettingsContent() {
} }
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!window.confirm('آیا از حذف این هشدار اطمینان دارید؟')) { const confirmed = await confirmDialog({
return message: 'آیا از حذف این هشدار اطمینان دارید؟',
} variant: 'danger',
confirmText: 'حذف',
cancelText: 'انصراف'
})
if (!confirmed) return
try { try {
await api.deleteAlertCondition(id) await api.deleteAlertCondition(id)
@@ -338,21 +345,12 @@ function AlertSettingsContent() {
<div className="min-h-screen p-4 md:p-6 bg-gray-50"> <div className="min-h-screen p-4 md:p-6 bg-gray-50">
<div className="max-w-7xl mx-auto space-y-6"> <div className="max-w-7xl mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="mb-6"> <PageHeader
<div className="flex items-center justify-between"> icon={Bell}
<div className="flex items-center gap-3"> title="تنظیمات هشدارها"
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-xl shadow-md"> subtitle="مدیریت شرایط و هشدارهای دستگاه"
<Bell className="w-6 h-6 text-white" /> iconGradient="from-orange-500 to-red-600"
</div> action={
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
تنظیمات هشدارها
</h1>
<p className="text-sm text-gray-500 mt-1">
مدیریت شرایط و هشدارهای دستگاه
</p>
</div>
</div>
<button <button
onClick={openCreateModal} onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg" className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
@@ -360,8 +358,8 @@ function AlertSettingsContent() {
<Plus className="w-5 h-5" /> <Plus className="w-5 h-5" />
افزودن هشدار افزودن هشدار
</button> </button>
</div> }
</div> />
{/* Alerts List */} {/* Alerts List */}
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden"> <div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
@@ -372,17 +370,19 @@ function AlertSettingsContent() {
</div> </div>
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<div className="p-12 text-center"> <EmptyState
<AlertTriangle className="w-16 h-16 text-gray-300 mx-auto mb-4" /> icon={AlertTriangle}
<p className="text-gray-500 mb-4">هیچ هشداری ثبت نشده است</p> message="هیچ هشداری ثبت نشده است"
<button action={
onClick={openCreateModal} <button
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors" onClick={openCreateModal}
> className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
<Plus className="w-4 h-4" /> >
افزودن اولین هشدار <Plus className="w-4 h-4" />
</button> افزودن اولین هشدار
</div> </button>
}
/>
) : ( ) : (
<div className="divide-y divide-gray-200"> <div className="divide-y divide-gray-200">
{alerts.map((alert) => ( {alerts.map((alert) => (
@@ -391,28 +391,18 @@ function AlertSettingsContent() {
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium"> <Badge
{NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && ( variant="warning"
<span className="w-4 h-4"> icon={NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon}
{(() => { >
const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon
return <Icon className="w-4 h-4" />
})()}
</span>
)}
{getNotificationLabel(alert.notificationType)} {getNotificationLabel(alert.notificationType)}
</div> </Badge>
<div className="flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"> <Badge
{TIME_TYPES.find(t => t.value === alert.timeType)?.icon && ( variant="info"
<span className="w-4 h-4"> icon={TIME_TYPES.find(t => t.value === alert.timeType)?.icon}
{(() => { >
const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon
return <Icon className="w-4 h-4" />
})()}
</span>
)}
{getTimeTypeLabel(alert.timeType)} {getTimeTypeLabel(alert.timeType)}
</div> </Badge>
<button <button
onClick={() => toggleActive(alert)} onClick={() => toggleActive(alert)}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${ className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
@@ -455,20 +445,18 @@ function AlertSettingsContent() {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <IconButton
icon={Pencil}
variant="primary"
onClick={() => openEditModal(alert)} onClick={() => openEditModal(alert)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="ویرایش" title="ویرایش"
> />
<Pencil className="w-4 h-4" /> <IconButton
</button> icon={Trash2}
<button variant="danger"
onClick={() => handleDelete(alert.id)} onClick={() => handleDelete(alert.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="حذف" title="حذف"
> />
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -479,43 +467,33 @@ function AlertSettingsContent() {
</div> </div>
{/* Modal */} {/* Modal */}
{showModal && ( <Modal
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"> isOpen={showModal}
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto"> onClose={closeModal}
{/* Modal Header */} title={editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10"> size="xl"
<h2 className="text-xl font-bold text-gray-900"> >
{editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
</h2>
<button
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */} <form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-6"> {/* Preview - Sticky at top */}
{/* Preview - Sticky at top */} <div className="sticky top-[73px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg -mx-6 -mt-6">
<div className="sticky top-[73px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg"> <div className="px-6 py-4">
<div className="px-6 py-4"> <div className="flex items-start gap-3">
<div className="flex items-start gap-3"> <div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
<div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm"> <Bell className="w-5 h-5 text-white" />
<Bell className="w-5 h-5 text-white" /> </div>
</div> <div className="flex-1">
<div className="flex-1"> <div className="text-xs font-semibold text-white/90 mb-1.5">📢 پیشنمایش زنده</div>
<div className="text-xs font-semibold text-white/90 mb-1.5">📢 پیشنمایش زنده</div> <div className="text-sm md:text-base text-white leading-relaxed font-medium">
<div className="text-sm md:text-base text-white leading-relaxed font-medium"> {generatePreviewText()}
{generatePreviewText()}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<div className="p-6 space-y-6"> <div className="px-6 space-y-6">
{/* Notification Type */} {/* Notification Type */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-3"> <label className="block text-sm font-medium text-gray-700 mb-3">
نوع اطلاعرسانی نوع اطلاعرسانی
@@ -605,9 +583,6 @@ function AlertSettingsContent() {
<div className="space-y-3"> <div className="space-y-3">
{formData.rules.map((rule, index) => { {formData.rules.map((rule, index) => {
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
const selectedSensor = SENSOR_TYPES.find(s => s.value === rule.sensorType)
const SensorIcon = selectedSensor?.icon
const selectedComp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
return ( return (
<div key={index} className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4 space-y-3 relative shadow-sm"> <div key={index} className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4 space-y-3 relative shadow-sm">
@@ -760,29 +735,27 @@ function AlertSettingsContent() {
</label> </label>
</div> </div>
{/* Modal Footer */} {/* Modal Footer */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200"> <div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 px-6">
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors" className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
disabled={saving} disabled={saving}
> >
انصراف انصراف
</button> </button>
<button <button
type="submit" type="submit"
className="px-6 py-2 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed" className="px-6 py-2 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving} disabled={saving}
> >
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'} {saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
</button> </button>
</div> </div>
</div>
</form>
</div> </div>
</div> </form>
)} </Modal>
</div> </div>
) )
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
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/date/persian-date'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
function useQueryParam(name: string) { function useQueryParam(name: string) {

View File

@@ -2,10 +2,13 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getCurrentPersianYear } from '@/lib/persian-date' import { getCurrentPersianYear } from '@/lib/date/persian-date'
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react' import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { PageHeader, BackLink, Card } from '@/components/common'
import { YearSelector } from '@/components/calendar'
import { MonthCard } from '@/components/cards'
import { StatsCard } from '@/components/cards'
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
@@ -75,54 +78,36 @@ export default function CalendarPage() {
<div className="min-h-screen p-4 md:p-6"> <div className="min-h-screen p-4 md:p-6">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Header */} {/* Header */}
<div className="mb-6"> <BackLink href="/" label="بازگشت به صفحه اصلی" />
<Link <PageHeader
href="/" icon={CalendarIcon}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4" title="انتخاب سال و ماه"
> iconGradient="from-green-500 to-green-600"
<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 */} {/* Main Card */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8"> <Card padding="lg">
{/* Year Selector */} {/* Year Selector */}
<div className="flex flex-wrap items-center justify-center gap-4 mb-6"> <YearSelector
<label className="text-sm font-medium text-gray-700 flex items-center gap-2"> years={years}
<CalendarIcon className="w-4 h-4 text-gray-500" /> selectedYear={year}
انتخاب سال: onYearChange={setYear}
</label> className="mb-6"
<select />
className="px-4 py-2 border-2 border-gray-200 rounded-xl focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all bg-white font-medium"
value={year}
onChange={e => setYear(Number(e.target.value))}
>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{/* Summary Stats */} {/* Summary Stats */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 mb-6"> <div className="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 justify-center gap-6 flex-wrap">
<div className="flex items-center gap-2"> <StatsCard
<Database className="w-5 h-5 text-green-600" /> icon={Database}
<span className="text-sm text-gray-700"> label="روز دارای داده"
<span className="font-semibold text-green-700">{totalDays}</span> روز دارای داده value={totalDays}
</span> />
</div> <StatsCard
<div className="flex items-center gap-2"> icon={TrendingUp}
<TrendingUp className="w-5 h-5 text-green-600" /> label="رکورد"
<span className="text-sm text-gray-700"> value={totalRecords}
<span className="font-semibold text-green-700">{totalRecords}</span> رکورد />
</span>
</div>
</div> </div>
</div> </div>
@@ -133,40 +118,17 @@ export default function CalendarPage() {
const isActive = activeMonths.includes(m) const isActive = activeMonths.includes(m)
const stats = monthDays[m] const stats = monthDays[m]
return ( return (
<button <MonthCard
key={m} key={m}
name={name}
isActive={isActive}
stats={stats}
onClick={() => isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)} onClick={() => isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)}
disabled={!isActive} />
className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${
isActive
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed'
}`}
>
<div className={`text-lg font-semibold mb-2 ${isActive ? 'text-gray-900' : 'text-gray-400'}`}>
{name}
</div>
{isActive && stats ? (
<div className="space-y-1">
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-full px-3 py-1.5 font-medium">
<Database className="w-3 h-3" />
{stats.days} روز
</div>
<div className="text-xs text-gray-600 mt-2">
{stats.records} رکورد
</div>
</div>
) : (
<div className="text-xs text-gray-400">بدون داده</div>
)}
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 rounded-b-xl transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
)}
</button>
) )
})} })}
</div> </div>
</div> </Card>
</div> </div>
</div> </div>
) )

View File

@@ -1,26 +1,24 @@
"use client" "use client"
import { useEffect, useMemo, useState, useCallback, Suspense } from 'react' import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { api, TelemetryDto, DailyReportDto } from '@/lib/api' import { api, TelemetryDto } from '@/lib/api'
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date' import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react' import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
import { TABS, TabType } from '@/features/daily-report'
import { detectDataGaps } from '@/features/daily-report/utils'
import { BarChart3, Bell, CalendarIcon, ChevronRight } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { import { SummaryTab } from '@/components/daily-report'
SummaryTab, import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
ChartsTab, import { Tabs, PageHeader, Button } from '@/components/common'
WeatherTab, import { DateNavigation } from '@/components/navigation'
AnalysisTab, import { usePullToRefresh } from '@/hooks/usePullToRefresh'
TABS,
TabType, // Lazy load heavy components
WeatherData, const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
ensureDateFormat, const WeatherTab = lazy(() => import('@/components/daily-report/WeatherTab').then(m => ({ default: m.WeatherTab })))
formatPersianDate, const AnalysisTab = lazy(() => import('@/components/daily-report/AnalysisTab').then(m => ({ default: m.AnalysisTab })))
QOM_LAT,
QOM_LON,
detectDataGaps,
DataGap
} from '@/components/daily-report'
function DailyReportContent() { function DailyReportContent() {
const router = useRouter() const router = useRouter()
@@ -28,15 +26,8 @@ function DailyReportContent() {
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([]) const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabType>('summary') const [activeTab, setActiveTab] = useState<TabType>('summary')
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null) const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
const [analysisLoading, setAnalysisLoading] = useState(false) const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
const [analysisError, setAnalysisError] = useState<string | null>(null)
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [weatherError, setWeatherError] = useState<string | null>(null)
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00
const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59
const deviceId = Number(searchParams.get('deviceId') ?? '1') const deviceId = Number(searchParams.get('deviceId') ?? '1')
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay()) const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
@@ -103,156 +94,48 @@ function DailyReportContent() {
} }
}, [deviceId, selectedDate]) }, [deviceId, selectedDate])
const loadAnalysis = useCallback(async () => { // Pull-to-refresh for mobile
if (!selectedDate || dailyReport) return usePullToRefresh(loadData)
setAnalysisLoading(true)
setAnalysisError(null)
try {
const report = await api.getDailyReport(deviceId, selectedDate)
setDailyReport(report)
} catch (error) {
console.error('Error loading analysis:', error)
setAnalysisError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
} finally {
setAnalysisLoading(false)
}
}, [deviceId, selectedDate, dailyReport])
const loadWeather = useCallback(async () => {
if (weatherData) return
setWeatherLoading(true)
setWeatherError(null)
try {
if (!selectedDate) {
setWeatherError('تاریخ انتخاب نشده است')
return
}
// تبدیل تاریخ شمسی به میلادی
const [year, month, day] = selectedDate.split('/').map(Number)
const gregorianDate = persianToGregorian(year, month, day)
// بررسی اینکه تاریخ امروز است یا گذشته
const today = new Date()
today.setHours(0, 0, 0, 0)
gregorianDate.setHours(0, 0, 0, 0)
const isPast = gregorianDate.getTime() < today.getTime()
let weather: WeatherData
if (isPast) {
// استفاده از Historical API برای روزهای گذشته
const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD
const response = await fetch(
`https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran`
)
if (!response.ok) {
throw new Error('Failed to fetch historical weather data')
}
const data = await response.json()
// ساختار داده برای روزهای گذشته (بدون current و hourly)
weather = {
current: {
temperature: data.daily.temperature_2m_max?.[0] || 0,
humidity: 0, // Historical API رطوبت ندارد
windSpeed: data.daily.wind_speed_10m_max?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
},
hourly: [], // برای گذشته hourly نداریم
daily: [{
date: data.daily.time?.[0] || dateStr,
tempMax: data.daily.temperature_2m_max?.[0] || 0,
tempMin: data.daily.temperature_2m_min?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
precipitation: data.daily.precipitation_sum?.[0] || 0,
precipitationProbability: 0,
uvIndexMax: 0,
sunshineDuration: data.daily.sunshine_duration?.[0] || 0,
windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0,
}]
}
} else {
// استفاده از Forecast API برای امروز و آینده
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max&timezone=Asia/Tehran&forecast_days=7`
)
if (!response.ok) {
throw new Error('Failed to fetch weather data')
}
const data = await response.json()
// Get only today's hourly data (first 24 hours)
const todayHourly = data.hourly.time.slice(0, 24).map((time: string, i: number) => ({
time,
temperature: data.hourly.temperature_2m[i],
humidity: data.hourly.relative_humidity_2m[i],
weatherCode: data.hourly.weather_code[i],
precipitation: data.hourly.precipitation[i],
}))
weather = {
current: {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
weatherCode: data.current.weather_code,
},
hourly: todayHourly,
daily: data.daily.time.map((date: string, i: number) => ({
date,
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
weatherCode: data.daily.weather_code[i],
precipitation: data.daily.precipitation_sum[i],
precipitationProbability: data.daily.precipitation_probability_max[i],
uvIndexMax: data.daily.uv_index_max[i],
sunshineDuration: data.daily.sunshine_duration[i],
windSpeedMax: data.daily.wind_speed_10m_max[i],
}))
}
}
setWeatherData(weather)
} catch (error) {
console.error('Error loading weather:', error)
setWeatherError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
} finally {
setWeatherLoading(false)
}
}, [weatherData, selectedDate])
useEffect(() => { useEffect(() => {
// Reset states when date or device changes // Reset states when date or device changes
setDailyReport(null)
setWeatherData(null)
setAnalysisError(null)
setWeatherError(null)
loadData() loadData()
}, [loadData, deviceId, selectedDate]) }, [loadData, deviceId, selectedDate])
// Load analysis when switching to analysis tab // Load forecast weather data when selectedDate is today - lazy load after main data is loaded
useEffect(() => { useEffect(() => {
if (activeTab === 'analysis') { if (!selectedDate || loading) {
loadAnalysis() setForecastWeather(null)
setForecastWeatherLoading(false)
return
} }
}, [activeTab, loadAnalysis])
// Load weather when switching to weather tab const isTodayDate = checkIsToday(selectedDate)
useEffect(() => {
if (activeTab === 'weather') { if (isTodayDate) {
loadWeather() // Delay fetch to ensure page is fully loaded first
setForecastWeatherLoading(true)
setForecastWeather(null)
// Use setTimeout to ensure page is fully rendered before fetching
const timer = setTimeout(() => {
fetchForecastWeather()
.then(setForecastWeather)
.catch((error) => {
console.error('Error loading forecast weather:', error)
setForecastWeather(null)
})
.finally(() => {
setForecastWeatherLoading(false)
})
}, 100) // Small delay to ensure page is rendered
return () => clearTimeout(timer)
} else {
setForecastWeather(null)
setForecastWeatherLoading(false)
} }
}, [activeTab, loadWeather]) }, [selectedDate, loading])
const sortedTelemetry = useMemo(() => { const sortedTelemetry = useMemo(() => {
return [...telemetry].sort((a, b) => { return [...telemetry].sort((a, b) => {
@@ -269,141 +152,12 @@ function DailyReportContent() {
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry]) const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry]) const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
// Min/Max calculations (not currently used but kept for potential future use)
// const tempMinMax = useMemo(() => {
// const min = Math.min(...temp)
// const max = Math.max(...temp)
// return {
// min: min < 0 ? Math.floor(min / 10) * 10 : 0,
// max: max > 40 ? Math.floor(max / 10) * 10 : 40
// }
// }, [temp])
// const luxMinMax = useMemo(() => {
// const max = Math.max(...lux)
// return {
// min: 0,
// max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
// }
// }, [lux])
// Detect data gaps in the full day data // Detect data gaps in the full day data
const dataGaps = useMemo(() => { const dataGaps = useMemo(() => {
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc) const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
return detectDataGaps(timestamps, 30) // 30 minutes threshold return detectDataGaps(timestamps, 30) // 30 minutes threshold
}, [sortedTelemetry]) }, [sortedTelemetry])
// Filtered telemetry for charts based on minute range
const filteredTelemetryForCharts = useMemo(() => {
return sortedTelemetry.filter(t => {
const timestamp = t.serverTimestampUtc || t.timestampUtc
const date = new Date(timestamp)
const minuteOfDay = date.getHours() * 60 + date.getMinutes()
return minuteOfDay >= chartStartMinute && minuteOfDay <= chartEndMinute
})
}, [sortedTelemetry, chartStartMinute, chartEndMinute])
// Detect gaps in filtered data
const filteredDataGaps = useMemo(() => {
const timestamps = filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc)
return detectDataGaps(timestamps, 30)
}, [filteredTelemetryForCharts])
// Filtered chart labels
const chartLabels = useMemo(() => {
return filteredTelemetryForCharts.map(t => {
const timestamp = t.serverTimestampUtc || t.timestampUtc
const date = new Date(timestamp)
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}`
})
}, [filteredTelemetryForCharts])
// Helper function to insert nulls for gaps
const insertGapsInData = (data: number[], timestamps: string[], gaps: DataGap[]): (number | null)[] => {
if (gaps.length === 0 || data.length < 2) return data
const result: (number | null)[] = []
for (let i = 0; i < data.length; i++) {
result.push(data[i])
// Check if there's a gap after this point
if (i < data.length - 1) {
const currentTime = new Date(timestamps[i])
const nextTime = new Date(timestamps[i + 1])
const currentMinute = currentTime.getHours() * 60 + currentTime.getMinutes()
const nextMinute = nextTime.getHours() * 60 + nextTime.getMinutes()
// Find if any gap exists between current and next
const hasGap = gaps.some(gap =>
currentMinute <= gap.startMinute && nextMinute >= gap.endMinute
)
if (hasGap) {
result.push(null) // Insert null to break the line
}
}
}
return result
}
// Filtered data arrays for charts (with gaps as null)
const filteredTimestamps = useMemo(() =>
filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc),
[filteredTelemetryForCharts]
)
const chartSoil = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.soilPercent ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartTemp = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.temperatureC ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartHum = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.humidityPercent ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartGas = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.gasPPM ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
const chartLux = useMemo(() => {
const data = filteredTelemetryForCharts.map(t => Number(t.lux ?? 0))
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
// Min/Max calculations for filtered charts (filter out nulls)
const chartTempMinMax = useMemo(() => {
const validTemps = chartTemp.filter((t): t is number => t !== null)
if (validTemps.length === 0) return { min: 0, max: 40 }
const min = Math.min(...validTemps)
const max = Math.max(...validTemps)
return {
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
max: max > 40 ? Math.floor(max / 10) * 10 : 40
}
}, [chartTemp])
const chartLuxMinMax = useMemo(() => {
const validLux = chartLux.filter((l): l is number => l !== null)
if (validLux.length === 0) return { min: 0, max: 2000 }
const max = Math.max(...validLux)
return {
min: 0,
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
}
}, [chartLux])
if (loading) { if (loading) {
return <Loading message="در حال بارگذاری داده‌ها..." /> return <Loading message="در حال بارگذاری داده‌ها..." />
} }
@@ -414,13 +168,13 @@ function DailyReportContent() {
<div className="text-center"> <div className="text-center">
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" /> <CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div> <div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
<button <Button
onClick={goToCalendar} onClick={goToCalendar}
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors" variant="outline"
icon={ChevronRight}
> >
<ChevronRight className="w-4 h-4" />
بازگشت به تقویم بازگشت به تقویم
</button> </Button>
</div> </div>
</div> </div>
) )
@@ -430,21 +184,11 @@ function DailyReportContent() {
<div className="min-h-screen p-4 md:p-6"> <div className="min-h-screen p-4 md:p-6">
<div className="max-w-7xl mx-auto space-y-6"> <div className="max-w-7xl mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="mb-6"> <PageHeader
<div className="flex items-center justify-between"> icon={BarChart3}
<div className="flex items-center gap-3"> title="گزارش روزانه"
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-md"> iconGradient="from-indigo-500 to-purple-600"
<BarChart3 className="w-6 h-6 text-white" /> action={
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
گزارش روزانه {selectedDate}
</h1>
<p className="text-sm text-gray-500 mt-1">
مشاهده خلاصه و نمودارهای روز
</p>
</div>
</div>
<Link <Link
href={`/alert-settings?deviceId=${deviceId}`} href={`/alert-settings?deviceId=${deviceId}`}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md" className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
@@ -452,161 +196,45 @@ function DailyReportContent() {
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span> <span className="hidden sm:inline">تنظیمات هشدار</span>
</Link> </Link>
</div> }
</div> />
{/* Date Navigation Buttons */} {/* Date Navigation Buttons */}
<div className="flex items-center justify-center gap-3 mb-4"> {selectedDate && (
<button <DateNavigation
onClick={goToPreviousDay} selectedDate={selectedDate}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md" onPrevious={goToPreviousDay}
> onNext={goToNextDay}
<ChevronRight className="w-5 h-5" /> onCalendar={goToCalendar}
روز قبل />
</button> )}
<button
onClick={goToCalendar}
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
>
<CalendarIcon className="w-5 h-5" />
انتخاب تاریخ
</button>
<button
onClick={goToNextDay}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
>
روز بعد
<ChevronLeft className="w-5 h-5" />
</button>
</div>
{/* Tabs */} {/* Tabs */}
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden"> <Tabs
{/* Segmented Control for Mobile */} tabs={TABS}
<div className="p-3 md:p-6 md:pb-0"> activeTab={activeTab}
<div className="bg-gray-100 rounded-xl p-1 flex md:hidden"> setActiveTab={setActiveTab}
{TABS.map(tab => ( className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
<button >
key={tab.value} {{
onClick={() => setActiveTab(tab.value)} summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} />,
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-lg transition-all duration-200 ${ charts: (
activeTab === tab.value <Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
? 'bg-white text-indigo-600 shadow-sm' <ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />
: 'text-gray-600 hover:text-gray-900' </Suspense>
}`} ),
> weather: selectedDate ? (
{tab.label} <Suspense fallback={<Loading message="در حال بارگذاری اطلاعات آب و هوا..." />}>
</button> <WeatherTab selectedDate={selectedDate} />
))} </Suspense>
</div> ) : null,
analysis: selectedDate ? (
{/* Desktop Tabs */} <Suspense fallback={<Loading message="در حال بارگذاری تحلیل..." />}>
<div className="hidden md:flex border-b border-gray-200 -mx-6 -mt-6 mb-6"> <AnalysisTab deviceId={deviceId} selectedDate={selectedDate} />
{TABS.map(tab => ( </Suspense>
<button ) : null,
key={tab.value} }}
onClick={() => setActiveTab(tab.value)} </Tabs>
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
activeTab === tab.value
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-4 md:p-6 md:pt-0">
{/* Summary Tab */}
{activeTab === 'summary' && (
<SummaryTab
temperature={{
current: temp.at(-1) ?? 0,
min: Math.min(...temp),
max: Math.max(...temp),
data: temp
}}
humidity={{
current: hum.at(-1) ?? 0,
min: Math.min(...hum),
max: Math.max(...hum),
data: hum
}}
soil={{
current: soil.at(-1) ?? 0,
min: Math.min(...soil),
max: Math.max(...soil),
data: soil
}}
gas={{
current: gas.at(-1) ?? 0,
min: Math.min(...gas),
max: Math.max(...gas),
data: gas
}}
lux={{
current: lux.at(-1) ?? 0,
min: Math.min(...lux),
max: Math.max(...lux),
data: lux
}}
/>
)}
{/* Charts Tab */}
{activeTab === 'charts' && (
<ChartsTab
chartStartMinute={chartStartMinute}
chartEndMinute={chartEndMinute}
onStartMinuteChange={setChartStartMinute}
onEndMinuteChange={setChartEndMinute}
labels={chartLabels}
soil={chartSoil}
humidity={chartHum}
temperature={chartTemp}
lux={chartLux}
gas={chartGas}
tempMinMax={chartTempMinMax}
luxMinMax={chartLuxMinMax}
totalRecords={filteredTelemetryForCharts.length}
dataGaps={dataGaps}
/>
)}
{/* Weather Tab */}
{activeTab === 'weather' && (
<WeatherTab
loading={weatherLoading}
error={weatherError}
weatherData={weatherData}
onRetry={() => {
setWeatherData(null)
setWeatherError(null)
loadWeather()
}}
expandedDayIndex={expandedDayIndex}
onDayToggle={setExpandedDayIndex}
selectedDate={selectedDate}
/>
)}
{/* Analysis Tab */}
{activeTab === 'analysis' && (
<AnalysisTab
loading={analysisLoading}
error={analysisError}
dailyReport={dailyReport}
onRetry={() => {
setDailyReport(null)
setAnalysisError(null)
loadAnalysis()
}}
/>
)}
</div>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -2,10 +2,12 @@
import { useEffect, useState, useMemo, Suspense } from 'react' import { useEffect, useState, useMemo, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date' import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react' import { Calendar as CalendarIcon, Database } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { PageHeader, BackLink, Card } from '@/components/common'
import { WeekdayHeaders } from '@/components/calendar'
import { CalendarDayCell, StatsCard } from '@/components/cards'
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'] const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
@@ -72,34 +74,17 @@ function DayDetailsContent() {
<div className="min-h-screen p-4 md:p-6"> <div className="min-h-screen p-4 md:p-6">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Header */} {/* Header */}
<div className="mb-6"> <BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
<Link <PageHeader
href={`/calendar?deviceId=${deviceId}`} icon={CalendarIcon}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4" title={`${monthNames[month - 1]} ${year}`}
> iconGradient="from-green-500 to-green-600"
<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 */} {/* Calendar Grid */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden"> <Card className="overflow-hidden" padding="none">
{/* Weekday Headers */} {/* 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"> <WeekdayHeaders />
{['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
<div key={day} className="p-3 md:p-4 text-sm font-semibold text-gray-700">
{day}
</div>
))}
</div>
{/* Days Grid */} {/* Days Grid */}
<div className="grid grid-cols-7 gap-1 p-1 bg-gray-50"> <div className="grid grid-cols-7 gap-1 p-1 bg-gray-50">
@@ -123,41 +108,34 @@ function DayDetailsContent() {
const dateStr = `${year}/${monthStr}/${dayStr}` const dateStr = `${year}/${monthStr}/${dayStr}`
return ( return (
<button <CalendarDayCell
key={day} key={day}
day={day}
hasData={true}
recordCount={recordCount}
onClick={() => router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)} onClick={() => router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)}
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>
</button>
) )
} else { } else {
return ( return (
<div <CalendarDayCell
key={day} 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" day={day}
> hasData={false}
<div className="text-sm md:text-base">{day}</div> />
</div>
) )
} }
})} })}
</div> </div>
</div> </Card>
{/* Summary */} {/* 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="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"> <StatsCard
<Database className="w-4 h-4 text-green-600" /> icon={Database}
<span> label={`روز دارای داده از ${totalDays} روز ماه`}
<span className="font-semibold text-green-700">{items.length}</span> روز دارای داده از{' '} value={items.length}
<span className="font-semibold text-green-700">{totalDays}</span> روز ماه />
</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,11 @@
"use client" "use client"
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { api, DeviceSettingsDto } from '@/lib/api' import { api, DeviceSettingsDto } from '@/lib/api'
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react' import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { PageHeader, BackLink, ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common'
import { SettingsSection, SettingsInputGroup } from '@/components/settings'
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
@@ -93,19 +95,13 @@ export default function DeviceSettingsPage() {
if (!deviceId) { if (!deviceId) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <ErrorMessage
<div className="text-center"> message="شناسه دستگاه مشخص نشده است"
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> fullPage
<div className="text-lg text-red-600 mb-4">شناسه دستگاه مشخص نشده است</div> action={
<Link <BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
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>
) )
} }
@@ -113,23 +109,12 @@ export default function DeviceSettingsPage() {
<div className="min-h-screen p-4 md:p-6"> <div className="min-h-screen p-4 md:p-6">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{/* Header */} {/* Header */}
<div className="mb-6"> <BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
<Link <PageHeader
href={`/devices`} icon={Settings}
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4" title={`تنظیمات ${deviceName}`}
> iconGradient="from-blue-500 to-blue-600"
<ChevronRight className="w-4 h-4" /> action={
بازگشت به انتخاب دستگاه
</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-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>
<Link <Link
href={`/alert-settings?deviceId=${deviceId}`} href={`/alert-settings?deviceId=${deviceId}`}
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md" className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
@@ -137,188 +122,107 @@ export default function DeviceSettingsPage() {
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
<span className="hidden sm:inline">تنظیمات هشدار</span> <span className="hidden sm:inline">تنظیمات هشدار</span>
</Link> </Link>
</div> }
</div> />
{/* Messages */} {/* Messages */}
{error && ( {error && <ErrorMessage message={error} className="mb-6" />}
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 flex items-center gap-2"> {success && <SuccessMessage message={success} className="mb-6" />}
<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 ? ( {!settings ? (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 text-center"> <EmptyState
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" /> icon={AlertCircle}
<div className="text-lg text-gray-600 mb-6"> title="تنظیمات وجود ندارد"
تنظیمات برای این دستگاه وجود ندارد message="تنظیمات برای این دستگاه وجود ندارد"
</div> action={
<button <button
onClick={initializeDefaultSettings} 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" 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> </button>
</div> }
/>
) : ( ) : (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8"> <Card padding="lg">
<div className="grid gap-8 md:grid-cols-2"> <div className="grid gap-8 md:grid-cols-2">
{/* Temperature Settings */} {/* Temperature Settings */}
<div className="space-y-5"> <SettingsSection
<div className="flex items-center gap-3 pb-3 border-b border-gray-200"> icon={Thermometer}
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-orange-500 rounded-lg flex items-center justify-center"> title="تنظیمات دما"
<Thermometer className="w-5 h-5 text-white" /> iconGradient="from-red-500 to-orange-500"
</div> >
<h3 className="text-lg font-semibold text-gray-900"> <SettingsInputGroup
تنظیمات دما label="محدوده دما"
</h3> minLabel="حداقل دما"
</div> maxLabel="حداکثر دما"
minValue={settings.minTemperature}
<div> maxValue={settings.maxTemperature}
<label className="block text-sm font-medium text-gray-700 mb-2"> onMinChange={(value) => handleInputChange('minTemperature', value)}
حداکثر دما (°C) onMaxChange={(value) => handleInputChange('maxTemperature', value)}
</label> minUnit="°C"
<input maxUnit="°C"
type="number" />
step="0.1" </SettingsSection>
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 */} {/* Gas Settings */}
<div className="space-y-5"> <SettingsSection
<div className="flex items-center gap-3 pb-3 border-b border-gray-200"> icon={Wind}
<div className="w-10 h-10 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg flex items-center justify-center"> title="تنظیمات گاز"
<Wind className="w-5 h-5 text-white" /> iconGradient="from-gray-600 to-gray-700"
</div> >
<h3 className="text-lg font-semibold text-gray-900"> <SettingsInputGroup
تنظیمات گاز label="محدوده گاز CO"
</h3> minLabel="حداقل گاز"
</div> maxLabel="حداکثر گاز"
minValue={settings.minGasPPM}
<div> maxValue={settings.maxGasPPM}
<label className="block text-sm font-medium text-gray-700 mb-2"> onMinChange={(value) => handleInputChange('minGasPPM', value)}
حداکثر گاز CO (ppm) onMaxChange={(value) => handleInputChange('maxGasPPM', value)}
</label> minUnit="ppm"
<input maxUnit="ppm"
type="number" minStep={1}
value={settings.maxGasPPM} maxStep={1}
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" </SettingsSection>
/>
</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 */} {/* Light Settings */}
<div className="space-y-5"> <SettingsSection
<div className="flex items-center gap-3 pb-3 border-b border-gray-200"> icon={Sun}
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-lg flex items-center justify-center"> title="تنظیمات نور"
<Sun className="w-5 h-5 text-white" /> iconGradient="from-yellow-500 to-orange-500"
</div> >
<h3 className="text-lg font-semibold text-gray-900"> <SettingsInputGroup
تنظیمات نور label="محدوده نور"
</h3> minLabel="حداقل نور"
</div> maxLabel="حداکثر نور"
minValue={settings.minLux}
<div> maxValue={settings.maxLux}
<label className="block text-sm font-medium text-gray-700 mb-2"> onMinChange={(value) => handleInputChange('minLux', value)}
حداکثر نور (Lux) onMaxChange={(value) => handleInputChange('maxLux', value)}
</label> minUnit="Lux"
<input maxUnit="Lux"
type="number" />
step="0.1" </SettingsSection>
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 */} {/* Humidity Settings */}
<div className="space-y-5"> <SettingsSection
<div className="flex items-center gap-3 pb-3 border-b border-gray-200"> icon={Droplets}
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-lg flex items-center justify-center"> title="تنظیمات رطوبت هوا"
<Droplets className="w-5 h-5 text-white" /> iconGradient="from-blue-500 to-cyan-500"
</div> >
<h3 className="text-lg font-semibold text-gray-900"> <SettingsInputGroup
تنظیمات رطوبت هوا label="محدوده رطوبت"
</h3> minLabel="حداقل رطوبت"
</div> maxLabel="حداکثر رطوبت"
minValue={settings.minHumidityPercent}
<div> maxValue={settings.maxHumidityPercent}
<label className="block text-sm font-medium text-gray-700 mb-2"> onMinChange={(value) => handleInputChange('minHumidityPercent', value)}
حداکثر رطوبت (%) onMaxChange={(value) => handleInputChange('maxHumidityPercent', value)}
</label> minUnit="%"
<input maxUnit="%"
type="number" />
step="0.1" </SettingsSection>
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> </div>
{/* Save Button */} {/* Save Button */}
@@ -345,7 +249,7 @@ export default function DeviceSettingsPage() {
)} )}
</button> </button>
</div> </div>
</div> </Card>
)} )}
</div> </div>
</div> </div>

View File

@@ -2,10 +2,13 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { api, DeviceDto, PagedResult } from '@/lib/api' import { api, DeviceDto, PagedResult } from '@/lib/api'
import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react' import { Settings, LogOut } from 'lucide-react'
import Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date' import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
import { PageHeader, ErrorMessage, EmptyState, BackLink } from '@/components/common'
import { SearchInput } from '@/components/forms'
import { DeviceCard } from '@/components/cards'
import { Pagination } from '@/components/navigation'
export default function DevicesPage() { export default function DevicesPage() {
const router = useRouter() const router = useRouter()
@@ -75,9 +78,10 @@ export default function DevicesPage() {
} }
}, [user, currentPage, searchTerm, fetchDevices]) }, [user, currentPage, searchTerm, fetchDevices])
const handleSearch = (e: React.FormEvent) => { const handleSearch = (searchValue?: string) => {
e.preventDefault() const valueToUse = searchValue ?? searchInput
setSearchTerm(searchInput) setSearchTerm(valueToUse)
setSearchInput(valueToUse)
setCurrentPage(1) setCurrentPage(1)
} }
@@ -96,24 +100,19 @@ export default function DevicesPage() {
if (error && (!pagedResult || pagedResult.items.length === 0)) { if (error && (!pagedResult || pagedResult.items.length === 0)) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <ErrorMessage
<div className="w-full max-w-md"> message={error}
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100 text-center"> fullPage
<div className="text-red-600 mb-4"> action={
<Settings className="w-16 h-16 mx-auto" /> <button
</div> onClick={handleLogout}
<h2 className="text-xl font-bold text-gray-900 mb-2">خطا</h2> className="w-full py-3 rounded-xl bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-all duration-200 flex items-center justify-center gap-2"
<p className="text-gray-600 mb-6">{error}</p> >
<button <LogOut className="w-5 h-5" />
onClick={handleLogout} خروج
className="w-full py-3 rounded-xl bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-all duration-200 flex items-center justify-center gap-2" </button>
> }
<LogOut className="w-5 h-5" /> />
خروج
</button>
</div>
</div>
</div>
) )
} }
@@ -121,47 +120,30 @@ export default function DevicesPage() {
<div className="min-h-screen p-4 md:p-8"> <div className="min-h-screen p-4 md:p-8">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Header */} {/* Header */}
<div className="mb-6 flex items-center justify-between flex-wrap gap-4"> <PageHeader
<div> icon={Settings}
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2"> title="انتخاب دستگاه"
انتخاب دستگاه subtitle={user ? `${user.name} ${user.family} (${user.mobile})` : undefined}
</h1> action={
{user && ( <button
<p className="text-sm text-gray-600"> onClick={handleLogout}
{user.name} {user.family} ({user.mobile}) className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm"
</p> >
)} <LogOut className="w-4 h-4" />
</div> خروج
<button </button>
onClick={handleLogout} }
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm" iconGradient="from-green-500 to-green-600"
> />
<LogOut className="w-4 h-4" />
خروج
</button>
</div>
{/* Search */} {/* Search */}
<div className="mb-6"> <div className="mb-6">
<form onSubmit={handleSearch} className="flex gap-2"> <SearchInput
<div className="flex-1 relative"> value={searchInput}
<Search className="absolute right-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" /> onValueChange={setSearchInput}
<input onSubmit={handleSearch}
type="text" placeholder="جستجو در نام دستگاه، نام صاحب، نام خانوادگی صاحب، موقعیت..."
value={searchInput} />
onChange={(e) => setSearchInput(e.target.value)}
placeholder="جستجو در نام دستگاه، نام صاحب، نام خانوادگی صاحب، موقعیت..."
className="w-full pr-12 pl-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"
/>
</div>
<button
type="submit"
className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg flex items-center gap-2"
>
<Search className="w-5 h-5" />
جستجو
</button>
</form>
{searchTerm && ( {searchTerm && (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
@@ -195,113 +177,36 @@ export default function DevicesPage() {
{pagedResult.items.map((device) => { {pagedResult.items.map((device) => {
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}` const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
return ( return (
<Link <DeviceCard
key={device.id} key={device.id}
device={device}
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`} href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative" />
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
<Settings 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">
{device.deviceName}
</h3>
<p className="text-sm text-gray-500 mb-2">
{device.location || 'بدون موقعیت'}
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{device.userName} {device.userFamily}</span>
</div>
</div>
<div className="flex-shrink-0">
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
</Link>
) )
})} })}
</div> </div>
{/* Pagination */} {/* Pagination */}
{showPagination && ( {showPagination && (
<div className="flex items-center justify-center gap-2 mt-6"> <Pagination
<button currentPage={currentPage}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))} totalPages={totalPages}
disabled={currentPage === 1} onPageChange={setCurrentPage}
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2 ${ className="mt-6"
currentPage === 1 />
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
}`}
>
<ChevronRight className="w-4 h-4" />
قبلی
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (currentPage <= 3) {
pageNum = i + 1
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = currentPage - 2 + i
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 rounded-xl transition-all duration-200 ${
currentPage === pageNum
? 'bg-gradient-to-r from-green-500 to-green-600 text-white shadow-md'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
}`}
>
{pageNum}
</button>
)
})}
</div>
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
}`}
>
بعدی
<ChevronLeft className="w-4 h-4" />
</button>
</div>
)} )}
</> </>
) : ( ) : (
!loading && ( !loading && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-8 text-center"> <EmptyState
<p className="text-gray-600">هیچ دستگاهی یافت نشد</p> message="هیچ دستگاهی یافت نشد"
</div> />
) )
)} )}
{/* Back to Home */} {/* Back to Home */}
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<Link <BackLink href="/" label="بازگشت به صفحه اصلی" />
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> </div>

View File

@@ -21,6 +21,13 @@
* { * {
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
} }
body { body {
@@ -29,6 +36,27 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
font-weight: 400; font-weight: 400;
overscroll-behavior-y: contain; /* Prevent pull-to-refresh on Android */
-webkit-overflow-scrolling: touch;
}
/* Better touch targets (minimum 44x44px for mobile) */
button, a, [role="button"] {
min-height: 44px;
min-width: 44px;
touch-action: manipulation; /* Disable double-tap zoom */
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* Hardware acceleration for animations */
.transition-all,
.transition-colors,
.transition-transform,
.transition-opacity {
will-change: transform, opacity;
transform: translateZ(0); /* Force GPU acceleration */
} }
.persian-number { .persian-number {
@@ -107,4 +135,25 @@ body {
border: none; border: none;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
margin: 1.5em 0; margin: 1.5em 0;
}
/* Loading skeleton animation */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 0%,
#e0e0e0 50%,
#f0f0f0 100%
);
background-size: 2000px 100%;
animation: shimmer 2s infinite;
} }

View File

@@ -16,8 +16,13 @@ export const metadata: Metadata = {
export const viewport = { export const viewport = {
width: 'device-width', width: 'device-width',
initialScale: 1, initialScale: 1,
maximumScale: 1, maximumScale: 5, // Allow zoom for accessibility
userScalable: false userScalable: true, // Better for accessibility
viewportFit: 'cover', // For notch support
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#16a34a' },
{ media: '(prefers-color-scheme: dark)', color: '#16a34a' },
],
} }
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {

View File

@@ -1,44 +1,17 @@
"use client" "use client"
import { useState, useRef, useEffect } from 'react' import { useState } from 'react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Smartphone, ArrowLeft } from 'lucide-react' import { Smartphone, ArrowLeft } from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { MobileInput } from '@/components/forms'
import { ErrorMessage } from '@/components/common'
export default function LoginPage() { export default function LoginPage() {
const [mobile, setMobile] = useState('') const [mobile, setMobile] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const router = useRouter() const router = useRouter()
const mobileRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const checkAutoFill = () => {
setTimeout(() => {
if (mobileRef.current) {
const filledMobile = mobileRef.current.value
if (filledMobile && filledMobile !== mobile) {
setMobile(filledMobile)
}
}
}, 100)
}
checkAutoFill()
window.addEventListener('load', checkAutoFill)
return () => {
window.removeEventListener('load', checkAutoFill)
}
}, [mobile])
const handleInputChange = (e: React.FormEvent<HTMLInputElement>) => {
const value = e.currentTarget.value
// Only allow digits
const digitsOnly = value.replace(/\D/g, '')
setMobile(digitsOnly)
setError(null)
}
const normalizeMobile = (mobile: string): string => { const normalizeMobile = (mobile: string): string => {
const digitsOnly = mobile.replace(/\D/g, '') const digitsOnly = mobile.replace(/\D/g, '')
@@ -111,24 +84,17 @@ export default function LoginPage() {
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
شماره موبایل شماره موبایل
</label> </label>
<input <MobileInput
ref={mobileRef}
type="tel"
inputMode="numeric"
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 text-left"
placeholder="09123456789"
value={mobile} value={mobile}
onInput={handleInputChange} onValueChange={(value) => {
setMobile(value)
setError(null)
}}
disabled={loading} disabled={loading}
maxLength={11}
/> />
</div> </div>
{error && ( {error && <ErrorMessage message={error} />}
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<button <button
type="submit" type="submit"

View File

@@ -1,16 +1,17 @@
"use client" "use client"
import { useEffect, useState, useRef } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Smartphone, ArrowLeft } from 'lucide-react' import { Smartphone, ArrowLeft } from 'lucide-react'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { MobileInput } from '@/components/forms'
import { ErrorMessage } from '@/components/common'
export default function Home() { export default function Home() {
const router = useRouter() const router = useRouter()
const [mobile, setMobile] = useState('') const [mobile, setMobile] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const mobileRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
// Check if user is logged in // Check if user is logged in
@@ -28,34 +29,6 @@ export default function Home() {
} }
}, [router]) }, [router])
useEffect(() => {
const checkAutoFill = () => {
setTimeout(() => {
if (mobileRef.current) {
const filledMobile = mobileRef.current.value
if (filledMobile && filledMobile !== mobile) {
setMobile(filledMobile)
}
}
}, 100)
}
checkAutoFill()
window.addEventListener('load', checkAutoFill)
return () => {
window.removeEventListener('load', checkAutoFill)
}
}, [mobile])
const handleInputChange = (e: React.FormEvent<HTMLInputElement>) => {
const value = e.currentTarget.value
// Only allow digits
const digitsOnly = value.replace(/\D/g, '')
setMobile(digitsOnly)
setError(null)
}
const normalizeMobile = (mobile: string): string => { const normalizeMobile = (mobile: string): string => {
const digitsOnly = mobile.replace(/\D/g, '') const digitsOnly = mobile.replace(/\D/g, '')
if (digitsOnly.startsWith('9') && digitsOnly.length === 10) { if (digitsOnly.startsWith('9') && digitsOnly.length === 10) {
@@ -127,24 +100,17 @@ export default function Home() {
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
شماره موبایل شماره موبایل
</label> </label>
<input <MobileInput
ref={mobileRef}
type="tel"
inputMode="numeric"
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 text-left"
placeholder="09123456789"
value={mobile} value={mobile}
onInput={handleInputChange} onValueChange={(value) => {
setMobile(value)
setError(null)
}}
disabled={loading} disabled={loading}
maxLength={11}
/> />
</div> </div>
{error && ( {error && <ErrorMessage message={error} />}
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<button <button
type="submit" type="submit"

View File

@@ -43,18 +43,41 @@ self.addEventListener('fetch', (event: FetchEvent) => {
if (event.request.mode === 'navigate') { if (event.request.mode === 'navigate') {
event.respondWith( event.respondWith(
(async () => { (async () => {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request);
// Return cached version immediately (fast)
if (cachedResponse) {
// Update cache in background (stale-while-revalidate)
fetch(event.request)
.then((response) => {
if (response.ok) {
cache.put(event.request, response.clone());
}
})
.catch(() => {
// Ignore network errors in background update
});
return cachedResponse;
}
// If no cache, fetch from network
try { try {
const preloadResponse = await event.preloadResponse; const preloadResponse = await event.preloadResponse;
if (preloadResponse) { if (preloadResponse) {
cache.put(event.request, preloadResponse.clone());
return preloadResponse; return preloadResponse;
} }
return await fetch(event.request); const networkResponse = await fetch(event.request);
if (networkResponse.ok) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch { } catch {
const cache = await caches.open(CACHE_NAME); return await cache.match('/') || new Response('Offline', {
return await cache.match('/') || new Response('', { status: 503,
status: 404, statusText: 'Service Unavailable',
statusText: 'Not Found',
}); });
} }
})() })()
@@ -62,15 +85,28 @@ self.addEventListener('fetch', (event: FetchEvent) => {
return; return;
} }
// Stale-while-revalidate for other requests
event.respondWith( event.respondWith(
(async () => { (async () => {
const cache = await caches.open(CACHE_NAME); const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(event.request); const cachedResponse = await cache.match(event.request);
// Return cached version immediately if available
if (cachedResponse) { if (cachedResponse) {
// Update cache in background
fetch(event.request)
.then((response) => {
if (response.ok && response.type === 'basic') {
cache.put(event.request, response.clone());
}
})
.catch(() => {
// Ignore network errors in background update
});
return cachedResponse; return cachedResponse;
} }
// If no cache, fetch from network
try { try {
const response = await fetch(event.request); const response = await fetch(event.request);
@@ -85,9 +121,9 @@ self.addEventListener('fetch', (event: FetchEvent) => {
return response; return response;
} catch { } catch {
return new Response('', { return new Response('Resource not available offline', {
status: 404, status: 503,
statusText: 'Not Found', statusText: 'Service Unavailable',
}); });
} }
})() })()

View File

@@ -1,10 +1,13 @@
"use client" "use client"
import { useState, useEffect, useRef, useCallback, Suspense } from 'react' import { useState, useEffect, useCallback, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Shield, ArrowRight, ArrowLeft, RotateCcw } from 'lucide-react' import { Shield, ArrowRight, ArrowLeft } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import Loading from '@/components/Loading' import Loading from '@/components/Loading'
import { CodeInput } from '@/components/forms'
import { ErrorMessage } from '@/components/common'
import { ResendButton } from '@/components/utils'
function VerifyCodeContent() { function VerifyCodeContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
@@ -16,7 +19,6 @@ function VerifyCodeContent() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [resendCooldown, setResendCooldown] = useState(120) const [resendCooldown, setResendCooldown] = useState(120)
const [canResend, setCanResend] = useState(false) const [canResend, setCanResend] = useState(false)
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
const checkResendStatus = useCallback(async () => { const checkResendStatus = useCallback(async () => {
try { try {
@@ -54,41 +56,6 @@ function VerifyCodeContent() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [mobile, router, checkResendStatus]) }, [mobile, router, checkResendStatus])
const handleCodeChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return
const newCode = [...code]
newCode[index] = value.slice(-1)
setCode(newCode)
setError(null)
// Auto-focus next input
if (value && index < 3) {
inputRefs.current[index + 1]?.focus()
}
// Auto-submit when all fields are filled
if (newCode.every(c => c !== '') && newCode.join('').length === 4) {
handleVerify(newCode.join(''))
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
}
}
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault()
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 4)
if (pastedData.length === 4) {
const newCode = pastedData.split('')
setCode(newCode)
inputRefs.current[3]?.focus()
handleVerify(pastedData)
}
}
const handleVerify = async (codeToVerify?: string) => { const handleVerify = async (codeToVerify?: string) => {
const codeValue = codeToVerify || code.join('') const codeValue = codeToVerify || code.join('')
@@ -137,15 +104,12 @@ function VerifyCodeContent() {
} }
} else { } else {
setError(result.message || 'کد وارد شده نادرست است') setError(result.message || 'کد وارد شده نادرست است')
// Clear code inputs
setCode(['', '', '', '']) setCode(['', '', '', ''])
inputRefs.current[0]?.focus()
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error('Error verifying code:', error) console.error('Error verifying code:', error)
setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور') setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور')
setCode(['', '', '', '']) setCode(['', '', '', ''])
inputRefs.current[0]?.focus()
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -164,9 +128,7 @@ function VerifyCodeContent() {
if (result.success) { if (result.success) {
setResendCooldown(result.resendAfterSeconds || 120) setResendCooldown(result.resendAfterSeconds || 120)
// Clear code inputs
setCode(['', '', '', '']) setCode(['', '', '', ''])
inputRefs.current[0]?.focus()
} else { } else {
setError(result.message || 'خطا در ارسال مجدد کد') setError(result.message || 'خطا در ارسال مجدد کد')
setCanResend(true) setCanResend(true)
@@ -215,29 +177,14 @@ function VerifyCodeContent() {
</div> </div>
<form onSubmit={(e) => { e.preventDefault(); handleVerify(); }} className="space-y-5"> <form onSubmit={(e) => { e.preventDefault(); handleVerify(); }} className="space-y-5">
<div className="flex justify-center gap-3" style={{ direction: 'ltr' }}> <CodeInput
{code.map((digit, index) => ( value={code}
<input onChange={setCode}
key={index} onComplete={handleVerify}
ref={(el) => { inputRefs.current[index] = el }} disabled={loading}
type="text" />
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={index === 0 ? handlePaste : undefined}
className="w-14 h-14 text-center text-2xl font-bold 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"
disabled={loading}
/>
))}
</div>
{error && ( {error && <ErrorMessage message={error} />}
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<button <button
type="submit" type="submit"
@@ -262,23 +209,12 @@ function VerifyCodeContent() {
</button> </button>
<div className="pt-4 border-t border-gray-200 space-y-3"> <div className="pt-4 border-t border-gray-200 space-y-3">
<button <ResendButton
type="button" canResend={canResend}
onClick={handleResend} cooldown={resendCooldown}
disabled={!canResend || loading} onResend={handleResend}
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium transition-all duration-200 ${ loading={loading}
canResend && !loading />
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md hover:shadow-lg'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
<RotateCcw className="w-4 h-4" />
{canResend ? (
'ارسال مجدد کد'
) : (
`ارسال مجدد (${Math.floor(resendCooldown / 60)}:${String(resendCooldown % 60).padStart(2, '0')})`
)}
</button>
<Link <Link
href="/login" href="/login"

View File

@@ -150,8 +150,8 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
backgroundColor: s.backgroundColor ?? s.borderColor, backgroundColor: s.backgroundColor ?? s.borderColor,
fill: s.fill ?? false, fill: s.fill ?? false,
tension: 0.3, tension: 0.3,
pointRadius: 1.5, pointRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 2 : 1.5, // Smaller points on mobile
pointHoverRadius: 4, pointHoverRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 3 : 4,
borderWidth: 2, borderWidth: 2,
spanGaps: false // Don't connect points across null values (gaps) spanGaps: false // Don't connect points across null values (gaps)
})) }))
@@ -159,6 +159,13 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
options={{ options={{
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
animation: {
duration: typeof window !== 'undefined' && window.innerWidth < 768 ? 300 : 1000, // Faster animations on mobile
},
interaction: {
intersect: false,
mode: 'index' as const,
},
layout: { layout: {
padding: { padding: {
left: 0, left: 0,

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
type LoadingProps = { type LoadingProps = {
message?: string message?: string
@@ -25,3 +26,38 @@ export default function Loading({ message = 'در حال بارگذاری...', f
) )
} }
// Skeleton loading component
type SkeletonProps = {
className?: string
width?: string | number
height?: string | number
rounded?: boolean
}
export function Skeleton({ className, width, height, rounded = true }: SkeletonProps) {
return (
<div
className={cn(
'skeleton',
rounded && 'rounded',
className
)}
style={{
width: width || '100%',
height: height || '1rem',
}}
/>
)
}
// Card skeleton for loading states
export function CardSkeleton() {
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-md p-6 space-y-4">
<Skeleton height="1.5rem" width="60%" />
<Skeleton height="1rem" />
<Skeleton height="1rem" width="80%" />
</div>
)
}

100
src/components/README.md Normal file
View File

@@ -0,0 +1,100 @@
# ساختار کامپوننت‌ها
این پوشه شامل تمام کامپوننت‌های قابل استفاده مجدد اپلیکیشن است که به صورت استاندارد سازماندهی شده‌اند.
## ساختار پوشه‌بندی
```
components/
├── common/ # کامپوننت‌های عمومی و پایه
├── forms/ # کامپوننت‌های فرم و ورودی
├── navigation/ # کامپوننت‌های ناوبری
├── cards/ # کامپوننت‌های کارت و نمایش داده
├── alerts/ # کامپوننت‌های هشدار
├── settings/ # کامپوننت‌های تنظیمات
├── calendar/ # کامپوننت‌های تقویم
├── utils/ # کامپوننت‌های کمکی
└── daily-report/ # کامپوننت‌های مخصوص گزارش روزانه
```
## کامپوننت‌های Common
کامپوننت‌های پایه که در همه جا استفاده می‌شوند:
- **PageHeader**: هدر صفحات با icon و title
- **ErrorMessage**: نمایش پیام خطا
- **SuccessMessage**: نمایش پیام موفقیت
- **EmptyState**: حالت خالی
- **Card**: wrapper برای کارت‌ها
- **Badge**: نشان/برچسب
- **IconButton**: دکمه با icon
- **Modal**: مودال/دیالوگ
- **BackLink**: لینک بازگشت
- **Button**: دکمه (موجود)
- **Dialog**: دیالوگ (موجود)
- **Tabs**: تب‌ها (موجود)
## کامپوننت‌های Forms
کامپوننت‌های ورودی و فرم:
- **MobileInput**: ورودی شماره موبایل با normalization
- **CodeInput**: ورودی کد 4 رقمی با auto-focus
- **SearchInput**: ورودی جستجو با icon
- **FormInput**: wrapper برای input با label و error
## کامپوننت‌های Navigation
کامپوننت‌های ناوبری:
- **DateNavigation**: ناوبری تاریخ (قبل/بعد/تقویم)
- **Pagination**: صفحه‌بندی
## کامپوننت‌های Cards
کامپوننت‌های نمایش داده:
- **DeviceCard**: کارت دستگاه
- **MonthCard**: کارت ماه در تقویم
- **CalendarDayCell**: سلول روز در تقویم
- **StatsCard**: کارت آمار
## کامپوننت‌های Alerts
کامپوننت‌های هشدار:
- **WeatherAlertBanner**: بنر هشدار آب و هوا
- **AlertBadge**: نشان هشدار
## کامپوننت‌های Settings
کامپوننت‌های تنظیمات:
- **SettingsInputGroup**: گروه input برای min/max
- **SettingsSection**: بخش تنظیمات با icon
## کامپوننت‌های Calendar
کامپوننت‌های تقویم:
- **YearSelector**: انتخاب سال
- **WeekdayHeaders**: هدر روزهای هفته
## کامپوننت‌های Utils
کامپوننت‌های کمکی:
- **ResendButton**: دکمه ارسال مجدد با countdown
- **ConfirmDialog**: hook و function برای dialog تأیید
## نحوه استفاده
```typescript
// Import از index اصلی
import { PageHeader, ErrorMessage, DeviceCard } from '@/components'
// یا import مستقیم
import { PageHeader } from '@/components/common'
import { MobileInput } from '@/components/forms'
```

View File

@@ -0,0 +1,17 @@
import { LucideIcon } from 'lucide-react'
import { Badge } from '@/components/common/Badge'
type AlertBadgeProps = {
icon: LucideIcon
label: string
variant?: 'default' | 'success' | 'warning' | 'error' | 'info'
}
export function AlertBadge({ icon: Icon, label, variant = 'default' }: AlertBadgeProps) {
return (
<Badge variant={variant} icon={Icon}>
{label}
</Badge>
)
}

View File

@@ -0,0 +1,39 @@
import { AlertTriangle, ChevronLeft } from 'lucide-react'
import { toPersianDigits } from '@/lib/format/persian-digits'
type WeatherAlertBannerProps = {
alertsCount: number
onClick: () => void
className?: string
}
export function WeatherAlertBanner({
alertsCount,
onClick,
className
}: WeatherAlertBannerProps) {
return (
<div className={className || 'mb-6'}>
<button
onClick={onClick}
className="w-full p-4 bg-gradient-to-r from-amber-50 to-orange-50 border-2 border-amber-200 hover:border-amber-400 rounded-xl transition-all duration-200 hover:shadow-md text-right flex items-center justify-between group"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
<AlertTriangle className="w-6 h-6 text-white" />
</div>
<div>
<p className="font-semibold text-gray-800">
شما {toPersianDigits(alertsCount)} هشدار آب و هوایی برای روزهای آینده دارید
</p>
<p className="text-sm text-gray-600 mt-1">
برای مشاهده جزئیات و توصیههای مدیریت گلخانه کلیک کنید
</p>
</div>
</div>
<ChevronLeft className="w-5 h-5 text-amber-600 group-hover:translate-x-[-4px] transition-transform" />
</button>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { WeatherAlertBanner } from './WeatherAlertBanner'
export { AlertBadge } from './AlertBadge'

View File

@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
type WeekdayHeadersProps = {
weekdays?: string[]
className?: string
}
const defaultWeekdays = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']
export function WeekdayHeaders({
weekdays = defaultWeekdays,
className
}: WeekdayHeadersProps) {
return (
<div className={cn(
'grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200',
className
)}>
{weekdays.map((day) => (
<div key={day} className="p-3 md:p-4 text-sm font-semibold text-gray-700">
{day}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Calendar as CalendarIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
type YearSelectorProps = {
years: number[]
selectedYear: number
onYearChange: (year: number) => void
className?: string
}
export function YearSelector({
years,
selectedYear,
onYearChange,
className
}: YearSelectorProps) {
return (
<div className={cn('flex flex-wrap items-center justify-center gap-4', className)}>
<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={selectedYear}
onChange={(e) => onYearChange(Number(e.target.value))}
>
{years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { YearSelector } from './YearSelector'
export { WeekdayHeaders } from './WeekdayHeaders'

View File

@@ -0,0 +1,48 @@
import { Database } from 'lucide-react'
import { cn } from '@/lib/utils'
type CalendarDayCellProps = {
day: number
hasData: boolean
recordCount?: number
onClick?: () => void
className?: string
}
export function CalendarDayCell({
day,
hasData,
recordCount = 0,
onClick,
className
}: CalendarDayCellProps) {
if (hasData) {
return (
<button
onClick={onClick}
className={cn(
'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',
className
)}
>
<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>
</button>
)
}
return (
<div
className={cn(
'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',
className
)}
>
<div className="text-sm md:text-base">{day}</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import Link from 'next/link'
import { Settings, Calendar } from 'lucide-react'
import { DeviceDto } from '@/lib/api'
type DeviceCardProps = {
device: DeviceDto
href: string
className?: string
}
export function DeviceCard({ device, href, className }: DeviceCardProps) {
return (
<Link
href={href}
className={`group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative ${className || ''}`}
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
<Settings 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">
{device.deviceName}
</h3>
<p className="text-sm text-gray-500 mb-2">
{device.location || 'بدون موقعیت'}
</p>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{device.userName} {device.userFamily}</span>
</div>
</div>
<div className="flex-shrink-0">
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
</Link>
)
}

View File

@@ -0,0 +1,53 @@
import { Database } from 'lucide-react'
import { cn } from '@/lib/utils'
type MonthCardProps = {
name: string
isActive: boolean
stats?: { days: number; records: number }
onClick?: () => void
className?: string
}
export function MonthCard({
name,
isActive,
stats,
onClick,
className
}: MonthCardProps) {
return (
<button
onClick={onClick}
disabled={!isActive}
className={cn(
'group relative rounded-xl border-2 p-5 text-center transition-all duration-300',
isActive
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed',
className
)}
>
<div className={cn('text-lg font-semibold mb-2', isActive ? 'text-gray-900' : 'text-gray-400')}>
{name}
</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>
)}
{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" />
)}
</button>
)
}

View File

@@ -0,0 +1,32 @@
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toPersianDigits } from '@/lib/format/persian-digits'
type StatsCardProps = {
icon: LucideIcon
label: string
value: number | string
unit?: string
className?: string
iconColor?: string
}
export function StatsCard({
icon: Icon,
label,
value,
unit,
className,
iconColor = 'text-green-600'
}: StatsCardProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
<Icon className={cn('w-5 h-5', iconColor)} />
<span className="text-sm text-gray-700">
<span className="font-semibold text-green-700">{toPersianDigits(value.toString())}</span>
{unit && ` ${unit}`} {label}
</span>
</div>
)
}

View File

@@ -0,0 +1,5 @@
export { DeviceCard } from './DeviceCard'
export { MonthCard } from './MonthCard'
export { CalendarDayCell } from './CalendarDayCell'
export { StatsCard } from './StatsCard'

View File

@@ -0,0 +1,29 @@
import Link from 'next/link'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
type BackLinkProps = {
href: string
label?: string
className?: string
}
export function BackLink({
href,
label = 'بازگشت',
className
}: BackLinkProps) {
return (
<Link
href={href}
className={cn(
'inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4',
className
)}
>
<ChevronRight className="w-4 h-4" />
{label}
</Link>
)
}

View File

@@ -0,0 +1,47 @@
import { ReactNode } from 'react'
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info'
type BadgeProps = {
children: ReactNode
variant?: BadgeVariant
icon?: LucideIcon
className?: string
size?: 'sm' | 'md'
}
const variantStyles: Record<BadgeVariant, string> = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-orange-100 text-orange-800',
error: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
}
const sizeStyles = {
sm: 'text-xs px-2 py-1',
md: 'text-sm px-3 py-1.5',
}
export function Badge({
children,
variant = 'default',
icon: Icon,
className,
size = 'md'
}: BadgeProps) {
return (
<span className={cn(
'inline-flex items-center gap-1 rounded-full font-medium',
variantStyles[variant],
sizeStyles[size],
className
)}>
{Icon && <Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />}
{children}
</span>
)
}

View File

@@ -0,0 +1,114 @@
import { ButtonHTMLAttributes } from 'react'
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
type ButtonVariant = 'default' | 'primary' | 'secondary' | 'outline' | 'ghost'
type ButtonSize = 'sm' | 'md' | 'lg'
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant
size?: ButtonSize
icon?: LucideIcon
iconPosition?: 'left' | 'right'
responsiveText?: {
mobile: string
desktop: string
}
tooltip?: string
}
const variantStyles: Record<ButtonVariant, string> = {
default: 'bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600',
primary: 'bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg',
secondary: 'bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800',
outline: 'border border-gray-300 hover:bg-gray-50 text-gray-700',
ghost: 'hover:bg-gray-100 text-gray-700',
}
const sizeStyles: Record<ButtonSize, { padding: string; text: string; icon: string }> = {
sm: {
padding: 'px-2 py-1.5',
text: 'text-xs',
icon: 'w-3 h-3',
},
md: {
padding: 'px-2 sm:px-4 py-2.5',
text: 'text-xs sm:text-sm',
icon: 'w-4 h-4 sm:w-5 sm:h-5',
},
lg: {
padding: 'px-4 sm:px-6 py-3',
text: 'text-sm sm:text-base',
icon: 'w-5 h-5 sm:w-6 sm:h-6',
},
}
export function Button({
variant = 'default',
size = 'md',
icon: Icon,
iconPosition = 'left',
responsiveText,
tooltip,
children,
className,
...props
}: ButtonProps) {
const sizeStyle = sizeStyles[size]
const variantStyle = variantStyles[variant]
const buttonClasses = cn(
'inline-flex items-center justify-center gap-1 sm:gap-2',
'rounded-xl transition-all duration-200 font-medium',
'whitespace-nowrap',
'shadow-sm hover:shadow-md active:shadow-sm',
'active:scale-[0.98]', // Native-like press effect
'touch-manipulation', // Better touch response
'select-none', // Prevent text selection
sizeStyle.padding,
sizeStyle.text,
variantStyle,
className
)
const iconClasses = cn(sizeStyle.icon, 'flex-shrink-0')
const buttonContent = (
<>
{Icon && iconPosition === 'left' && <Icon className={iconClasses} />}
{responsiveText ? (
<>
<span className="hidden sm:inline">{responsiveText.desktop}</span>
<span className="sm:hidden">{responsiveText.mobile}</span>
</>
) : (
<span>{children}</span>
)}
{Icon && iconPosition === 'right' && <Icon className={iconClasses} />}
</>
)
if (tooltip) {
return (
<button
className={cn(buttonClasses, 'group relative')}
title={tooltip}
{...props}
>
{buttonContent}
<span className="absolute -bottom-6 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity text-[10px] text-gray-500 whitespace-nowrap pointer-events-none hidden sm:block">
{tooltip}
</span>
</button>
)
}
return (
<button className={buttonClasses} {...props}>
{buttonContent}
</button>
)
}

View File

@@ -0,0 +1,35 @@
import { ReactNode } from 'react'
import { cn } from '@/lib/utils'
type CardProps = {
children: ReactNode
className?: string
hover?: boolean
padding?: 'sm' | 'md' | 'lg' | 'none'
}
export function Card({
children,
className,
hover = false,
padding = 'md'
}: CardProps) {
const paddingClasses = {
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
none: 'p-0'
}
return (
<div className={cn(
'bg-white rounded-2xl shadow-md border border-gray-100',
paddingClasses[padding],
hover && 'hover:shadow-xl transition-all duration-300',
className
)}>
{children}
</div>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useEffect } from 'react'
import { X } from 'lucide-react'
type DialogProps = {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
// Close on Escape key
useEffect(() => {
if (!isOpen) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, onClose])
// Prevent body scroll when dialog is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
aria-label="بستن"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { ReactNode } from 'react'
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
type EmptyStateProps = {
icon?: LucideIcon
title?: string
message: string
action?: ReactNode
className?: string
}
export function EmptyState({
icon: Icon,
title,
message,
action,
className
}: EmptyStateProps) {
return (
<div className={cn(
'bg-white rounded-2xl shadow-md border border-gray-100 p-8 md:p-12 text-center',
className
)}>
{Icon && (
<div className="text-gray-300 mb-4">
<Icon className="w-16 h-16 mx-auto" />
</div>
)}
{title && (
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
)}
<p className="text-gray-600 mb-6">{message}</p>
{action && (
<div className="flex justify-center">
{action}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { AlertCircle, X } from 'lucide-react'
import { cn } from '@/lib/utils'
type ErrorMessageProps = {
message: string
onClose?: () => void
className?: string
fullPage?: boolean
action?: React.ReactNode
}
export function ErrorMessage({
message,
onClose,
className,
fullPage = false,
action
}: ErrorMessageProps) {
if (fullPage) {
return (
<div className={cn('min-h-screen flex items-center justify-center p-4', className)}>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100 text-center">
<div className="text-red-600 mb-4">
<AlertCircle className="w-16 h-16 mx-auto" />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">خطا</h2>
<p className="text-gray-600 mb-6">{message}</p>
{action}
</div>
</div>
</div>
)
}
return (
<div className={cn(
'p-4 bg-red-50 border border-red-200 rounded-xl',
className
)}>
<div className="flex items-center gap-2 text-red-700 mb-3">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="flex-1">{message}</span>
{onClose && (
<button
onClick={onClose}
className="flex-shrink-0 p-1 hover:bg-red-100 rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{action && (
<div className="mt-3">
{action}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { ButtonHTMLAttributes } from 'react'
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
icon: LucideIcon
variant?: 'default' | 'primary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
const variantStyles = {
default: 'text-gray-600 hover:bg-gray-100',
primary: 'text-blue-600 hover:bg-blue-50',
danger: 'text-red-600 hover:bg-red-50',
ghost: 'text-gray-400 hover:text-gray-600 hover:bg-gray-100',
}
const sizeStyles = {
sm: 'p-1.5',
md: 'p-2',
lg: 'p-2.5',
}
const iconSizes = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5',
}
export function IconButton({
icon: Icon,
variant = 'default',
size = 'md',
className,
...props
}: IconButtonProps) {
return (
<button
className={cn(
'rounded-lg transition-colors',
variantStyles[variant],
sizeStyles[size],
className
)}
{...props}
>
<Icon className={iconSizes[size]} />
</button>
)
}

View File

@@ -0,0 +1,78 @@
import { ReactNode, useEffect } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
type ModalProps = {
isOpen: boolean
onClose: () => void
title?: string
children: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
className?: string
showCloseButton?: boolean
}
const sizeStyles = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-6xl',
full: 'max-w-full',
}
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
className,
showCloseButton = true
}: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen) return null
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className={cn(
'bg-white rounded-2xl shadow-2xl w-full max-h-[90vh] overflow-y-auto',
sizeStyles[size],
className
)}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
{showCloseButton && (
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
<div className={cn(title ? 'p-6' : 'p-6', className)}>
{children}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { ReactNode } from 'react'
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
type PageHeaderProps = {
icon: LucideIcon
title: string
subtitle?: string
action?: ReactNode
iconGradient?: string
className?: string
}
export function PageHeader({
icon: Icon,
title,
subtitle,
action,
iconGradient = 'from-indigo-500 to-purple-600',
className
}: PageHeaderProps) {
return (
<div className={cn('mb-6', className)}>
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className={cn(
'inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br rounded-xl shadow-md',
iconGradient
)}>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
{title}
</h1>
{subtitle && (
<p className="text-sm text-gray-500 mt-1">
{subtitle}
</p>
)}
</div>
</div>
{action && (
<div className="flex-shrink-0">
{action}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
type SegmentTabProps<T extends string> = {
tabs: { value: T; label: string }[]
activeTab: T
className?: string
setActiveTab: React.Dispatch<React.SetStateAction<T>>
}
export function SegmentTab<T extends string>({ tabs, activeTab, className, setActiveTab }: SegmentTabProps<T>) {
return (
<div className={`bg-gray-100 rounded-xl p-1 flex ${className}`}>
{tabs.map(tab => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-lg transition-all duration-200 ${
activeTab === tab.value
? 'bg-white text-indigo-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{tab.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { CheckCircle2, X } from 'lucide-react'
import { cn } from '@/lib/utils'
type SuccessMessageProps = {
message: string
onClose?: () => void
className?: string
}
export function SuccessMessage({
message,
onClose,
className
}: SuccessMessageProps) {
return (
<div className={cn(
'p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2',
className
)}>
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
<span className="flex-1">{message}</span>
{onClose && (
<button
onClick={onClose}
className="flex-shrink-0 p-1 hover:bg-green-100 rounded transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { ReactNode, Dispatch, SetStateAction } from 'react'
import { SegmentTab } from './SegmentTab'
type TabConfig<T extends string> = {
value: T
label: string
}
type TabsProps<T extends string> = {
tabs: TabConfig<T>[]
activeTab: T
setActiveTab: Dispatch<SetStateAction<T>>
children: Record<T, ReactNode | null>
className?: string
}
export function Tabs<T extends string>({
tabs,
activeTab,
setActiveTab,
children,
className
}: TabsProps<T>) {
return (
<div className={`bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden ${className || ''}`}>
{/* Segmented Control for Mobile */}
<div className="p-3 md:p-6 md:pb-0">
<SegmentTab
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
className="md:hidden"
/>
{/* Desktop Tabs */}
<div className="hidden md:flex border-b border-gray-200 -mx-6 -mt-6 mb-6">
{tabs.map(tab => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
activeTab === tab.value
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab Content */}
{children[activeTab] !== null && children[activeTab] !== undefined && (
<div className="p-4 md:p-6 md:pt-0">
{children[activeTab]}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,14 @@
export { Button } from './Button'
export { Dialog } from './Dialog'
export { Tabs } from './Tabs'
export { SegmentTab } from './SegmentTab'
export { PageHeader } from './PageHeader'
export { ErrorMessage } from './ErrorMessage'
export { SuccessMessage } from './SuccessMessage'
export { EmptyState } from './EmptyState'
export { Card } from './Card'
export { Badge } from './Badge'
export { IconButton } from './IconButton'
export { Modal } from './Modal'
export { BackLink } from './BackLink'

View File

@@ -1,48 +1,82 @@
import { Loader2, AlertCircle, RefreshCw } from 'lucide-react' import { useState, useEffect, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import { DailyReportDto } from '@/lib/api' import { DailyReportDto, api } from '@/lib/api'
import { toPersianDigits } from './utils' import { toPersianDigits } from '@/lib/format/persian-digits'
import Loading from '@/components/Loading'
import { ErrorMessage, EmptyState } from '@/components/common'
import { Button } from '@/components/common/Button'
import { FileText } from 'lucide-react'
type AnalysisTabProps = { type AnalysisTabProps = {
loading: boolean deviceId: number
error: string | null selectedDate: string
dailyReport: DailyReportDto | null
onRetry: () => void
} }
export function AnalysisTab({ loading, error, dailyReport, onRetry }: AnalysisTabProps) { export function AnalysisTab({ deviceId, selectedDate }: AnalysisTabProps) {
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadAnalysis = useCallback(async () => {
// اگر قبلاً لود شده، دوباره لود نکن
if (dailyReport) return
setLoading(true)
setError(null)
try {
const report = await api.getDailyReport(deviceId, selectedDate)
setDailyReport(report)
} catch (error) {
console.error('Error loading analysis:', error)
setError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
} finally {
setLoading(false)
}
}, [deviceId, selectedDate, dailyReport])
// Reset analysis data when selectedDate changes
useEffect(() => {
setDailyReport(null)
setError(null)
}, [selectedDate])
// Load analysis when component mounts (user clicked on tab)
useEffect(() => {
loadAnalysis()
}, [loadAnalysis])
if (loading) { if (loading) {
return ( return <Loading message="در حال دریافت تحلیل..." fullScreen={false} />
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Loader2 className="w-12 h-12 text-indigo-500 animate-spin mb-4" />
<p className="text-gray-600">در حال دریافت تحلیل...</p>
</div>
)
} }
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 text-gray-500"> <ErrorMessage
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4"> message={error}
<AlertCircle className="w-8 h-8 text-red-500" /> action={
</div> <Button
<p className="text-gray-700 mb-4">{error}</p> onClick={() => {
<button setDailyReport(null)
onClick={onRetry} loadAnalysis()
className="flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg transition-colors" }}
> variant="primary"
<RefreshCw className="w-4 h-4" /> icon={RefreshCw}
تلاش مجدد >
</button> تلاش مجدد
</div> </Button>
}
/>
) )
} }
if (!dailyReport) { if (!dailyReport) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 text-gray-500"> <EmptyState
<p className="text-gray-600">تحلیلی برای نمایش وجود ندارد</p> icon={FileText}
</div> title="تحلیلی موجود نیست"
message="تحلیلی برای نمایش وجود ندارد"
/>
) )
} }

View File

@@ -1,104 +1,106 @@
import { useState, useMemo, memo, useCallback } from 'react'
import { BarChart3 } from 'lucide-react' import { BarChart3 } from 'lucide-react'
import { LineChart, Panel } from '@/components/Charts' import { LineChart, Panel } from '@/components/Charts'
import { TimeRangeSelector } from './TimeRangeSelector' import { TimeRangeSelector } from './TimeRangeSelector'
import { DataGap } from './utils' import { DataGap, detectDataGaps, normalizeTelemetryData } from '@/features/daily-report/utils'
import { TelemetryDto } from '@/lib/api'
import { EmptyState } from '@/components/common'
import { useTelemetryCharts } from '@/features/daily-report/hooks/useTelemetryCharts'
type ChartsTabProps = { type ChartsTabProps = {
chartStartMinute: number sortedTelemetry: TelemetryDto[]
chartEndMinute: number
onStartMinuteChange: (minute: number) => void
onEndMinuteChange: (minute: number) => void
labels: string[]
soil: (number | null)[]
humidity: (number | null)[]
temperature: (number | null)[]
lux: (number | null)[]
gas: (number | null)[]
tempMinMax: { min: number; max: number }
luxMinMax: { min: number; max: number }
totalRecords: number
dataGaps?: DataGap[] dataGaps?: DataGap[]
} }
export function ChartsTab({ export const ChartsTab = memo(function ChartsTab({
chartStartMinute, sortedTelemetry,
chartEndMinute, dataGaps = [],
onStartMinuteChange,
onEndMinuteChange,
labels,
soil,
humidity,
temperature,
lux,
gas,
tempMinMax,
luxMinMax,
totalRecords,
dataGaps = []
}: ChartsTabProps) { }: ChartsTabProps) {
const [chartStartMinute, setChartStartMinute] = useState(0)
const [chartEndMinute, setChartEndMinute] = useState(1439)
const handleStartMinuteChange = useCallback((minute: number) => {
setChartStartMinute(minute)
}, [])
const handleEndMinuteChange = useCallback((minute: number) => {
setChartEndMinute(minute)
}, [])
// Normalize telemetry data
const normalizedTelemetry = useMemo(
() => normalizeTelemetryData(sortedTelemetry),
[sortedTelemetry]
)
// Filter by time range
const filteredTelemetry = useMemo(() => {
return normalizedTelemetry.filter(
t => t.minute >= chartStartMinute && t.minute <= chartEndMinute
)
}, [normalizedTelemetry, chartStartMinute, chartEndMinute])
// Detect data gaps in filtered data
const timestamps = useMemo(
() => filteredTelemetry.map(t => t.timestamp),
[filteredTelemetry]
)
const filteredDataGaps = useMemo(
() => detectDataGaps(timestamps, 30),
[timestamps]
)
// Build charts using custom hook
const { charts, chartLabels } = useTelemetryCharts({
filteredTelemetry,
filteredDataGaps,
})
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Time Range Selector */}
<TimeRangeSelector <TimeRangeSelector
startMinute={chartStartMinute} startMinute={chartStartMinute}
endMinute={chartEndMinute} endMinute={chartEndMinute}
onStartMinuteChange={onStartMinuteChange} onStartMinuteChange={handleStartMinuteChange}
onEndMinuteChange={onEndMinuteChange} onEndMinuteChange={handleEndMinuteChange}
totalRecords={totalRecords} totalRecords={filteredTelemetry.length}
dataGaps={dataGaps} dataGaps={dataGaps}
/> />
{/* Charts Grid */} {filteredTelemetry.length === 0 ? (
{totalRecords === 0 ? ( <EmptyState
<div className="flex flex-col items-center justify-center py-16 text-gray-500"> icon={BarChart3}
<BarChart3 className="w-12 h-12 text-gray-300 mb-4" /> title="داده‌ای موجود نیست"
<p className="text-gray-600">دادهای برای این بازه زمانی موجود نیست</p> message="داده‌ای برای این بازه زمانی موجود نیست"
</div> />
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<Panel title="رطوبت خاک"> {charts.map(chart => (
<LineChart <Panel key={chart.key} title={chart.title}>
labels={labels} <LineChart
series={[{ label: 'رطوبت خاک (%)', data: soil as (number | null)[], borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]} labels={chartLabels}
yAxisMin={0} series={[
yAxisMax={100} {
/> label: chart.seriesLabel,
</Panel> data: chart.data,
<Panel title="رطوبت"> borderColor: chart.color,
<LineChart backgroundColor: chart.bgColor,
labels={labels} fill: true,
series={[{ label: 'رطوبت (%)', data: humidity as (number | null)[], borderColor: '#3b82f6', backgroundColor: '#dbeafe', fill: true }]} },
yAxisMin={0} ]}
yAxisMax={100} yAxisMin={chart.yAxisMin}
/> yAxisMax={chart.yAxisMax}
</Panel> />
<Panel title="دما"> </Panel>
<LineChart ))}
labels={labels}
series={[{ label: 'دما (°C)', data: temperature as (number | null)[], borderColor: '#ef4444', backgroundColor: '#fee2e2', fill: true }]}
yAxisMin={tempMinMax.min}
yAxisMax={tempMinMax.max}
/>
</Panel>
<Panel title="نور">
<LineChart
labels={labels}
series={[{ label: 'Lux', data: lux as (number | null)[], borderColor: '#a855f7', backgroundColor: '#f3e8ff', fill: true }]}
yAxisMin={luxMinMax.min}
yAxisMax={luxMinMax.max}
/>
</Panel>
<Panel title="گاز CO">
<LineChart
labels={labels}
series={[{ label: 'CO (ppm)', data: gas as (number | null)[], borderColor: '#f59e0b', backgroundColor: '#fef3c7', fill: true }]}
yAxisMin={0}
yAxisMax={100}
/>
</Panel>
</div> </div>
)} )}
</div> </div>
) )
} }, (prevProps, nextProps) => {
// Custom comparison for better performance
return prevProps.sortedTelemetry.length === nextProps.sortedTelemetry.length &&
(prevProps.dataGaps?.length ?? 0) === (nextProps.dataGaps?.length ?? 0) &&
prevProps.sortedTelemetry[0]?.id === nextProps.sortedTelemetry[0]?.id
})

View File

@@ -0,0 +1,226 @@
"use client"
import { useMemo } from 'react'
import { Thermometer, Sun, Droplets, Wind, AlertTriangle } from 'lucide-react'
import { WeatherData, GreenhouseAlert } from '@/features/weather'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { getPersianDayName } from '@/features/daily-report/utils'
type GreenhouseForecastAlert = GreenhouseAlert & {
daysAhead: number // تعداد روزهای آینده
date: string // تاریخ روز پیش‌بینی
}
type GreenhouseForecastAlertsProps = {
weatherData: WeatherData
}
/**
* محاسبه تعداد هشدارهای پیش‌بینی آب و هوا
*/
export function getForecastAlertsCount(weatherData: WeatherData | null): number {
if (!weatherData) return 0
let count = 0
// بررسی روزهای آینده (از روز دوم به بعد، چون روز اول امروز است)
for (let i = 1; i < weatherData.daily.length; i++) {
const day = weatherData.daily[i]
if (day.tempMin < 5) count++ // یخ‌زدگی
if (day.tempMax > 35) count++ // گرمای شدید
if (day.uvIndexMax > 8) count++ // UV بالا
if (day.windSpeedMax > 40) count++ // باد شدید
if (day.precipitation > 10) count++ // بارش قابل توجه
if (day.tempMin >= 5 && day.tempMin < 10) count++ // دمای پایین
if (day.tempMax >= 30 && day.tempMax <= 35) count++ // دمای بالا
}
return count
}
/**
* کامپوننت هشدارهای پیش‌بینی آب و هوای آینده برای گلخانه
* این کامپوننت فقط برای روزهای آینده (نه امروز) هشدار می‌دهد
*/
export function GreenhouseForecastAlerts({ weatherData }: GreenhouseForecastAlertsProps) {
const alertsByDay = useMemo(() => {
const alertsMap = new Map<number, GreenhouseForecastAlert[]>()
// بررسی روزهای آینده (از روز دوم به بعد، چون روز اول امروز است)
for (let i = 1; i < weatherData.daily.length; i++) {
const day = weatherData.daily[i]
const daysAhead = i // تعداد روزهای آینده
const dayAlerts: GreenhouseForecastAlert[] = []
// هشدار یخ‌زدگی (دمای حداقل کمتر از 5 درجه)
if (day.tempMin < 5) {
dayAlerts.push({
type: 'danger',
title: '⚠️ هشدار یخ‌زدگی',
description: `دمای حداقل ${toPersianDigits(Math.round(day.tempMin))}°C پیش‌بینی شده. سیستم گرمایش را آماده کنید و پوشش محافظ روی گیاهان حساس قرار دهید.`,
icon: Thermometer,
daysAhead,
date: day.date
})
}
// هشدار گرمای شدید (دمای حداکثر بیشتر از 35 درجه)
if (day.tempMax > 35) {
dayAlerts.push({
type: 'danger',
title: '🌡️ هشدار گرمای شدید',
description: `دمای حداکثر ${toPersianDigits(Math.round(day.tempMax))}°C پیش‌بینی شده. سایه‌بان‌ها را فعال کنید، تهویه را افزایش دهید و آبیاری را در ساعات خنک انجام دهید.`,
icon: Sun,
daysAhead,
date: day.date
})
}
// هشدار شاخص UV بالا (بیشتر از 8)
if (day.uvIndexMax > 8) {
dayAlerts.push({
type: 'warning',
title: '☀️ شاخص UV بالا',
description: `شاخص UV ${toPersianDigits(Math.round(day.uvIndexMax))} است. برای گیاهان حساس به نور از سایه‌بان استفاده کنید.`,
icon: Sun,
daysAhead,
date: day.date
})
}
// هشدار باد شدید (بیشتر از 40 کیلومتر بر ساعت)
if (day.windSpeedMax > 40) {
dayAlerts.push({
type: 'warning',
title: '💨 باد شدید',
description: `سرعت باد به ${toPersianDigits(Math.round(day.windSpeedMax))} کیلومتر بر ساعت می‌رسد. دریچه‌ها و پنجره‌ها را ببندید و سازه را بررسی کنید.`,
icon: Wind,
daysAhead,
date: day.date
})
}
// هشدار بارش قابل توجه (بیشتر از 10 میلی‌متر)
if (day.precipitation > 10) {
dayAlerts.push({
type: 'info',
title: '🌧️ بارش قابل توجه',
description: `بارش ${toPersianDigits(Math.round(day.precipitation))} میلی‌متر پیش‌بینی شده. سیستم زهکشی را بررسی کنید و آبیاری را کاهش دهید.`,
icon: Droplets,
daysAhead,
date: day.date
})
}
// هشدار دمای پایین (بین 5 تا 10 درجه) - هشدار خفیف
if (day.tempMin >= 5 && day.tempMin < 10) {
dayAlerts.push({
type: 'warning',
title: '🌡️ دمای پایین',
description: `دمای حداقل ${toPersianDigits(Math.round(day.tempMin))}°C پیش‌بینی شده. مراقب گیاهان حساس به سرما باشید.`,
icon: Thermometer,
daysAhead,
date: day.date
})
}
// هشدار دمای بالا (بین 30 تا 35 درجه) - هشدار خفیف
if (day.tempMax >= 30 && day.tempMax <= 35) {
dayAlerts.push({
type: 'warning',
title: '🌡️ دمای بالا',
description: `دمای حداکثر ${toPersianDigits(Math.round(day.tempMax))}°C پیش‌بینی شده. تهویه را افزایش دهید و آبیاری را در ساعات صبح انجام دهید.`,
icon: Sun,
daysAhead,
date: day.date
})
}
// اگر هشداری برای این روز وجود دارد، به map اضافه کن
if (dayAlerts.length > 0) {
alertsMap.set(daysAhead, dayAlerts)
}
}
return alertsMap
}, [weatherData])
if (alertsByDay.size === 0) {
return null
}
// تبدیل map به array و مرتب‌سازی بر اساس daysAhead
const sortedDays = Array.from(alertsByDay.entries()).sort((a, b) => a[0] - b[0])
// تابع برای نمایش نام روزهای آینده
const getDayLabel = (daysAhead: number) => {
if (daysAhead === 1) return 'فردا'
if (daysAhead === 2) return 'پس فردا'
return `${toPersianDigits(daysAhead)} روز آینده`
}
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
هشدارهای پیشبینی آب و هوا برای روزهای آینده
</h3>
{sortedDays.map(([daysAhead, alerts]) => {
const dayName = getPersianDayName(weatherData.daily[daysAhead].date)
const dayLabel = getDayLabel(daysAhead)
return (
<div key={daysAhead} className="space-y-3">
{/* Header for the day */}
<div className="flex items-center gap-2 pb-2 border-b-2 border-gray-200">
<h4 className="text-base font-bold text-gray-800">
روز {dayName} ({dayLabel})
</h4>
</div>
{/* Alerts for this day */}
{alerts.map((alert, index) => {
const IconComponent = alert.icon
return (
<div
key={`${alert.date}-${alert.type}-${index}`}
className={`p-4 rounded-xl border-r-4 shadow-sm ${
alert.type === 'danger' ? 'bg-red-50 border-red-500' :
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' :
alert.type === 'info' ? 'bg-blue-50 border-blue-500' :
'bg-green-50 border-green-500'
}`}
>
<div className="flex items-start gap-3">
<IconComponent className={`w-5 h-5 mt-0.5 flex-shrink-0 ${
alert.type === 'danger' ? 'text-red-600' :
alert.type === 'warning' ? 'text-amber-600' :
alert.type === 'info' ? 'text-blue-600' :
'text-green-600'
}`} />
<div className="flex-1">
<p className={`font-semibold text-sm mb-1 ${
alert.type === 'danger' ? 'text-red-700' :
alert.type === 'warning' ? 'text-amber-700' :
alert.type === 'info' ? 'text-blue-700' :
'text-green-700'
}`}>
{alert.title}
</p>
<p className="text-sm text-gray-600 leading-relaxed">
{alert.description}
</p>
</div>
</div>
</div>
)
})}
</div>
)
})}
</div>
)
}

View File

@@ -1,7 +1,8 @@
import { TrendingUp, TrendingDown } from 'lucide-react' import { TrendingUp, TrendingDown } from 'lucide-react'
import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges' import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges'
import { MiniLineChart } from '@/components/MiniChart' import { MiniLineChart } from '@/components/MiniChart'
import { paramConfig, toPersianDigits } from './utils' import { paramConfig } from '@/features/daily-report/utils'
import { toPersianDigits } from '@/lib/format/persian-digits'
type SummaryCardProps = { type SummaryCardProps = {
param: string param: string

View File

@@ -1,77 +1,151 @@
import { useMemo, useState } from 'react'
import { SummaryCard } from './SummaryCard' import { SummaryCard } from './SummaryCard'
import { WeatherData } from '@/features/weather'
import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts'
import { Dialog } from '@/components/common/Dialog'
import { WeatherAlertBanner } from '@/components/alerts'
import { Loader2 } from 'lucide-react'
type SummaryTabProps = { type SummaryTabProps = {
temperature: { temperature: number[]
current: number humidity: number[]
min: number soil: number[]
max: number gas: number[]
data: number[] lux: number[]
} forecastWeather?: WeatherData | null
humidity: { forecastWeatherLoading?: boolean
current: number
min: number
max: number
data: number[]
}
soil: {
current: number
min: number
max: number
data: number[]
}
gas: {
current: number
min: number
max: number
data: number[]
}
lux: {
current: number
min: number
max: number
data: number[]
}
} }
export function SummaryTab({ temperature, humidity, soil, gas, lux }: SummaryTabProps) { export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false }: SummaryTabProps) {
const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
const alertsCount = useMemo(() => {
return getForecastAlertsCount(forecastWeather ?? null)
}, [forecastWeather])
// Memoized summary statistics for each parameter
const temperatureSummary = useMemo(() => {
if (temperature.length === 0) return { current: 0, min: 0, max: 0 }
return {
current: temperature.at(-1) ?? 0,
min: Math.min(...temperature),
max: Math.max(...temperature),
}
}, [temperature])
const humiditySummary = useMemo(() => {
if (humidity.length === 0) return { current: 0, min: 0, max: 0 }
return {
current: humidity.at(-1) ?? 0,
min: Math.min(...humidity),
max: Math.max(...humidity),
}
}, [humidity])
const soilSummary = useMemo(() => {
if (soil.length === 0) return { current: 0, min: 0, max: 0 }
return {
current: soil.at(-1) ?? 0,
min: Math.min(...soil),
max: Math.max(...soil),
}
}, [soil])
const gasSummary = useMemo(() => {
if (gas.length === 0) return { current: 0, min: 0, max: 0 }
return {
current: gas.at(-1) ?? 0,
min: Math.min(...gas),
max: Math.max(...gas),
}
}, [gas])
const luxSummary = useMemo(() => {
if (lux.length === 0) return { current: 0, min: 0, max: 0 }
return {
current: lux.at(-1) ?? 0,
min: Math.min(...lux),
max: Math.max(...lux),
}
}, [lux])
return ( return (
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> <>
<SummaryCard {/* Greenhouse Forecast Alerts Section */}
param="temperature" {forecastWeatherLoading ? (
currentValue={temperature.current} <div className="mb-6">
minValue={temperature.min} <div className="w-full p-4 bg-gradient-to-r from-amber-50 to-orange-50 border-2 border-amber-200 rounded-xl text-right flex items-center justify-between">
maxValue={temperature.max} <div className="flex items-center gap-3">
data={temperature.data} <div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
/> <Loader2 className="w-6 h-6 text-white animate-spin" />
<SummaryCard </div>
param="humidity" <div>
currentValue={humidity.current} <p className="font-semibold text-gray-800">
minValue={humidity.min} در حال بارگذاری هشدارهای آب و هوایی...
maxValue={humidity.max} </p>
data={humidity.data} <p className="text-sm text-gray-600 mt-1">
/> لطفاً صبر کنید
<SummaryCard </p>
param="soil" </div>
currentValue={soil.current} </div>
minValue={soil.min} </div>
maxValue={soil.max} </div>
data={soil.data} ) : alertsCount > 0 && forecastWeather ? (
/> <WeatherAlertBanner
<SummaryCard alertsCount={alertsCount}
param="gas" onClick={() => setIsAlertsDialogOpen(true)}
currentValue={gas.current} />
minValue={gas.min} ) : null}
maxValue={gas.max}
data={gas.data} {/* Summary Cards Grid */}
/> <div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<SummaryCard <SummaryCard
param="lux" param="temperature"
currentValue={lux.current} currentValue={temperatureSummary.current}
minValue={lux.min} minValue={temperatureSummary.min}
maxValue={lux.max} maxValue={temperatureSummary.max}
data={lux.data} data={temperature}
/> />
</div> <SummaryCard
param="humidity"
currentValue={humiditySummary.current}
minValue={humiditySummary.min}
maxValue={humiditySummary.max}
data={humidity}
/>
<SummaryCard
param="soil"
currentValue={soilSummary.current}
minValue={soilSummary.min}
maxValue={soilSummary.max}
data={soil}
/>
<SummaryCard
param="gas"
currentValue={gasSummary.current}
minValue={gasSummary.min}
maxValue={gasSummary.max}
data={gas}
/>
<SummaryCard
param="lux"
currentValue={luxSummary.current}
minValue={luxSummary.min}
maxValue={luxSummary.max}
data={lux}
/>
</div>
{/* Alerts Dialog */}
{forecastWeather && (
<Dialog
isOpen={isAlertsDialogOpen}
onClose={() => setIsAlertsDialogOpen(false)}
title="هشدارهای پیش‌بینی آب و هوا"
>
<GreenhouseForecastAlerts weatherData={forecastWeather} />
</Dialog>
)}
</>
) )
} }

View File

@@ -1,5 +1,13 @@
import { AlertTriangle } from 'lucide-react' import { useMemo } from 'react'
import { toPersianDigits, DataGap } from './utils' import { DataGap } from '@/features/daily-report/utils'
import { calculateSunTimes } from '@/lib/utils/sun-utils'
import {
TimeRangeHeader,
TimelineTrack,
TimelineSlider,
TimeLabel,
TimeRangeInfo,
} from './timeline'
type TimeRangeSelectorProps = { type TimeRangeSelectorProps = {
startMinute: number // دقیقه از نیمه شب (0-1439) startMinute: number // دقیقه از نیمه شب (0-1439)
@@ -10,49 +18,6 @@ type TimeRangeSelectorProps = {
dataGaps?: DataGap[] // گپ‌های داده dataGaps?: DataGap[] // گپ‌های داده
} }
// محاسبه زمان طلوع و غروب خورشید برای قم
// عرض جغرافیایی: 34.6416° شمالی، طول جغرافیایی: 50.8746° شرقی
function calculateSunTimes() {
const latitude = 34.6416
const now = new Date()
const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000)
// محاسبه انحراف خورشید (Solar Declination)
const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10))
// محاسبه زاویه ساعتی طلوع (Hour Angle)
const latRad = latitude * Math.PI / 180
const decRad = declination * Math.PI / 180
const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad)
// در صورتی که خورشید طلوع/غروب می‌کند
if (Math.abs(cosHourAngle) <= 1) {
const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI
// زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق)
const sunriseDecimal = 12 - hourAngle / 15 + (50.8746 / 15 - 3.5) // تصحیح برای طول جغرافیایی و منطقه زمانی ایران
const sunsetDecimal = 12 + hourAngle / 15 + (50.8746 / 15 - 3.5)
// تبدیل به ساعت و دقیقه
const sunriseHour = Math.floor(sunriseDecimal)
const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60)
const sunsetHour = Math.floor(sunsetDecimal)
const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60)
return {
sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal },
sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal }
}
}
// مقادیر پیش‌فرض
return {
sunrise: { hour: 6, minute: 0, decimal: 6 },
sunset: { hour: 18, minute: 0, decimal: 18 }
}
}
export function TimeRangeSelector({ export function TimeRangeSelector({
startMinute, startMinute,
endMinute, endMinute,
@@ -61,205 +26,37 @@ export function TimeRangeSelector({
totalRecords, totalRecords,
dataGaps = [] dataGaps = []
}: TimeRangeSelectorProps) { }: TimeRangeSelectorProps) {
const { sunrise, sunset } = calculateSunTimes() const sunTimes = useMemo(() => calculateSunTimes(), [])
// تبدیل دقیقه به ساعت برای نمایش
const startHour = Math.floor(startMinute / 60)
const startMin = startMinute % 60
const endHour = Math.floor(endMinute / 60)
const endMin = endMinute % 60
// محاسبه موقعیت دقیق با دقیقه (از 0 تا 24 ساعت)
const sunrisePosition = sunrise.decimal
const sunsetPosition = sunset.decimal
// محاسبه درصد موقعیت برای نمایش (0 ساعت در راست، 1440 دقیقه در چپ) const handleReset = () => {
const sunrisePercent = ((1439 - (sunrisePosition * 60)) / 1439) * 100 onStartMinuteChange(0)
const sunsetPercent = ((1439 - (sunsetPosition * 60)) / 1439) * 100 onEndMinuteChange(1439) // 23:59
}
return ( return (
<div className="p-5"> <div className="p-5">
{/* Header */} <TimeRangeHeader dataGaps={dataGaps} onReset={handleReset} />
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span className="text-slate-700 dark:text-slate-300 font-medium text-lg">محدوده زمانی</span>
{dataGaps.length > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs">
<AlertTriangle className="w-3.5 h-3.5" />
<span>{toPersianDigits(dataGaps.length)} گپ در دادهها</span>
</div>
)}
</div>
<button
onClick={() => {
onStartMinuteChange(0)
onEndMinuteChange(1439) // 23:59
}}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
کل روز
</button>
</div>
{/* Timeline Selector */} {/* Timeline Selector */}
<div className="relative h-32 pt-10 mb-4 select-none"> <div className="relative h-32 pt-10 mb-4 select-none">
{/* Track background */} <TimelineTrack sunTimes={sunTimes} dataGaps={dataGaps} />
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20 rounded-lg bg-slate-200 dark:bg-slate-700">
{/* Sunrise dashed line */} <TimeLabel minute={startMinute} variant="start" />
<div <TimeLabel minute={endMinute} variant="end" />
className="absolute top-[-20px] bottom-[-20px] inset-y-0 w-0 border-r-2 border-dashed border-slate-400 dark:border-slate-500 z-10"
style={{ right: `${sunrisePercent}%` }} <TimelineSlider
></div> startMinute={startMinute}
endMinute={endMinute}
{/* Sunset dashed line */} onStartMinuteChange={onStartMinuteChange}
<div onEndMinuteChange={onEndMinuteChange}
className="absolute top-[-20px] bottom-[-20px] inset-y-0 w-0 border-r-2 border-dashed border-slate-400 dark:border-slate-500 z-10" />
style={{ right: `${sunsetPercent}%` }}
></div>
<div
className="absolute bottom-[-30px] z-30 pointer-events-none bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
style={{ right: `${sunrisePercent}%`, transform: 'translateX(50%)' }}
>
طلوع {toPersianDigits(sunrise.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunrise.minute.toString().padStart(2, '0'))}
</div>
<div className="absolute right-0 top-0 bottom-0 bg-gray-300"
style={{width: `${sunsetPercent}%`}}>
</div>
<div className="absolute left-0 top-0 bottom-0 bg-gray-300"
style={{width: `calc(${100 -sunrisePercent}% - 1px)`}}>
</div>
{/* Data gaps visualization */}
{dataGaps.map((gap, idx) => {
const gapStartPercent = ((1439 - gap.startMinute) / 1439) * 100
const gapEndPercent = ((1439 - gap.endMinute) / 1439) * 100
const gapWidth = gapStartPercent - gapEndPercent
const gapHours = Math.floor(gap.durationMinutes / 60)
const gapMins = gap.durationMinutes % 60
return (
<div key={idx}>
{/* Gap area */}
<div
className="absolute top-0 bottom-0 bg-red-400/30 dark:bg-red-500/40 border-r-2 border-l-2 border-red-500 dark:border-red-400 z-15"
style={{
right: `${gapEndPercent}%`,
width: `${gapWidth}%`
}}
title={`گپ ${toPersianDigits(gapHours)}:${toPersianDigits(gapMins.toString().padStart(2, '0'))}`}
>
{/* Warning icon in gap */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
</div>
{/* Gap tooltip */}
{gapWidth > 5 && (
<div
className="absolute top-[-30px] z-30 pointer-events-none bg-red-500 text-white px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
style={{ right: `${gapEndPercent + gapWidth / 2}%`, transform: 'translateX(50%)' }}
>
گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))}
</div>
)}
</div>
)
})}
<div
className="absolute bottom-[-30px] z-30 pointer-events-none bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
style={{ right: `${sunsetPercent}%`, transform: 'translateX(50%)' }}
>
غروب {toPersianDigits(sunset.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunset.minute.toString().padStart(2, '0'))}
</div>
{/* Hour markers inside track */}
{[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map(hour => (
<div
key={hour}
className="absolute bottom-0 z-20"
style={{ right: `${((23 - hour) / 23) * 100}%`, transform: 'translateX(50%)' }}
>
<div className="flex flex-col items-center">
<span className="text-[10px] text-slate-600 dark:text-slate-400 font-mono">
{toPersianDigits(hour.toString().padStart(2, '0'))}
</span>
<div className="w-px h-2 bg-slate-400 dark:bg-slate-500 mb-1"></div>
</div>
</div>
))}
</div>
{/* Start time label - above handle */}
<div
className="absolute top-[-8px] z-30 pointer-events-none"
style={{ right: `calc(${((1439 - startMinute) / 1439) * 100}% - 4px)`, transform: 'translateX(50%)' }}
>
<div className="bg-emerald-500 text-white px-2 py-1 rounded text-sm font-mono whitespace-nowrap">
{toPersianDigits(startHour.toString().padStart(2, '0'))}:{toPersianDigits(startMin.toString().padStart(2, '0'))}
</div>
</div>
{/* End time label - above handle */}
<div
className="absolute top-[-8px] z-30 pointer-events-none"
style={{ right: `calc(${((1439 - endMinute) / 1439) * 100}% + 4px)`, transform: 'translateX(50%)' }}
>
<div className="bg-rose-500 text-white px-2 py-1 rounded text-sm font-mono whitespace-nowrap">
{toPersianDigits(endHour.toString().padStart(2, '0'))}:{toPersianDigits(endMin.toString().padStart(2, '0'))}
</div>
</div>
{/* Range inputs container */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20">
{/* Start time slider */}
<input
type="range"
min="0"
max="1439"
value={1439 - startMinute}
onChange={(e) => {
const val = 1439 - Number(e.target.value)
if (val <= endMinute) onStartMinuteChange(val)
}}
className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-emerald-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
/>
{/* End time slider */}
<input
type="range"
min="0"
max="1439"
value={1439 - endMinute}
onChange={(e) => {
const val = 1439 - Number(e.target.value)
if (val >= startMinute) onEndMinuteChange(val)
}}
className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-rose-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-rose-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
/>
</div>
</div> </div>
{/* Info section */} <TimeRangeInfo
<div className="flex items-center justify-between pt-4 border-t border-slate-200 dark:border-slate-700"> startMinute={startMinute}
<div className="text-slate-600 dark:text-slate-400"> endMinute={endMinute}
<span className="text-2xl font-bold text-slate-800 dark:text-slate-200 font-mono"> totalRecords={totalRecords}
{toPersianDigits(Math.floor((endMinute - startMinute + 1) / 60))}:{toPersianDigits(((endMinute - startMinute + 1) % 60).toString().padStart(2, '0'))} />
</span>
<span className="text-sm mr-2">بازه انتخاب شده</span>
</div>
<div className="text-slate-500 dark:text-slate-400 text-sm">
{totalRecords > 0
? <>{toPersianDigits(totalRecords)} رکورد</>
: <>بدون رکورد</>
}
</div>
</div>
</div> </div>
) )
} }

View File

@@ -1,80 +1,94 @@
import { Loader2, AlertCircle, RefreshCw, MapPin, Droplets, Wind, Thermometer, Sun, CloudRain, Calendar as CalendarIcon, ChevronDown } from 'lucide-react' import { useState, useEffect, useCallback } from 'react'
import { WeatherData, toPersianDigits, getWeatherInfo, getPersianDayName, getGreenhouseAlerts } from '.' import { RefreshCw, MapPin } from 'lucide-react'
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date' import { WeatherData } from '@/features/weather'
import { fetchHistoricalWeather, fetchForecastWeather, isToday as checkIsToday, fetchLocationName } from '@/features/weather'
import { QOM_LAT, QOM_LON } from '@/features/weather/helpers'
import { TodayWeather } from './weather/TodayWeather'
import { HistoricalWeather } from './weather/HistoricalWeather'
import Loading from '@/components/Loading'
import { ErrorMessage } from '@/components/common'
import { Button } from '@/components/common/Button'
type WeatherTabProps = { type WeatherTabProps = {
loading: boolean selectedDate: string // Persian date in format "yyyy/MM/dd"
error: string | null
weatherData: WeatherData | null
onRetry: () => void
expandedDayIndex: number | null
onDayToggle: (index: number | null) => void
selectedDate: string | null // Persian date in format "yyyy/MM/dd"
} }
export function WeatherTab({ export function WeatherTab({ selectedDate }: WeatherTabProps) {
loading, const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
error, const [loading, setLoading] = useState(false)
weatherData, const [error, setError] = useState<string | null>(null)
onRetry, const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
expandedDayIndex, const [locationName, setLocationName] = useState<string>('در حال دریافت...')
onDayToggle,
selectedDate const loadWeather = useCallback(async () => {
}: WeatherTabProps) { // اگر قبلاً لود شده، دوباره لود نکن
// Check if selected date is today by comparing Persian dates if (weatherData) return
const isToday = (() => {
if (!selectedDate) return true setLoading(true)
setError(null)
try { try {
// Get today's Persian date const isTodayDate = checkIsToday(selectedDate)
const todayYear = getCurrentPersianYear()
const todayMonth = getCurrentPersianMonth()
const todayDay = getCurrentPersianDay()
const todayPersian = `${todayYear}/${String(todayMonth).padStart(2, '0')}/${String(todayDay).padStart(2, '0')}`
// Normalize selected date format // Load weather data and location name in parallel
const [y, m, d] = selectedDate.split('/').map(s => s.trim()) const [weather, location] = await Promise.all([
const normalizedSelected = `${y}/${String(Number(m)).padStart(2, '0')}/${String(Number(d)).padStart(2, '0')}` isTodayDate
? fetchForecastWeather()
: fetchHistoricalWeather(selectedDate),
fetchLocationName(QOM_LAT, QOM_LON)
])
return normalizedSelected === todayPersian setWeatherData(weather)
} catch (e) { setLocationName(location)
console.error('Error checking if today:', e) } catch (error) {
return true console.error('Error loading weather:', error)
setError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
// Set fallback location name on error
setLocationName('کهک قم، ایران')
} finally {
setLoading(false)
} }
})() }, [selectedDate, weatherData])
// Reset weather data when selectedDate changes
useEffect(() => {
setWeatherData(null)
setError(null)
setExpandedDayIndex(null)
setLocationName('در حال دریافت...')
}, [selectedDate])
if (loading) { if (loading) {
return ( return <Loading message="در حال دریافت اطلاعات آب و هوا..." fullScreen={false} />
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Loader2 className="w-12 h-12 text-sky-500 animate-spin mb-4" />
<p className="text-gray-600">در حال دریافت اطلاعات آب و هوا...</p>
</div>
)
} }
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 text-gray-500"> <ErrorMessage
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4"> message={error}
<AlertCircle className="w-8 h-8 text-red-500" /> action={
</div> <Button
<p className="text-gray-700 mb-4">{error}</p> onClick={() => {
<button setWeatherData(null)
onClick={onRetry} loadWeather()
className="flex items-center gap-2 px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white rounded-lg transition-colors" }}
> variant="primary"
<RefreshCw className="w-4 h-4" /> icon={RefreshCw}
تلاش مجدد >
</button> تلاش مجدد
</div> </Button>
}
/>
) )
} }
if (!weatherData) { if (!weatherData) {
// Trigger load when component is mounted (user clicked on weather tab)
loadWeather()
return null return null
} }
const alerts = getGreenhouseAlerts(weatherData) const isTodayDate = checkIsToday(selectedDate)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -82,433 +96,24 @@ export function WeatherTab({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-gray-600"> <div className="flex items-center gap-2 text-gray-600">
<MapPin className="w-5 h-5 text-sky-500" /> <MapPin className="w-5 h-5 text-sky-500" />
<span className="font-medium">قم، ایران</span> <span className="font-medium">{locationName}</span>
</div> </div>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{isToday ? 'پیش‌بینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'} {isTodayDate ? 'پیش‌بینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
</span> </span>
</div> </div>
{/* Greenhouse Alerts - Only for today */} {isTodayDate ? (
{isToday && alerts.length > 0 && ( <TodayWeather
<div className="space-y-3"> weatherData={weatherData}
<h3 className="text-sm font-semibold text-gray-700">🌱 هشدارها و توصیههای گلخانه</h3> expandedDayIndex={expandedDayIndex}
{alerts.map((alert, index) => ( onDayToggle={setExpandedDayIndex}
<div />
key={index} ) : (
className={`p-4 rounded-xl border-r-4 ${ <HistoricalWeather
alert.type === 'danger' ? 'bg-red-50 border-red-500' : weatherData={weatherData}
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' : selectedDate={selectedDate}
alert.type === 'info' ? 'bg-blue-50 border-blue-500' : />
'bg-green-50 border-green-500'
}`}
>
<p className={`font-semibold text-sm ${
alert.type === 'danger' ? 'text-red-700' :
alert.type === 'warning' ? 'text-amber-700' :
alert.type === 'info' ? 'text-blue-700' :
'text-green-700'
}`}>{alert.title}</p>
<p className="text-sm text-gray-600 mt-1">{alert.description}</p>
</div>
))}
</div>
)}
{/* Today's Status Card */}
<div className="space-y-4">
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
{/* Current Weather Header - Only for today */}
{isToday && (
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-6">
<div className="flex items-center justify-between">
<div className="text-white">
<p className="text-lg opacity-90 mb-1">🌡 الان</p>
<div className="flex items-baseline gap-2">
<span className="text-6xl font-bold">
{toPersianDigits(Math.round(weatherData.current.temperature))}
</span>
<span className="text-3xl">درجه</span>
</div>
</div>
<div className="text-center text-white">
{(() => {
const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
return <IconComponent className="w-20 h-20" />
})()}
<p className="text-lg mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
</div>
</div>
</div>
)}
{/* Past Date Header */}
{!isToday && (
<div className="bg-gradient-to-r from-blue-500 to-indigo-500 p-6">
<div className="text-center text-white">
<p className="text-lg opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
<p className="text-2xl font-bold">{selectedDate}</p>
</div>
</div>
)}
{/* Status Grid */}
<div className="p-4 grid grid-cols-2 gap-4">
{/* Temperature Card */}
<div className={`rounded-2xl p-4 ${
weatherData.daily[0]?.tempMin < 5 ? 'bg-blue-100 border-2 border-blue-300' :
weatherData.daily[0]?.tempMax > 35 ? 'bg-red-100 border-2 border-red-300' :
'bg-green-100 border-2 border-green-300'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
weatherData.daily[0]?.tempMin < 5 ? 'bg-blue-500' :
weatherData.daily[0]?.tempMax > 35 ? 'bg-red-500' :
'bg-green-500'
}`}>
<Thermometer className="w-8 h-8 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
<p className={`text-sm ${
weatherData.daily[0]?.tempMin < 5 ? 'text-blue-600' :
weatherData.daily[0]?.tempMax > 35 ? 'text-red-600' :
'text-green-600'
}`}>
{weatherData.daily[0]?.tempMin < 5 ? '❄️ سرد!' :
weatherData.daily[0]?.tempMax > 35 ? '🔥 گرم!' :
'✅ مناسب'}
</p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-center">
<p className="text-xs text-gray-500">🌙 شب</p>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(weatherData.daily[0]?.tempMin || 0))}°</p>
</div>
<div className="text-3xl text-gray-300"></div>
<div className="text-center">
<p className="text-xs text-gray-500"> روز</p>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(weatherData.daily[0]?.tempMax || 0))}°</p>
</div>
</div>
</div>
{/* Rain Card */}
{isToday ? (
/* Forecast: احتمال بارش */
<div className={`rounded-2xl p-4 ${
(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? 'bg-blue-100 border-2 border-blue-300' :
(weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-50 border-2 border-sky-200' :
'bg-amber-50 border-2 border-amber-200'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? 'bg-blue-500' :
(weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'bg-sky-400' :
'bg-amber-400'
}`}>
{(weatherData.daily[0]?.precipitationProbability || 0) > 30 ?
<CloudRain className="w-8 h-8 text-white" /> :
<Sun className="w-8 h-8 text-white" />
}
</div>
<div>
<p className="text-lg font-bold text-gray-800">بارش</p>
<p className={`text-sm ${
(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? 'text-blue-600' :
(weatherData.daily[0]?.precipitationProbability || 0) > 30 ? 'text-sky-600' :
'text-amber-600'
}`}>
{(weatherData.daily[0]?.precipitationProbability || 0) > 60 ? '🌧️ باران می‌آید' :
(weatherData.daily[0]?.precipitationProbability || 0) > 30 ? '🌦️ شاید ببارد' :
'☀️ خشک است'}
</p>
</div>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(weatherData.daily[0]?.precipitationProbability || 0)}%</p>
<p className="text-sm text-gray-500">احتمال بارش</p>
</div>
</div>
) : (
/* Historical: میزان بارش واقعی */
<div className={`rounded-2xl p-4 ${
(weatherData.daily[0]?.precipitation || 0) > 5 ? 'bg-blue-100 border-2 border-blue-300' :
(weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-50 border-2 border-sky-200' :
'bg-amber-50 border-2 border-amber-200'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
(weatherData.daily[0]?.precipitation || 0) > 5 ? 'bg-blue-500' :
(weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-400' :
'bg-amber-400'
}`}>
{(weatherData.daily[0]?.precipitation || 0) > 0 ?
<CloudRain className="w-8 h-8 text-white" /> :
<Sun className="w-8 h-8 text-white" />
}
</div>
<div>
<p className="text-lg font-bold text-gray-800">بارش</p>
<p className={`text-sm ${
(weatherData.daily[0]?.precipitation || 0) > 5 ? 'text-blue-600' :
(weatherData.daily[0]?.precipitation || 0) > 0 ? 'text-sky-600' :
'text-amber-600'
}`}>
{(weatherData.daily[0]?.precipitation || 0) > 5 ? '🌧️ بارش زیاد' :
(weatherData.daily[0]?.precipitation || 0) > 0 ? '🌦️ بارش کم' :
'☀️ بدون بارش'}
</p>
</div>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-gray-800">{toPersianDigits((weatherData.daily[0]?.precipitation || 0).toFixed(1))}</p>
<p className="text-sm text-gray-500">میلیمتر بارش</p>
</div>
</div>
)}
{/* Sunlight Card */}
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-2xl p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 rounded-xl bg-yellow-400 flex items-center justify-center">
<Sun className="w-8 h-8 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
<p className="text-sm text-yellow-600">
{(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 8 ? '☀️ آفتاب زیاد' :
(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 4 ? '🌤️ آفتاب متوسط' :
'☁️ کم‌آفتاب'}
</p>
</div>
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(Math.round((weatherData.daily[0]?.sunshineDuration || 0) / 3600))}</p>
<p className="text-sm text-gray-500">ساعت آفتاب</p>
</div>
<div className="text-left">
<p className="text-2xl font-bold text-orange-500">{toPersianDigits(Math.round(weatherData.daily[0]?.uvIndexMax || 0))}</p>
<p className="text-xs text-gray-500">شاخص UV</p>
</div>
</div>
</div>
{/* Wind & Humidity Card */}
<div className="bg-cyan-50 border-2 border-cyan-200 rounded-2xl p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 rounded-xl bg-cyan-400 flex items-center justify-center">
<Wind className="w-8 h-8 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
<p className="text-sm text-cyan-600">
{(weatherData.daily[0]?.windSpeedMax || 0) > 40 ? '💨 باد شدید!' :
(weatherData.daily[0]?.windSpeedMax || 0) > 20 ? '🍃 وزش باد' :
'😌 آرام'}
</p>
</div>
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(weatherData.daily[0]?.windSpeedMax || 0))}</p>
<p className="text-xs text-gray-500">کیلومتر/ساعت باد</p>
</div>
<div className="text-left">
<p className="text-3xl font-bold text-blue-500">{toPersianDigits(weatherData.current.humidity)}%</p>
<p className="text-xs text-gray-500">رطوبت هوا</p>
</div>
</div>
</div>
</div>
</div>
{/* Hourly Forecast - Only for today */}
{isToday && (
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 p-4">
<h4 className="text-lg font-bold text-white flex items-center gap-2">
🕐 وضعیت ساعت به ساعت امروز
</h4>
</div>
<div className="p-4">
<div className="overflow-x-auto pb-2">
<div className="flex gap-2" style={{ minWidth: 'max-content' }}>
{weatherData.hourly.map((hour) => {
const hourNum = new Date(hour.time).getHours()
const isNow = hourNum === new Date().getHours()
const IconComponent = getWeatherInfo(hour.weatherCode).icon
const isHot = hour.temperature > 35
const isCold = hour.temperature < 10
return (
<div
key={hour.time}
className={`flex flex-col items-center p-3 rounded-xl min-w-[85px] transition-all ${
isNow
? 'bg-gradient-to-b from-emerald-400 to-emerald-500 text-white shadow-lg scale-105'
: isHot
? 'bg-red-50 border border-red-200'
: isCold
? 'bg-blue-50 border border-blue-200'
: 'bg-gray-50 border border-gray-100'
}`}
>
<p className={`text-sm font-bold ${isNow ? 'text-white' : 'text-gray-600'}`}>
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
</p>
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
<IconComponent className={`w-6 h-6 ${
isNow ? 'text-white' :
isHot ? 'text-red-500' :
isCold ? 'text-blue-500' :
'text-gray-500'
}`} />
</div>
<p className={`text-2xl font-bold ${
isNow ? 'text-white' :
isHot ? 'text-red-600' :
isCold ? 'text-blue-600' :
'text-gray-800'
}`}>
{toPersianDigits(Math.round(hour.temperature))}°
</p>
<div className={`flex items-center gap-1 mt-2 text-xs ${isNow ? 'text-white/80' : 'text-blue-500'}`}>
<Droplets className="w-3 h-3" />
<span>{toPersianDigits(hour.humidity)}%</span>
</div>
{hour.precipitation > 0 && (
<div className={`mt-1 px-2 py-0.5 rounded-full text-xs ${isNow ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-600'}`}>
🌧 {toPersianDigits(hour.precipitation.toFixed(1))}
</div>
)}
</div>
)
})}
</div>
</div>
<p className="text-xs text-gray-400 text-center mt-3">👈 برای دیدن ساعتهای بیشتر به چپ بکشید</p>
</div>
</div>
)}
</div>
{/* 7-Day Forecast - Only for today */}
{isToday && (
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-emerald-500" />
پیشبینی ۷ روز آینده
</h3>
<div className="space-y-2">
{weatherData.daily.map((day, index) => {
const weatherInfo = getWeatherInfo(day.weatherCode)
const IconComponent = weatherInfo.icon
const isToday = index === 0
const hasFrost = day.tempMin < 5
const hasHeat = day.tempMax > 35
const isExpanded = expandedDayIndex === index
return (
<div key={day.date} className="overflow-hidden rounded-xl">
<button
onClick={() => onDayToggle(isExpanded ? null : index)}
className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
isExpanded
? 'bg-emerald-500 text-white'
: isToday
? 'bg-emerald-50 hover:bg-emerald-100'
: hasFrost
? 'bg-blue-50 hover:bg-blue-100'
: hasHeat
? 'bg-red-50 hover:bg-red-100'
: 'bg-gray-50 hover:bg-gray-100'
}`}
>
<div className="flex items-center gap-4 flex-1">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isExpanded ? 'bg-white/20' : 'bg-white'
}`}>
<IconComponent className={`w-6 h-6 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
</div>
<div className="text-right flex-1">
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
{isToday ? 'امروز' : getPersianDayName(day.date)}
</p>
<p className={`text-sm ${isExpanded ? 'text-white/80' : 'text-gray-500'}`}>
{weatherInfo.description}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<p className={`text-2xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
{toPersianDigits(Math.round(day.tempMax))}°
</p>
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
{toPersianDigits(Math.round(day.tempMin))}°
</p>
</div>
<ChevronDown className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
</div>
</button>
{isExpanded && (
<div className="bg-white p-4 border-t border-emerald-100">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Thermometer className="w-4 h-4 text-gray-500" />
<span className="text-xs text-gray-600 font-medium">دما</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
<p className="text-sm text-gray-500">حداکثر</p>
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
<p className="text-sm text-gray-500">حداقل</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<CloudRain className="w-4 h-4 text-gray-500" />
<span className="text-xs text-gray-600 font-medium">بارش</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
<p className="text-sm text-gray-500">احتمال</p>
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
<p className="text-sm text-gray-500">میلیمتر</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Sun className="w-4 h-4 text-gray-500" />
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
<p className="text-sm text-gray-500">ساعت</p>
<p className="text-xl font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
<p className="text-sm text-gray-500">UV Index</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Wind className="w-4 h-4 text-gray-500" />
<span className="text-xs text-gray-600 font-medium">باد</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
<p className="text-sm text-gray-500">کیلومتر/ساعت</p>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)} )}
</div> </div>
) )

View File

@@ -1,10 +1,9 @@
// Daily report UI components only
export { SummaryCard } from './SummaryCard' export { SummaryCard } from './SummaryCard'
export { SummaryTab } from './SummaryTab' export { SummaryTab } from './SummaryTab'
export { TimeRangeSelector } from './TimeRangeSelector' export { TimeRangeSelector } from './TimeRangeSelector'
export { ChartsTab } from './ChartsTab' export { ChartsTab } from './ChartsTab'
export { WeatherTab } from './WeatherTab' export { WeatherTab } from './WeatherTab'
export { AnalysisTab } from './AnalysisTab' export { AnalysisTab } from './AnalysisTab'
export * from './types' export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
export * from './utils'
export * from './weather-helpers'

View File

@@ -0,0 +1,47 @@
import { AlertTriangle } from 'lucide-react'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { DataGap } from '@/features/daily-report/utils'
import { minuteToPercent } from '@/lib/utils/time-utils'
type DataGapMarkerProps = {
gap: DataGap
index: number
}
export function DataGapMarker({ gap, index }: DataGapMarkerProps) {
const gapStartPercent = minuteToPercent(gap.startMinute)
const gapEndPercent = minuteToPercent(gap.endMinute)
const gapWidth = gapStartPercent - gapEndPercent
const gapHours = Math.floor(gap.durationMinutes / 60)
const gapMins = gap.durationMinutes % 60
return (
<div key={index}>
{/* Gap area */}
<div
className="absolute top-0 bottom-0 bg-red-400/30 dark:bg-red-500/40 border-r-2 border-l-2 border-red-500 dark:border-red-400 z-15"
style={{
right: `${gapEndPercent}%`,
width: `${gapWidth}%`
}}
title={`گپ ${toPersianDigits(gapHours)}:${toPersianDigits(gapMins.toString().padStart(2, '0'))}`}
>
{/* Warning icon in gap */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
</div>
{/* Gap tooltip */}
{gapWidth > 5 && (
<div
className="absolute top-[-30px] z-30 pointer-events-none bg-red-500 text-white px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
style={{ right: `${gapEndPercent + gapWidth / 2}%`, transform: 'translateX(50%)' }}
>
گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { DataGap } from '@/features/daily-report/utils'
import { DataGapMarker } from './DataGapMarker'
type DataGapsOverlayProps = {
dataGaps: DataGap[]
}
export function DataGapsOverlay({ dataGaps }: DataGapsOverlayProps) {
if (dataGaps.length === 0) return null
return (
<>
{dataGaps.map((gap, idx) => (
<DataGapMarker key={idx} gap={gap} index={idx} />
))}
</>
)
}

View File

@@ -0,0 +1,26 @@
import { formatSunTimeLabel, minuteToPercent } from '@/lib/utils/time-utils'
type SunTimeLabelProps = {
hour: number
minute: number
decimal: number
label: 'طلوع' | 'غروب'
position?: 'top' | 'bottom'
}
export function SunTimeLabel({ hour, minute, decimal, label, position = 'bottom' }: SunTimeLabelProps) {
const percent = minuteToPercent(decimal * 60)
const timeStr = formatSunTimeLabel(label, hour, minute)
return (
<div
className={`absolute z-30 pointer-events-none bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs font-mono whitespace-nowrap ${
position === 'top' ? 'top-[-30px]' : 'bottom-[-30px]'
}`}
style={{ right: `${percent}%`, transform: 'translateX(50%)' }}
>
{timeStr}
</div>
)
}

View File

@@ -0,0 +1,39 @@
import { memo, useMemo } from 'react'
import { formatTimeLabel, minuteToHours, minuteToPercent } from '@/lib/utils/time-utils'
type TimeLabelProps = {
minute: number
variant?: 'start' | 'end'
className?: string
}
const variantStyles = {
start: 'bg-emerald-500',
end: 'bg-rose-500',
}
function TimeLabelComponent({ minute, variant = 'start', className }: TimeLabelProps) {
const { hour, min, timeStr, percent } = useMemo(() => {
const { hour, minute: min } = minuteToHours(minute)
const timeStr = formatTimeLabel(hour, min)
const percent = minuteToPercent(minute)
return { hour, min, timeStr, percent }
}, [minute])
return (
<div
className={`absolute top-[-8px] z-30 pointer-events-none ${className || ''}`}
style={{
right: `calc(${percent}% ${variant === 'start' ? '-' : '+'} 4px)`,
transform: 'translateX(50%)'
}}
>
<div className={`${variantStyles[variant]} text-white px-2 py-1 rounded text-sm font-mono whitespace-nowrap`}>
{timeStr}
</div>
</div>
)
}
export const TimeLabel = memo(TimeLabelComponent)

View File

@@ -0,0 +1,34 @@
import { AlertTriangle } from 'lucide-react'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { Badge } from '@/components/common'
import { DataGap } from '@/features/daily-report/utils'
type TimeRangeHeaderProps = {
dataGaps?: DataGap[]
onReset: () => void
}
export function TimeRangeHeader({ dataGaps = [], onReset }: TimeRangeHeaderProps) {
return (
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span className="text-slate-700 dark:text-slate-300 font-medium text-lg">محدوده زمانی</span>
{dataGaps.length > 0 && (
<Badge
variant="warning"
icon={AlertTriangle}
>
{toPersianDigits(dataGaps.length)} گپ در دادهها
</Badge>
)}
</div>
<button
onClick={onReset}
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
کل روز
</button>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { toPersianDigits } from '@/lib/format/persian-digits'
import { calculateDuration } from '@/lib/utils/time-utils'
type TimeRangeInfoProps = {
startMinute: number
endMinute: number
totalRecords: number
}
export function TimeRangeInfo({ startMinute, endMinute, totalRecords }: TimeRangeInfoProps) {
const { hour, minute } = calculateDuration(startMinute, endMinute)
return (
<div className="flex items-center justify-between pt-4 border-t border-slate-200 dark:border-slate-700">
<div className="text-slate-600 dark:text-slate-400">
<span className="text-2xl font-bold text-slate-800 dark:text-slate-200 font-mono">
{toPersianDigits(hour)}:{toPersianDigits(minute.toString().padStart(2, '0'))}
</span>
<span className="text-sm mr-2">بازه انتخاب شده</span>
</div>
<div className="text-slate-500 dark:text-slate-400 text-sm">
{totalRecords > 0
? <>{toPersianDigits(totalRecords)} رکورد</>
: <>بدون رکورد</>
}
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { toPersianDigits } from '@/lib/format/persian-digits'
const HOURS_TO_MARK = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22]
export function TimelineHourMarkers() {
return (
<>
{HOURS_TO_MARK.map(hour => (
<div
key={hour}
className="absolute bottom-0 z-20"
style={{ right: `${((23 - hour) / 23) * 100}%`, transform: 'translateX(50%)' }}
>
<div className="flex flex-col items-center">
<span className="text-[10px] text-slate-600 dark:text-slate-400 font-mono">
{toPersianDigits(hour.toString().padStart(2, '0'))}
</span>
<div className="w-px h-2 bg-slate-400 dark:bg-slate-500 mb-1"></div>
</div>
</div>
))}
</>
)
}

View File

@@ -0,0 +1,109 @@
import { memo, useState, useEffect, useRef } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
type TimelineSliderProps = {
startMinute: number
endMinute: number
onStartMinuteChange: (minute: number) => void
onEndMinuteChange: (minute: number) => void
}
function TimelineSliderComponent({
startMinute,
endMinute,
onStartMinuteChange,
onEndMinuteChange,
}: TimelineSliderProps) {
// State محلی برای input values (برای نمایش فوری)
const [localStartValue, setLocalStartValue] = useState(1439 - startMinute)
const [localEndValue, setLocalEndValue] = useState(1439 - endMinute)
// Refs برای track کردن تغییرات props و نگه داشتن مقادیر valid
const prevStartMinuteRef = useRef(startMinute)
const prevEndMinuteRef = useRef(endMinute)
const endMinuteRef = useRef(endMinute)
const startMinuteRef = useRef(startMinute)
// Sync refs with props
useEffect(() => {
endMinuteRef.current = endMinute
startMinuteRef.current = startMinute
}, [startMinute, endMinute])
// Sync local state with props (فقط وقتی props از بیرون تغییر می‌کنن)
useEffect(() => {
if (prevStartMinuteRef.current !== startMinute) {
setLocalStartValue(1439 - startMinute)
prevStartMinuteRef.current = startMinute
}
}, [startMinute])
useEffect(() => {
if (prevEndMinuteRef.current !== endMinute) {
setLocalEndValue(1439 - endMinute)
prevEndMinuteRef.current = endMinute
}
}, [endMinute])
// Debounce values
const debouncedStartValue = useDebounce(localStartValue, 600)
const debouncedEndValue = useDebounce(localEndValue, 600)
// Update parent when debounced values change (فقط وقتی از user input باشه)
useEffect(() => {
const val = 1439 - debouncedStartValue
const currentEndMinute = endMinuteRef.current
const currentStartMinute = startMinuteRef.current
// فقط اگه مقدار debounced با prop فعلی متفاوت باشه و valid باشه
if (val !== currentStartMinute && val <= currentEndMinute) {
onStartMinuteChange(val)
}
}, [debouncedStartValue, onStartMinuteChange])
useEffect(() => {
const val = 1439 - debouncedEndValue
const currentStartMinute = startMinuteRef.current
const currentEndMinute = endMinuteRef.current
// فقط اگه مقدار debounced با prop فعلی متفاوت باشه و valid باشه
if (val !== currentEndMinute && val >= currentStartMinute) {
onEndMinuteChange(val)
}
}, [debouncedEndValue, onEndMinuteChange])
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = Number(e.target.value)
setLocalStartValue(inputValue)
}
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = Number(e.target.value)
setLocalEndValue(inputValue)
}
return (
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20">
{/* Start time slider */}
<input
type="range"
min="0"
max="1439"
value={localStartValue}
onChange={handleStartChange}
className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-emerald-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
/>
{/* End time slider */}
<input
type="range"
min="0"
max="1439"
value={localEndValue}
onChange={handleEndChange}
className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-rose-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-rose-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
/>
</div>
)
}
export const TimelineSlider = memo(TimelineSliderComponent)

View File

@@ -0,0 +1,73 @@
import { memo, useMemo } from 'react'
import { DataGap } from '@/features/daily-report/utils'
import { minuteToPercent } from '@/lib/utils/time-utils'
import { DataGapsOverlay } from './DataGapsOverlay'
import { TimelineHourMarkers } from './TimelineHourMarkers'
import { SunTimeLabel } from './SunTimeLabel'
type SunTimes = {
sunrise: { hour: number; minute: number; decimal: number }
sunset: { hour: number; minute: number; decimal: number }
}
type TimelineTrackProps = {
sunTimes: SunTimes
dataGaps?: DataGap[]
}
function TimelineTrackComponent({ sunTimes, dataGaps = [] }: TimelineTrackProps) {
const { sunrisePercent, sunsetPercent } = useMemo(() => ({
sunrisePercent: minuteToPercent(sunTimes.sunrise.decimal * 60),
sunsetPercent: minuteToPercent(sunTimes.sunset.decimal * 60),
}), [sunTimes.sunrise.decimal, sunTimes.sunset.decimal])
return (
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20 rounded-lg bg-slate-200 dark:bg-slate-700">
{/* Sunrise dashed line */}
<div
className="absolute top-[-20px] bottom-[-20px] inset-y-0 w-0 border-r-2 border-dashed border-slate-400 dark:border-slate-500 z-10"
style={{ right: `${sunrisePercent}%` }}
/>
{/* Sunset dashed line */}
<div
className="absolute top-[-20px] bottom-[-20px] inset-y-0 w-0 border-r-2 border-dashed border-slate-400 dark:border-slate-500 z-10"
style={{ right: `${sunsetPercent}%` }}
/>
{/* Sun time labels */}
<SunTimeLabel
hour={sunTimes.sunrise.hour}
minute={sunTimes.sunrise.minute}
decimal={sunTimes.sunrise.decimal}
label="طلوع"
position="bottom"
/>
<SunTimeLabel
hour={sunTimes.sunset.hour}
minute={sunTimes.sunset.minute}
decimal={sunTimes.sunset.decimal}
label="غروب"
position="bottom"
/>
{/* Night/day background areas */}
<div
className="absolute right-0 top-0 bottom-0 bg-gray-300"
style={{ width: `${sunsetPercent}%` }}
/>
<div
className="absolute left-0 top-0 bottom-0 bg-gray-300"
style={{ width: `calc(${100 - sunrisePercent}% - 1px)` }}
/>
{/* Data gaps visualization */}
<DataGapsOverlay dataGaps={dataGaps} />
{/* Hour markers */}
<TimelineHourMarkers />
</div>
)
}
export const TimelineTrack = memo(TimelineTrackComponent)

View File

@@ -0,0 +1,10 @@
export { TimeRangeHeader } from './TimeRangeHeader'
export { TimeLabel } from './TimeLabel'
export { SunTimeLabel } from './SunTimeLabel'
export { TimelineHourMarkers } from './TimelineHourMarkers'
export { DataGapMarker } from './DataGapMarker'
export { DataGapsOverlay } from './DataGapsOverlay'
export { TimelineSlider } from './TimelineSlider'
export { TimelineTrack } from './TimelineTrack'
export { TimeRangeInfo } from './TimeRangeInfo'

View File

@@ -0,0 +1,32 @@
import { WeatherData } from '@/features/weather'
import { TemperatureCard, PrecipitationHistoricalCard, SunlightCard, WindHumidityCard } from './WeatherCards'
type HistoricalWeatherProps = {
weatherData: WeatherData
selectedDate: string
}
export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeatherProps) {
return (
<div className="space-y-6">
{/* Past Date Header */}
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-blue-500 to-indigo-500 p-6">
<div className="text-center text-white">
<p className="text-lg opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
<p className="text-2xl font-bold">{selectedDate}</p>
</div>
</div>
{/* Status Grid */}
<div className="p-4 grid grid-cols-2 gap-4">
<TemperatureCard weatherData={weatherData} isToday={false} />
<PrecipitationHistoricalCard weatherData={weatherData} />
<SunlightCard weatherData={weatherData} />
<WindHumidityCard weatherData={weatherData} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,268 @@
import { Calendar as CalendarIcon, ChevronDown } from 'lucide-react'
import { WeatherData } from '@/features/weather'
import { getWeatherInfo, getPersianDayName } from '@/features/daily-report/utils'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { TemperatureCard, PrecipitationForecastCard, SunlightCard, WindHumidityCard } from './WeatherCards'
import { getGreenhouseAlerts } from '@/features/weather'
type TodayWeatherProps = {
weatherData: WeatherData
expandedDayIndex: number | null
onDayToggle: (index: number | null) => void
}
export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: TodayWeatherProps) {
const alerts = getGreenhouseAlerts(weatherData)
return (
<div className="space-y-6">
{/* Greenhouse Alerts */}
{alerts.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700">🌱 هشدارها و توصیههای گلخانه</h3>
{alerts.map((alert, index) => {
const IconComponent = alert.icon
return (
<div
key={index}
className={`p-4 rounded-xl border-r-4 ${
alert.type === 'danger' ? 'bg-red-50 border-red-500' :
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' :
alert.type === 'info' ? 'bg-blue-50 border-blue-500' :
'bg-green-50 border-green-500'
}`}
>
<div className="flex items-start gap-3">
<IconComponent className={`w-5 h-5 mt-0.5 flex-shrink-0 ${
alert.type === 'danger' ? 'text-red-600' :
alert.type === 'warning' ? 'text-amber-600' :
alert.type === 'info' ? 'text-blue-600' :
'text-green-600'
}`} />
<div className="flex-1">
<p className={`font-semibold text-sm ${
alert.type === 'danger' ? 'text-red-700' :
alert.type === 'warning' ? 'text-amber-700' :
alert.type === 'info' ? 'text-blue-700' :
'text-green-700'
}`}>{alert.title}</p>
<p className="text-sm text-gray-600 mt-1">{alert.description}</p>
</div>
</div>
</div>
)
})}
</div>
)}
{/* Current Weather Header */}
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-6">
<div className="flex items-center justify-between">
<div className="text-white">
<p className="text-lg opacity-90 mb-1">🌡 الان</p>
<div className="flex items-baseline gap-2">
<span className="text-6xl font-bold">
{toPersianDigits(Math.round(weatherData.current.temperature))}
</span>
<span className="text-3xl">درجه</span>
</div>
</div>
<div className="text-center text-white">
{(() => {
const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
return <IconComponent className="w-20 h-20" />
})()}
<p className="text-lg mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
</div>
</div>
</div>
{/* Status Grid */}
<div className="p-4 grid grid-cols-2 gap-4">
<TemperatureCard weatherData={weatherData} isToday={true} />
<PrecipitationForecastCard weatherData={weatherData} />
<SunlightCard weatherData={weatherData} />
<WindHumidityCard weatherData={weatherData} />
</div>
</div>
{/* Hourly Forecast */}
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 p-4">
<h4 className="text-lg font-bold text-white flex items-center gap-2">
🕐 وضعیت ساعت به ساعت امروز
</h4>
</div>
<div className="p-4">
<div className="overflow-x-auto pb-2">
<div className="flex gap-2" style={{ minWidth: 'max-content' }}>
{weatherData.hourly.map((hour) => {
const hourNum = new Date(hour.time).getHours()
const isNow = hourNum === new Date().getHours()
const IconComponent = getWeatherInfo(hour.weatherCode).icon
const isHot = hour.temperature > 35
const isCold = hour.temperature < 10
return (
<div
key={hour.time}
className={`flex flex-col items-center p-3 rounded-xl min-w-[85px] transition-all ${
isNow
? 'bg-gradient-to-b from-emerald-400 to-emerald-500 text-white shadow-lg scale-105'
: isHot
? 'bg-red-50 border border-red-200'
: isCold
? 'bg-blue-50 border border-blue-200'
: 'bg-gray-50 border border-gray-100'
}`}
>
<p className={`text-sm font-bold ${isNow ? 'text-white' : 'text-gray-600'}`}>
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
</p>
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
<IconComponent className={`w-6 h-6 ${
isNow ? 'text-white' :
isHot ? 'text-red-500' :
isCold ? 'text-blue-500' :
'text-gray-500'
}`} />
</div>
<p className={`text-2xl font-bold ${
isNow ? 'text-white' :
isHot ? 'text-red-600' :
isCold ? 'text-blue-600' :
'text-gray-800'
}`}>
{toPersianDigits(Math.round(hour.temperature))}°
</p>
<div className={`flex items-center gap-1 mt-2 text-xs ${isNow ? 'text-white/80' : 'text-blue-500'}`}>
<span>{toPersianDigits(hour.humidity)}%</span>
</div>
{hour.precipitation > 0 && (
<div className={`mt-1 px-2 py-0.5 rounded-full text-xs ${isNow ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-600'}`}>
🌧 {toPersianDigits(hour.precipitation.toFixed(1))}
</div>
)}
</div>
)
})}
</div>
</div>
<p className="text-xs text-gray-400 text-center mt-3">👈 برای دیدن ساعتهای بیشتر به چپ بکشید</p>
</div>
</div>
{/* 7-Day Forecast */}
<div>
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-emerald-500" />
پیشبینی ۷ روز آینده
</h3>
<div className="space-y-2">
{weatherData.daily.map((day, index) => {
const weatherInfo = getWeatherInfo(day.weatherCode)
const IconComponent = weatherInfo.icon
const isToday = index === 0
const hasFrost = day.tempMin < 5
const hasHeat = day.tempMax > 35
const isExpanded = expandedDayIndex === index
return (
<div key={day.date} className="overflow-hidden rounded-xl">
<button
onClick={() => onDayToggle(isExpanded ? null : index)}
className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
isExpanded
? 'bg-emerald-500 text-white'
: isToday
? 'bg-emerald-50 hover:bg-emerald-100'
: hasFrost
? 'bg-blue-50 hover:bg-blue-100'
: hasHeat
? 'bg-red-50 hover:bg-red-100'
: 'bg-gray-50 hover:bg-gray-100'
}`}
>
<div className="flex items-center gap-4 flex-1">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isExpanded ? 'bg-white/20' : 'bg-white'
}`}>
<IconComponent className={`w-6 h-6 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
</div>
<div className="text-right flex-1">
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
{isToday ? 'امروز' : getPersianDayName(day.date)}
</p>
<p className={`text-sm ${isExpanded ? 'text-white/80' : 'text-gray-500'}`}>
{weatherInfo.description}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<p className={`text-2xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
{toPersianDigits(Math.round(day.tempMax))}°
</p>
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
{toPersianDigits(Math.round(day.tempMin))}°
</p>
</div>
<ChevronDown className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
</div>
</button>
{isExpanded && (
<div className="bg-white p-4 border-t border-emerald-100">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">دما</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
<p className="text-sm text-gray-500">حداکثر</p>
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
<p className="text-sm text-gray-500">حداقل</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">بارش</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
<p className="text-sm text-gray-500">احتمال</p>
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
<p className="text-sm text-gray-500">میلیمتر</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
<p className="text-sm text-gray-500">ساعت</p>
<p className="text-xl font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
<p className="text-sm text-gray-500">UV Index</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 font-medium">باد</span>
</div>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
<p className="text-sm text-gray-500">کیلومتر/ساعت</p>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { Thermometer, Sun, CloudRain, Wind } from 'lucide-react'
import { WeatherData } from '@/features/weather'
import { toPersianDigits } from '@/lib/format/persian-digits'
type WeatherCardsProps = {
weatherData: WeatherData
isToday: boolean
}
/**
* Temperature Card - مشترک بین امروز و گذشته
*/
export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
const tempMin = weatherData.daily[0]?.tempMin || 0
const tempMax = weatherData.daily[0]?.tempMax || 0
const isCold = tempMin < 5
const isHot = tempMax > 35
return (
<div className={`rounded-2xl p-4 ${
isCold ? 'bg-blue-100 border-2 border-blue-300' :
isHot ? 'bg-red-100 border-2 border-red-300' :
'bg-green-100 border-2 border-green-300'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
isCold ? 'bg-blue-500' :
isHot ? 'bg-red-500' :
'bg-green-500'
}`}>
<Thermometer className="w-8 h-8 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
<p className={`text-sm ${
isCold ? 'text-blue-600' :
isHot ? 'text-red-600' :
'text-green-600'
}`}>
{isCold ? '❄️ سرد!' :
isHot ? '🔥 گرم!' :
'✅ مناسب'}
</p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-center">
<p className="text-xs text-gray-500">🌙 شب</p>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMin))}°</p>
</div>
<div className="text-3xl text-gray-300"></div>
<div className="text-center">
<p className="text-xs text-gray-500"> روز</p>
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMax))}°</p>
</div>
</div>
</div>
)
}
/**
* Precipitation Card - برای امروز (احتمال بارش)
*/
export function PrecipitationForecastCard({ weatherData }: { weatherData: WeatherData }) {
const probability = weatherData.daily[0]?.precipitationProbability || 0
const isHigh = probability > 60
const isMedium = probability > 30
return (
<div className={`rounded-2xl p-4 ${
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
isMedium ? 'bg-sky-50 border-2 border-sky-200' :
'bg-amber-50 border-2 border-amber-200'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
isHigh ? 'bg-blue-500' :
isMedium ? 'bg-sky-400' :
'bg-amber-400'
}`}>
{isMedium ?
<CloudRain className="w-8 h-8 text-white" /> :
<Sun className="w-8 h-8 text-white" />
}
</div>
<div>
<p className="text-lg font-bold text-gray-800">بارش</p>
<p className={`text-sm ${
isHigh ? 'text-blue-600' :
isMedium ? 'text-sky-600' :
'text-amber-600'
}`}>
{isHigh ? '🌧️ باران می‌آید' :
isMedium ? '🌦️ شاید ببارد' :
'☀️ خشک است'}
</p>
</div>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
<p className="text-sm text-gray-500">احتمال بارش</p>
</div>
</div>
)
}
/**
* Precipitation Card - برای روزهای گذشته (میزان بارش واقعی)
*/
export function PrecipitationHistoricalCard({ weatherData }: { weatherData: WeatherData }) {
const precipitation = weatherData.daily[0]?.precipitation || 0
const isHigh = precipitation > 5
const hasPrecipitation = precipitation > 0
return (
<div className={`rounded-2xl p-4 ${
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
hasPrecipitation ? 'bg-sky-50 border-2 border-sky-200' :
'bg-amber-50 border-2 border-amber-200'
}`}>
<div className="flex items-center gap-3 mb-3">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
isHigh ? 'bg-blue-500' :
hasPrecipitation ? 'bg-sky-400' :
'bg-amber-400'
}`}>
{hasPrecipitation ?
<CloudRain className="w-8 h-8 text-white" /> :
<Sun className="w-8 h-8 text-white" />
}
</div>
<div>
<p className="text-lg font-bold text-gray-800">بارش</p>
<p className={`text-sm ${
isHigh ? 'text-blue-600' :
hasPrecipitation ? 'text-sky-600' :
'text-amber-600'
}`}>
{isHigh ? '🌧️ بارش زیاد' :
hasPrecipitation ? '🌦️ بارش کم' :
'☀️ بدون بارش'}
</p>
</div>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
<p className="text-sm text-gray-500">میلیمتر بارش</p>
</div>
</div>
)
}
/**
* Sunlight Card - مشترک بین امروز و گذشته
*/
export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
const sunshineHours = (weatherData.daily[0]?.sunshineDuration || 0) / 3600
const uvIndex = weatherData.daily[0]?.uvIndexMax || 0
return (
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-2xl p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 rounded-xl bg-yellow-400 flex items-center justify-center">
<Sun className="w-8 h-8 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
<p className="text-sm text-yellow-600">
{sunshineHours > 8 ? '☀️ آفتاب زیاد' :
sunshineHours > 4 ? '🌤️ آفتاب متوسط' :
'☁️ کم‌آفتاب'}
</p>
</div>
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
<p className="text-sm text-gray-500">ساعت آفتاب</p>
</div>
<div className="text-left">
<p className="text-2xl font-bold text-orange-500">{toPersianDigits(Math.round(uvIndex))}</p>
<p className="text-xs text-gray-500">شاخص UV</p>
</div>
</div>
</div>
)
}
/**
* Wind & Humidity Card - مشترک بین امروز و گذشته
*/
export function WindHumidityCard({ weatherData }: { weatherData: WeatherData }) {
const windSpeed = weatherData.daily[0]?.windSpeedMax || 0
const humidity = weatherData.current.humidity
return (
<div className="bg-cyan-50 border-2 border-cyan-200 rounded-2xl p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 rounded-xl bg-cyan-400 flex items-center justify-center">
<Wind className="w-8 h-8 text-white" />
</div>
<div>
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
<p className="text-sm text-cyan-600">
{windSpeed > 40 ? '💨 باد شدید!' :
windSpeed > 20 ? '🍃 وزش باد' :
'😌 آرام'}
</p>
</div>
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(windSpeed))}</p>
<p className="text-xs text-gray-500">کیلومتر/ساعت باد</p>
</div>
<div className="text-left">
<p className="text-3xl font-bold text-blue-500">{toPersianDigits(humidity)}%</p>
<p className="text-xs text-gray-500">رطوبت هوا</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { useRef, KeyboardEvent, ClipboardEvent } from 'react'
import { cn } from '@/lib/utils'
type CodeInputProps = {
length?: number
value: string[]
onChange: (value: string[]) => void
onComplete?: (code: string) => void
disabled?: boolean
className?: string
}
export function CodeInput({
length = 4,
value,
onChange,
onComplete,
disabled = false,
className
}: CodeInputProps) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
const handleCodeChange = (index: number, inputValue: string) => {
if (!/^\d*$/.test(inputValue)) return
const newCode = [...value]
newCode[index] = inputValue.slice(-1)
onChange(newCode)
// Auto-focus next input
if (inputValue && index < length - 1) {
inputRefs.current[index + 1]?.focus()
}
// Auto-submit when all fields are filled
if (newCode.every(c => c !== '') && newCode.join('').length === length) {
onComplete?.(newCode.join(''))
}
}
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !value[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
}
}
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault()
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length)
if (pastedData.length === length) {
const newCode = pastedData.split('')
onChange(newCode)
inputRefs.current[length - 1]?.focus()
onComplete?.(pastedData)
}
}
return (
<div className={cn('flex justify-center gap-3', className)} style={{ direction: 'ltr' }}>
{Array.from({ length }, (_, index) => (
<input
key={index}
ref={(el) => { inputRefs.current[index] = el }}
type="text"
inputMode="numeric"
maxLength={1}
value={value[index] || ''}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={index === 0 ? handlePaste : undefined}
className="w-14 h-14 text-center text-2xl font-bold 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"
disabled={disabled}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { InputHTMLAttributes, LabelHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
label?: string
labelProps?: LabelHTMLAttributes<HTMLLabelElement>
error?: string
helperText?: string
leftAddon?: React.ReactNode
rightAddon?: React.ReactNode
}
export function FormInput({
label,
labelProps,
error,
helperText,
leftAddon,
rightAddon,
className,
id,
...props
}: FormInputProps) {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`
return (
<div className="space-y-2">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700"
{...labelProps}
>
{label}
</label>
)}
<div className="relative">
{leftAddon && (
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-3">
{leftAddon}
</div>
)}
<input
id={inputId}
className={cn(
'w-full px-4 py-3 rounded-xl border-2 transition-all duration-200',
error
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20 bg-red-50'
: 'border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20',
leftAddon ? 'pr-12' : '',
rightAddon ? 'pl-12' : '',
className
)}
{...props}
/>
{rightAddon && (
<div className="absolute left-0 top-0 bottom-0 flex items-center pl-3">
{rightAddon}
</div>
)}
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
{helperText && !error && (
<p className="text-sm text-gray-500">{helperText}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useRef, useEffect, useCallback, InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
type MobileInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onInput' | 'value'> & {
value: string
onValueChange: (value: string) => void
}
export function MobileInput({
value,
onValueChange,
className,
...props
}: MobileInputProps) {
const inputRef = useRef<HTMLInputElement>(null)
const handleInputChange = useCallback((e: React.FormEvent<HTMLInputElement>) => {
const inputValue = e.currentTarget.value
const digitsOnly = inputValue.replace(/\D/g, '')
onValueChange(digitsOnly)
}, [onValueChange])
useEffect(() => {
const checkAutoFill = () => {
setTimeout(() => {
if (inputRef.current) {
const filledMobile = inputRef.current.value
if (filledMobile && filledMobile !== value) {
handleInputChange({ currentTarget: { value: filledMobile } } as React.FormEvent<HTMLInputElement>)
}
}
}, 100)
}
checkAutoFill()
window.addEventListener('load', checkAutoFill)
return () => {
window.removeEventListener('load', checkAutoFill)
}
}, [value, handleInputChange])
return (
<input
ref={inputRef}
type="tel"
inputMode="numeric"
className={cn(
'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 text-left',
className
)}
placeholder="09123456789"
value={value}
onInput={handleInputChange}
maxLength={11}
{...props}
/>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { FormEvent, InputHTMLAttributes } from 'react'
import { Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/common/Button'
type SearchInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onSubmit'> & {
value: string
onValueChange: (value: string) => void
onSubmit?: (value: string) => void
onClear?: () => void
placeholder?: string
showClearButton?: boolean
showSearchButton?: boolean
}
export function SearchInput({
value,
onValueChange,
onSubmit,
onClear,
placeholder = 'جستجو...',
showClearButton = true,
showSearchButton = true,
className,
...props
}: SearchInputProps) {
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
onSubmit?.(value)
}
const handleClear = () => {
onValueChange('')
onClear?.()
}
return (
<form onSubmit={handleSubmit} className={cn('flex gap-2', className)}>
<div className="flex-1 relative">
<Search className="absolute right-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder={placeholder}
className="w-full pr-12 pl-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"
{...props}
/>
{showClearButton && value && (
<button
type="button"
onClick={handleClear}
className="absolute left-4 top-1/2 transform -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 rounded"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{showSearchButton && (
<Button
type="submit"
variant="primary"
icon={Search}
>
جستجو
</Button>
)}
</form>
)
}

View File

@@ -0,0 +1,5 @@
export { MobileInput } from './MobileInput'
export { CodeInput } from './CodeInput'
export { SearchInput } from './SearchInput'
export { FormInput } from './FormInput'

29
src/components/index.ts Normal file
View File

@@ -0,0 +1,29 @@
// Common components
export * from './common'
// Form components
export * from './forms'
// Navigation components
export * from './navigation'
// Card components
export * from './cards'
// Alert components
export * from './alerts'
// Settings components
export * from './settings'
// Calendar components
export * from './calendar'
// Utils components
export * from './utils'
// Other components
export { default as Loading } from './Loading'
export { Skeleton, CardSkeleton } from './Loading'
export { LineChart, Panel } from './Charts'

View File

@@ -0,0 +1,47 @@
import { ChevronRight, ChevronLeft, Calendar as CalendarIcon } from 'lucide-react'
import { Button } from '@/components/common/Button'
import { toPersianDigits } from '@/lib/format/persian-digits'
type DateNavigationProps = {
selectedDate: string
onPrevious: () => void
onNext: () => void
onCalendar: () => void
className?: string
}
export function DateNavigation({
selectedDate,
onPrevious,
onNext,
onCalendar,
className
}: DateNavigationProps) {
return (
<div className={`flex items-center justify-center gap-2 sm:gap-3 mb-4 ${className || ''}`}>
<Button
onClick={onPrevious}
variant="default"
icon={ChevronRight}
responsiveText={{ mobile: 'قبل', desktop: 'روز قبل' }}
/>
<Button
onClick={onCalendar}
variant="primary"
icon={CalendarIcon}
tooltip="کلیک برای انتخاب تاریخ"
className="px-2.5 sm:px-5"
>
<span className="font-semibold">{selectedDate ? toPersianDigits(selectedDate) : 'انتخاب تاریخ'}</span>
</Button>
<Button
onClick={onNext}
variant="default"
icon={ChevronLeft}
iconPosition="right"
responsiveText={{ mobile: 'بعد', desktop: 'روز بعد' }}
/>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { ChevronRight, ChevronLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
type PaginationProps = {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
className?: string
maxVisiblePages?: number
}
export function Pagination({
currentPage,
totalPages,
onPageChange,
className,
maxVisiblePages = 5
}: PaginationProps) {
if (totalPages <= 1) return null
const getVisiblePages = () => {
if (totalPages <= maxVisiblePages) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
if (currentPage <= 3) {
return Array.from({ length: maxVisiblePages }, (_, i) => i + 1)
}
if (currentPage >= totalPages - 2) {
return Array.from({ length: maxVisiblePages }, (_, i) => totalPages - maxVisiblePages + i + 1)
}
return Array.from({ length: maxVisiblePages }, (_, i) => currentPage - 2 + i)
}
const visiblePages = getVisiblePages()
return (
<div className={cn('flex items-center justify-center gap-2', className)}>
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={cn(
'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
)}
>
<ChevronRight className="w-4 h-4" />
قبلی
</button>
<div className="flex items-center gap-1">
{visiblePages.map((pageNum) => (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={cn(
'w-10 h-10 rounded-xl transition-all duration-200',
currentPage === pageNum
? 'bg-gradient-to-r from-green-500 to-green-600 text-white shadow-md'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
)}
>
{pageNum}
</button>
))}
</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={cn(
'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
)}
>
بعدی
<ChevronLeft className="w-4 h-4" />
</button>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { DateNavigation } from './DateNavigation'
export { Pagination } from './Pagination'

View File

@@ -0,0 +1,56 @@
import { FormInput } from '@/components/forms/FormInput'
type SettingsInputGroupProps = {
label: string
minLabel: string
maxLabel: string
minValue: number
maxValue: number
onMinChange: (value: number) => void
onMaxChange: (value: number) => void
minUnit?: string
maxUnit?: string
minStep?: number
maxStep?: number
className?: string
}
export function SettingsInputGroup({
label,
minLabel,
maxLabel,
minValue,
maxValue,
onMinChange,
onMaxChange,
minUnit,
maxUnit,
minStep = 0.1,
maxStep = 0.1,
className
}: SettingsInputGroupProps) {
return (
<div className={className}>
<label className="block text-sm font-medium text-gray-700 mb-4">{label}</label>
<div className="grid grid-cols-2 gap-4">
<FormInput
label={minLabel}
type="number"
step={minStep}
value={minValue}
onChange={(e) => onMinChange(parseFloat(e.target.value) || 0)}
rightAddon={minUnit && <span className="text-sm text-gray-500 font-medium">{minUnit}</span>}
/>
<FormInput
label={maxLabel}
type="number"
step={maxStep}
value={maxValue}
onChange={(e) => onMaxChange(parseFloat(e.target.value) || 0)}
rightAddon={maxUnit && <span className="text-sm text-gray-500 font-medium">{maxUnit}</span>}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { ReactNode } from 'react'
import { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
type SettingsSectionProps = {
icon: LucideIcon
title: string
children: ReactNode
iconGradient?: string
className?: string
}
export function SettingsSection({
icon: Icon,
title,
children,
iconGradient = 'from-gray-500 to-gray-600',
className
}: SettingsSectionProps) {
return (
<div className={cn('space-y-5', className)}>
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
<div className={cn(
'w-10 h-10 bg-gradient-to-br rounded-lg flex items-center justify-center',
iconGradient
)}>
<Icon className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900">
{title}
</h3>
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { SettingsInputGroup } from './SettingsInputGroup'
export { SettingsSection } from './SettingsSection'

View File

@@ -0,0 +1,84 @@
'use client'
import { useState, useCallback } from 'react'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/common/Button'
type ConfirmDialogOptions = {
title?: string
message: string
confirmText?: string
cancelText?: string
variant?: 'default' | 'danger'
}
type ConfirmDialogHook = {
confirm: (options: ConfirmDialogOptions) => Promise<boolean>
}
export function useConfirmDialog(): ConfirmDialogHook {
const [isOpen, setIsOpen] = useState(false)
const [options, setOptions] = useState<ConfirmDialogOptions>({ message: '' })
const [resolve, setResolve] = useState<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmDialogOptions): Promise<boolean> => {
return new Promise((res) => {
setOptions(opts)
setIsOpen(true)
setResolve(() => res)
})
}, [])
const handleConfirm = () => {
setIsOpen(false)
resolve?.(true)
setResolve(null)
}
const handleCancel = () => {
setIsOpen(false)
resolve?.(false)
setResolve(null)
}
const ConfirmDialogComponent = () => (
<Modal
isOpen={isOpen}
onClose={handleCancel}
title={options.title || 'تأیید'}
size="sm"
>
<div className="space-y-4">
<p className="text-gray-700">{options.message}</p>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
onClick={handleCancel}
>
{options.cancelText || 'انصراف'}
</Button>
<Button
variant={options.variant === 'danger' ? 'secondary' : 'primary'}
onClick={handleConfirm}
>
{options.confirmText || 'تأیید'}
</Button>
</div>
</div>
</Modal>
)
return {
confirm,
ConfirmDialog: ConfirmDialogComponent
} as ConfirmDialogHook & { ConfirmDialog: React.ComponentType }
}
// Simple confirm function for direct use (fallback to window.confirm)
export async function confirmDialog(options: ConfirmDialogOptions): Promise<boolean> {
return new Promise((resolve) => {
const result = window.confirm(options.message)
resolve(result)
})
}

View File

@@ -0,0 +1,49 @@
'use client'
import { RotateCcw } from 'lucide-react'
import { cn } from '@/lib/utils'
type ResendButtonProps = {
canResend: boolean
cooldown: number
onResend: () => void
loading?: boolean
className?: string
}
export function ResendButton({
canResend,
cooldown,
onResend,
loading = false,
className
}: ResendButtonProps) {
const formatCooldown = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${String(secs).padStart(2, '0')}`
}
return (
<button
type="button"
onClick={onResend}
disabled={!canResend || loading}
className={cn(
'w-full flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium transition-all duration-200',
canResend && !loading
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md hover:shadow-lg'
: 'bg-gray-100 text-gray-400 cursor-not-allowed',
className
)}
>
<RotateCcw className="w-4 h-4" />
{canResend ? (
'ارسال مجدد کد'
) : (
`ارسال مجدد (${formatCooldown(cooldown)})`
)}
</button>
)
}

View File

@@ -0,0 +1,3 @@
export { ResendButton } from './ResendButton'
export { useConfirmDialog, confirmDialog } from './ConfirmDialog'

View File

@@ -0,0 +1,54 @@
import { ChartConfig } from './types'
import { NormalizedTelemetry } from './types'
export const BASE_CHART_CONFIGS: ChartConfig[] = [
{
key: 'soil',
title: 'رطوبت خاک',
seriesLabel: 'رطوبت خاک (%)',
color: '#16a34a',
bgColor: '#dcfce7',
getValue: (t: NormalizedTelemetry) => t.soil,
yAxisMin: 0,
yAxisMax: 100,
},
{
key: 'hum',
title: 'رطوبت',
seriesLabel: 'رطوبت (%)',
color: '#3b82f6',
bgColor: '#dbeafe',
getValue: (t: NormalizedTelemetry) => t.hum,
yAxisMin: 0,
yAxisMax: 100,
},
{
key: 'temp',
title: 'دما',
seriesLabel: 'دما (°C)',
color: '#ef4444',
bgColor: '#fee2e2',
getValue: (t: NormalizedTelemetry) => t.temp,
yAxisMin: 0,
},
{
key: 'lux',
title: 'نور',
seriesLabel: 'Lux',
color: '#a855f7',
bgColor: '#f3e8ff',
getValue: (t: NormalizedTelemetry) => t.lux,
yAxisMin: 0,
},
{
key: 'gas',
title: 'گاز CO',
seriesLabel: 'CO (ppm)',
color: '#f59e0b',
bgColor: '#fef3c7',
getValue: (t: NormalizedTelemetry) => t.gas,
yAxisMin: 0,
yAxisMax: 100,
},
]

View File

@@ -0,0 +1,58 @@
import { useMemo } from 'react'
import { NormalizedTelemetry, ChartConfig, ChartData } from '../types'
import { DataGap } from '../utils'
import { insertGapsInData, calcMinMax } from '../utils'
import { BASE_CHART_CONFIGS } from '../chart-config'
type UseTelemetryChartsParams = {
filteredTelemetry: NormalizedTelemetry[]
filteredDataGaps: DataGap[]
}
export function useTelemetryCharts({
filteredTelemetry,
filteredDataGaps,
}: UseTelemetryChartsParams) {
const timestamps = useMemo(
() => filteredTelemetry.map(t => t.timestamp),
[filteredTelemetry]
)
const chartLabels = useMemo(
() => filteredTelemetry.map(t => t.label),
[filteredTelemetry]
)
const charts = useMemo(() => {
return BASE_CHART_CONFIGS.map((cfg): ChartData => {
const raw = filteredTelemetry.map(cfg.getValue)
const data = insertGapsInData(raw, timestamps, filteredDataGaps)
let yAxisMin = cfg.yAxisMin
let yAxisMax = cfg.yAxisMax
if (cfg.key === 'temp') {
const mm = calcMinMax(data, 0, 40, 10)
yAxisMin = mm.min
yAxisMax = mm.max
} else if (cfg.key === 'lux') {
const mm = calcMinMax(data, 0, 2000, 1000)
yAxisMin = mm.min
yAxisMax = mm.max
}
return {
...cfg,
data,
yAxisMin,
yAxisMax: yAxisMax ?? yAxisMin,
}
})
}, [filteredTelemetry, timestamps, filteredDataGaps])
return {
charts,
chartLabels,
}
}

View File

@@ -0,0 +1,5 @@
// Daily report feature exports
export * from './types'
export * from './utils'
export * from './chart-config'

View File

@@ -0,0 +1,37 @@
export type TabType = 'summary' | 'charts' | 'weather' | 'analysis'
export const TABS: { value: TabType; label: string }[] = [
{ value: 'summary', label: 'خلاصه' },
{ value: 'charts', label: 'نمودار' },
{ value: 'weather', label: 'آب و هوا' },
{ value: 'analysis', label: 'تحلیل' },
]
export type NormalizedTelemetry = {
minute: number
timestamp: string
label: string
soil: number
temp: number
hum: number
gas: number
lux: number
}
export type ChartConfig = {
key: string
title: string
seriesLabel: string
color: string
bgColor: string
getValue: (t: NormalizedTelemetry) => number
yAxisMin: number
yAxisMax?: number
}
export type ChartData = ChartConfig & {
data: (number | null)[]
yAxisMin: number
yAxisMax: number
}

View File

@@ -1,25 +1,6 @@
import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react' import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react'
import { TelemetryDto } from '@/lib/api'
// Format date to yyyy/MM/dd import { NormalizedTelemetry } from './types'
export function formatPersianDate(year: number, month: number, day: number): string {
const mm = month.toString().padStart(2, '0')
const dd = day.toString().padStart(2, '0')
return `${year}/${mm}/${dd}`
}
// Ensure date string is in yyyy/MM/dd format
export function ensureDateFormat(dateStr: string): string {
const parts = dateStr.split('/')
if (parts.length !== 3) return dateStr
const [year, month, day] = parts.map(Number)
return formatPersianDate(year, month, day)
}
// تابع تبدیل ارقام انگلیسی به فارسی
export function toPersianDigits(num: number | string): string {
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
}
// Weather code to description and icon mapping // Weather code to description and icon mapping
export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = { export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
@@ -169,3 +150,76 @@ export function fillGapsWithNull<T>(
return result return result
} }
// Insert gaps in data (alternative implementation used in charts)
export function insertGapsInData(
data: number[],
timestamps: string[],
gaps: DataGap[]
): (number | null)[] {
if (gaps.length === 0 || data.length < 2) return data
const result: (number | null)[] = []
for (let i = 0; i < data.length; i++) {
result.push(data[i])
if (i < data.length - 1) {
const cur = new Date(timestamps[i])
const next = new Date(timestamps[i + 1])
const curMin = cur.getHours() * 60 + cur.getMinutes()
const nextMin = next.getHours() * 60 + next.getMinutes()
const hasGap = gaps.some(
g => curMin <= g.startMinute && nextMin >= g.endMinute
)
if (hasGap) result.push(null)
}
}
return result
}
// Calculate min/max for chart axes with step rounding
export function calcMinMax(
data: (number | null)[],
defaultMin: number,
defaultMax: number,
step: number
) {
const valid = data.filter((v): v is number => v !== null)
if (!valid.length) return { min: defaultMin, max: defaultMax }
const min = Math.min(...valid)
const max = Math.max(...valid)
return {
min: min < defaultMin ? Math.floor(min / step) * step : defaultMin,
max: max > defaultMax ? Math.ceil(max / step) * step : defaultMax,
}
}
// Normalize telemetry data
export function normalizeTelemetryData(telemetry: TelemetryDto[]): NormalizedTelemetry[] {
return telemetry.map(t => {
const ts = t.serverTimestampUtc || t.timestampUtc
const d = new Date(ts)
const h = d.getHours()
const m = d.getMinutes()
const s = d.getSeconds()
return {
timestamp: ts,
minute: h * 60 + m,
label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`,
soil: Number(t.soilPercent ?? 0),
temp: Number(t.temperatureC ?? 0),
hum: Number(t.humidityPercent ?? 0),
gas: Number(t.gasPPM ?? 0),
lux: Number(t.lux ?? 0),
}
})
}

159
src/features/weather/api.ts Normal file
View File

@@ -0,0 +1,159 @@
"use client"
import { WeatherData } from './types'
import { persianToGregorian } from '@/lib/date/persian-date'
import { QOM_LAT, QOM_LON } from './helpers'
/**
* Fetch location name from coordinates using reverse geocoding
*/
export async function fetchLocationName(latitude: number, longitude: number): Promise<string> {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10&addressdetails=1&accept-language=fa,en`
)
if (!response.ok) {
throw new Error('Failed to fetch location name')
}
const data = await response.json()
// Extract location name from address
const address = data.address || {}
// Try to get the most specific location name
const locationParts: string[] = []
if (address.city || address.town || address.village) {
locationParts.push(address.city || address.town || address.village)
}
if (address.state || address.province) {
locationParts.push(address.state || address.province)
}
if (address.country) {
locationParts.push(address.country)
}
// If we have parts, join them, otherwise use display_name
if (locationParts.length > 0) {
return locationParts.join('، ')
}
// Fallback to display_name
return data.display_name || 'موقعیت نامشخص'
} catch (error) {
console.error('Error fetching location name:', error)
// Fallback to default location name
return 'کهک قم، ایران'
}
}
/**
* Fetch historical weather data for past dates
*/
export async function fetchHistoricalWeather(selectedDate: string): Promise<WeatherData> {
// تبدیل تاریخ شمسی به میلادی
const [year, month, day] = selectedDate.split('/').map(Number)
const gregorianDate = persianToGregorian(year, month, day)
const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD
const response = await fetch(
`https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran`
)
if (!response.ok) {
throw new Error('Failed to fetch historical weather data')
}
const data = await response.json()
// ساختار داده برای روزهای گذشته (بدون current و hourly)
return {
current: {
temperature: data.daily.temperature_2m_max?.[0] || 0,
humidity: 0, // Historical API رطوبت ندارد
windSpeed: data.daily.wind_speed_10m_max?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
},
hourly: [], // برای گذشته hourly نداریم
daily: [{
date: data.daily.time?.[0] || dateStr,
tempMax: data.daily.temperature_2m_max?.[0] || 0,
tempMin: data.daily.temperature_2m_min?.[0] || 0,
weatherCode: data.daily.weather_code?.[0] || 0,
precipitation: data.daily.precipitation_sum?.[0] || 0,
precipitationProbability: 0,
uvIndexMax: 0,
sunshineDuration: data.daily.sunshine_duration?.[0] || 0,
windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0,
}]
}
}
/**
* Fetch forecast weather data for today and future dates
*/
export async function fetchForecastWeather(): Promise<WeatherData> {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max&timezone=Asia/Tehran&forecast_days=7`
)
if (!response.ok) {
throw new Error('Failed to fetch weather data')
}
const data = await response.json()
// Get only today's hourly data (first 24 hours)
const todayHourly = data.hourly.time.slice(0, 24).map((time: string, i: number) => ({
time,
temperature: data.hourly.temperature_2m[i],
humidity: data.hourly.relative_humidity_2m[i],
weatherCode: data.hourly.weather_code[i],
precipitation: data.hourly.precipitation[i],
}))
return {
current: {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
weatherCode: data.current.weather_code,
},
hourly: todayHourly,
daily: data.daily.time.map((date: string, i: number) => ({
date,
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
weatherCode: data.daily.weather_code[i],
precipitation: data.daily.precipitation_sum[i],
precipitationProbability: data.daily.precipitation_probability_max[i],
uvIndexMax: data.daily.uv_index_max[i],
sunshineDuration: data.daily.sunshine_duration[i],
windSpeedMax: data.daily.wind_speed_10m_max[i],
}))
}
}
/**
* Check if a Persian date is today
*/
export function isToday(selectedDate: string): boolean {
try {
const [year, month, day] = selectedDate.split('/').map(Number)
const gregorianDate = persianToGregorian(year, month, day)
const today = new Date()
today.setHours(0, 0, 0, 0)
gregorianDate.setHours(0, 0, 0, 0)
return gregorianDate.getTime() === today.getTime()
} catch (e) {
console.error('Error checking if today:', e)
return false
}
}

View File

@@ -1,10 +1,10 @@
import { Thermometer, Sun, Droplets, Wind, Leaf } from 'lucide-react' import { Thermometer, Sun, Droplets, Wind, Leaf } from 'lucide-react'
import { WeatherData, GreenhouseAlert } from './types' import { WeatherData, GreenhouseAlert } from './types'
import { toPersianDigits } from './utils' import { toPersianDigits } from '@/lib/format/persian-digits'
// Qom coordinates // Kahak Qom coordinates
export const QOM_LAT = 34.6416 export const QOM_LAT = 34.39674800
export const QOM_LON = 50.8746 export const QOM_LON = 50.86594800
// Greenhouse-specific recommendations // Greenhouse-specific recommendations
export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] { export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] {

View File

@@ -0,0 +1,5 @@
// Weather feature exports
export * from './types'
export * from './api'
export * from './helpers'

View File

@@ -1,5 +1,3 @@
export type TabType = 'summary' | 'charts' | 'weather' | 'analysis'
export type WeatherData = { export type WeatherData = {
current: { current: {
temperature: number temperature: number
@@ -34,10 +32,3 @@ export type GreenhouseAlert = {
icon: React.ComponentType<{ className?: string }> icon: React.ComponentType<{ className?: string }>
} }
export const TABS: { value: TabType; label: string }[] = [
{ value: 'summary', label: 'خلاصه' },
{ value: 'charts', label: 'نمودار' },
{ value: 'weather', label: 'آب و هوا' },
{ value: 'analysis', label: 'تحلیل' },
]

24
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
/**
* Hook برای debounce کردن یک مقدار
* @param value - مقداری که باید debounce بشه
* @param delay - تاخیر به میلی‌ثانیه (پیش‌فرض: 300ms)
* @returns مقدار debounced شده
*/
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}

View File

@@ -0,0 +1,73 @@
'use client'
import { useEffect, useRef, useState } from 'react'
export function usePullToRefresh(onRefresh: () => Promise<void>) {
const touchStartY = useRef(0)
const isRefreshing = useRef(false)
const [pullDistance, setPullDistance] = useState(0)
useEffect(() => {
const handleTouchStart = (e: TouchEvent) => {
if (window.scrollY === 0) {
touchStartY.current = e.touches[0].clientY
}
}
const handleTouchMove = (e: TouchEvent) => {
if (window.scrollY === 0 && touchStartY.current > 0) {
const touchY = e.touches[0].clientY
const distance = touchY - touchStartY.current
if (distance > 0 && !isRefreshing.current) {
// Limit pull distance to 150px
const limitedDistance = Math.min(distance, 150)
setPullDistance(limitedDistance)
// Prevent default scrolling when pulling down
if (distance > 10) {
e.preventDefault()
}
}
}
}
const handleTouchEnd = async (e: TouchEvent) => {
if (window.scrollY === 0 && touchStartY.current > 0) {
const touchY = e.changedTouches[0].clientY
const distance = touchY - touchStartY.current
if (distance > 100 && !isRefreshing.current) {
isRefreshing.current = true
setPullDistance(0)
try {
await onRefresh()
} finally {
isRefreshing.current = false
}
} else {
setPullDistance(0)
}
}
touchStartY.current = 0
}
// Only enable on touch devices
if ('ontouchstart' in window) {
window.addEventListener('touchstart', handleTouchStart, { passive: false })
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('touchend', handleTouchEnd)
}
return () => {
if ('ontouchstart' in window) {
window.removeEventListener('touchstart', handleTouchStart)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
}
}
}, [onRefresh])
return { pullDistance, isRefreshing: isRefreshing.current }
}

View File

@@ -1,150 +1,22 @@
"use client" "use client"
export type DeviceDto = { import type {
id: number DeviceDto,
deviceName: string TelemetryDto,
userId: number DeviceSettingsDto,
userName: string SendCodeRequest,
userFamily: string SendCodeResponse,
userMobile: string VerifyCodeRequest,
location: string VerifyCodeResponse,
neshanLocation: string PagedResult,
} DailyReportDto,
AlertConditionDto,
export type TelemetryDto = { CreateAlertConditionDto,
id: number UpdateAlertConditionDto,
deviceId: number } from './types'
timestampUtc: string
temperatureC: number
humidityPercent: number
soilPercent: number
gasPPM: number
lux: number
persianYear: number
persianMonth: number
persianDate: string
deviceName?: string
serverTimestampUtc?: 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 SendCodeRequest = {
mobile: string
}
export type SendCodeResponse = {
success: boolean
message?: string
resendAfterSeconds: number
}
export type VerifyCodeRequest = {
mobile: string
code: string
}
export type VerifyCodeResponse = {
success: boolean
message?: string
token?: string
user?: {
id: number
mobile: string
name: string
family: string
}
}
export type PagedResult<T> = {
items: T[]
totalCount: number
page: number
pageSize: number
}
export type DailyReportDto = {
id: number
deviceId: number
deviceName: string
persianDate: string
analysis: string
recordCount: number
sampledRecordCount: number
totalTokens: number
createdAt: string
fromCache: boolean
}
export type AlertRuleDto = {
id: number
alertConditionId: number
sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4
comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3
value1: number
value2?: number // برای Between و OutOfRange
order: number
}
export type CreateAlertRuleRequest = {
sensorType: 0 | 1 | 2 | 3 | 4
comparisonType: 0 | 1 | 2 | 3
value1: number
value2?: number
order: number
}
export type AlertConditionDto = {
id: number
deviceId: number
deviceName: string
notificationType: 0 | 1 // Call=0, SMS=1
timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2
callCooldownMinutes: number
smsCooldownMinutes: number
isEnabled: boolean
rules: AlertRuleDto[]
createdAt: string
updatedAt: string
}
export type CreateAlertConditionDto = {
deviceId: number
notificationType: 0 | 1
timeType: 0 | 1 | 2
callCooldownMinutes?: number
smsCooldownMinutes?: number
isEnabled: boolean
rules: CreateAlertRuleRequest[]
}
export type UpdateAlertConditionDto = {
id: number
notificationType: 0 | 1
timeType: 0 | 1 | 2
callCooldownMinutes?: number
smsCooldownMinutes?: number
isEnabled: boolean
rules: CreateAlertRuleRequest[]
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
async function http<T>(url: string, init?: RequestInit): Promise<T> { async function http<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } }) const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
if (!res.ok) { if (!res.ok) {
@@ -224,3 +96,4 @@ export const api = {
deleteAlertCondition: (id: number) => deleteAlertCondition: (id: number) =>
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' }) http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
} }

4
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// Re-export API client and types for backward compatibility
export { api } from './client'
export * from './types'

146
src/lib/api/types.ts Normal file
View File

@@ -0,0 +1,146 @@
// API DTOs and Types
export type DeviceDto = {
id: number
deviceName: string
userId: number
userName: string
userFamily: string
userMobile: 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
serverTimestampUtc?: 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 SendCodeRequest = {
mobile: string
}
export type SendCodeResponse = {
success: boolean
message?: string
resendAfterSeconds: number
}
export type VerifyCodeRequest = {
mobile: string
code: string
}
export type VerifyCodeResponse = {
success: boolean
message?: string
token?: string
user?: {
id: number
mobile: string
name: string
family: string
}
}
export type PagedResult<T> = {
items: T[]
totalCount: number
page: number
pageSize: number
}
export type DailyReportDto = {
id: number
deviceId: number
deviceName: string
persianDate: string
analysis: string
recordCount: number
sampledRecordCount: number
totalTokens: number
createdAt: string
fromCache: boolean
}
export type AlertRuleDto = {
id: number
alertConditionId: number
sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4
comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3
value1: number
value2?: number // برای Between و OutOfRange
order: number
}
export type CreateAlertRuleRequest = {
sensorType: 0 | 1 | 2 | 3 | 4
comparisonType: 0 | 1 | 2 | 3
value1: number
value2?: number
order: number
}
export type AlertConditionDto = {
id: number
deviceId: number
deviceName: string
notificationType: 0 | 1 // Call=0, SMS=1
timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2
callCooldownMinutes: number
smsCooldownMinutes: number
isEnabled: boolean
rules: AlertRuleDto[]
createdAt: string
updatedAt: string
}
export type CreateAlertConditionDto = {
deviceId: number
notificationType: 0 | 1
timeType: 0 | 1 | 2
callCooldownMinutes?: number
smsCooldownMinutes?: number
isEnabled: boolean
rules: CreateAlertRuleRequest[]
}
export type UpdateAlertConditionDto = {
id: number
notificationType: 0 | 1
timeType: 0 | 1 | 2
callCooldownMinutes?: number
smsCooldownMinutes?: number
isEnabled: boolean
rules: CreateAlertRuleRequest[]
}

View File

@@ -123,4 +123,5 @@ export function getNextPersianDay(dateStr: string): string | null {
const nextPersian = gregorianToPersian(gregorian) const nextPersian = gregorianToPersian(gregorian)
return formatPersianDateString(nextPersian) return formatPersianDateString(nextPersian)
} }

4
src/lib/format/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// Re-export formatting utilities
export * from './persian-digits'
export * from './persian-date'

View File

@@ -0,0 +1,23 @@
/**
* Persian date formatting utilities
*/
/**
* Format date to yyyy/MM/dd
*/
export function formatPersianDate(year: number, month: number, day: number): string {
const mm = month.toString().padStart(2, '0')
const dd = day.toString().padStart(2, '0')
return `${year}/${mm}/${dd}`
}
/**
* Ensure date string is in yyyy/MM/dd format
*/
export function ensureDateFormat(dateStr: string): string {
const parts = dateStr.split('/')
if (parts.length !== 3) return dateStr
const [year, month, day] = parts.map(Number)
return formatPersianDate(year, month, day)
}

View File

@@ -0,0 +1,12 @@
/**
* Persian digit formatting utilities
*/
/**
* تابع تبدیل ارقام انگلیسی به فارسی
*/
export function toPersianDigits(num: number | string): string {
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
}

7
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Utility function to merge class names
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ')
}

View File

@@ -0,0 +1,51 @@
/**
* محاسبه زمان طلوع و غروب خورشید برای یک موقعیت جغرافیایی
* @param latitude عرض جغرافیایی (درجه)
* @param longitude طول جغرافیایی (درجه)
* @param timezoneOffset افست زمانی از UTC (ساعت)
* @returns اطلاعات طلوع و غروب خورشید
*/
export function calculateSunTimes(
latitude: number = 34.39674800, // کهک قم
longitude: number = 50.86594800,
timezoneOffset: number = 3.5 // ایران
) {
const now = new Date()
const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000)
// محاسبه انحراف خورشید (Solar Declination)
const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10))
// محاسبه زاویه ساعتی طلوع (Hour Angle)
const latRad = latitude * Math.PI / 180
const decRad = declination * Math.PI / 180
const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad)
// در صورتی که خورشید طلوع/غروب می‌کند
if (Math.abs(cosHourAngle) <= 1) {
const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI
// زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق)
const sunriseDecimal = 12 - hourAngle / 15 + (longitude / 15 - timezoneOffset)
const sunsetDecimal = 12 + hourAngle / 15 + (longitude / 15 - timezoneOffset)
// تبدیل به ساعت و دقیقه
const sunriseHour = Math.floor(sunriseDecimal)
const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60)
const sunsetHour = Math.floor(sunsetDecimal)
const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60)
return {
sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal },
sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal }
}
}
// مقادیر پیش‌فرض (برای روزهای قطبی)
return {
sunrise: { hour: 6, minute: 0, decimal: 6 },
sunset: { hour: 18, minute: 0, decimal: 18 }
}
}

Some files were not shown because too many files have changed in this diff Show More