optimization
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 1s
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Failing after 1s
This commit is contained in:
@@ -31,6 +31,8 @@ import {
|
||||
LucideIcon
|
||||
} from 'lucide-react'
|
||||
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 ComparisonType = 0 | 1 | 2 | 3
|
||||
@@ -172,9 +174,14 @@ function AlertSettingsContent() {
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('آیا از حذف این هشدار اطمینان دارید؟')) {
|
||||
return
|
||||
}
|
||||
const confirmed = await confirmDialog({
|
||||
message: 'آیا از حذف این هشدار اطمینان دارید؟',
|
||||
variant: 'danger',
|
||||
confirmText: 'حذف',
|
||||
cancelText: 'انصراف'
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
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="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<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-orange-500 to-red-600 rounded-xl shadow-md">
|
||||
<Bell className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<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>
|
||||
<PageHeader
|
||||
icon={Bell}
|
||||
title="تنظیمات هشدارها"
|
||||
subtitle="مدیریت شرایط و هشدارهای دستگاه"
|
||||
iconGradient="from-orange-500 to-red-600"
|
||||
action={
|
||||
<button
|
||||
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"
|
||||
@@ -360,8 +358,8 @@ function AlertSettingsContent() {
|
||||
<Plus className="w-5 h-5" />
|
||||
افزودن هشدار
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||
@@ -372,17 +370,19 @@ function AlertSettingsContent() {
|
||||
</div>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">هیچ هشداری ثبت نشده است</p>
|
||||
<button
|
||||
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" />
|
||||
افزودن اولین هشدار
|
||||
</button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
message="هیچ هشداری ثبت نشده است"
|
||||
action={
|
||||
<button
|
||||
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" />
|
||||
افزودن اولین هشدار
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{alerts.map((alert) => (
|
||||
@@ -391,28 +391,18 @@ function AlertSettingsContent() {
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Header */}
|
||||
<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">
|
||||
{NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && (
|
||||
<span className="w-4 h-4">
|
||||
{(() => {
|
||||
const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon
|
||||
return <Icon className="w-4 h-4" />
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="warning"
|
||||
icon={NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon}
|
||||
>
|
||||
{getNotificationLabel(alert.notificationType)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
||||
{TIME_TYPES.find(t => t.value === alert.timeType)?.icon && (
|
||||
<span className="w-4 h-4">
|
||||
{(() => {
|
||||
const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon
|
||||
return <Icon className="w-4 h-4" />
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="info"
|
||||
icon={TIME_TYPES.find(t => t.value === alert.timeType)?.icon}
|
||||
>
|
||||
{getTimeTypeLabel(alert.timeType)}
|
||||
</div>
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => toggleActive(alert)}
|
||||
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 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<IconButton
|
||||
icon={Pencil}
|
||||
variant="primary"
|
||||
onClick={() => openEditModal(alert)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
variant="danger"
|
||||
onClick={() => handleDelete(alert.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,43 +467,33 @@ function AlertSettingsContent() {
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<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">
|
||||
{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
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
title={editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
|
||||
size="xl"
|
||||
>
|
||||
|
||||
{/* Modal Body */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Preview - Sticky at top */}
|
||||
<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="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">
|
||||
<Bell className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
{generatePreviewText()}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 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="px-6 py-4">
|
||||
<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">
|
||||
<Bell className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
{generatePreviewText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Notification Type */}
|
||||
<div className="px-6 space-y-6">
|
||||
{/* Notification Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
نوع اطلاعرسانی
|
||||
@@ -605,9 +583,6 @@ function AlertSettingsContent() {
|
||||
<div className="space-y-3">
|
||||
{formData.rules.map((rule, index) => {
|
||||
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 (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
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'
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear } from '@/lib/persian-date'
|
||||
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
||||
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
||||
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 = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
@@ -75,54 +78,36 @@ export default function CalendarPage() {
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به صفحه اصلی
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
||||
<CalendarIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">انتخاب سال و ماه</h1>
|
||||
</div>
|
||||
</div>
|
||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
||||
<PageHeader
|
||||
icon={CalendarIcon}
|
||||
title="انتخاب سال و ماه"
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
/>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
|
||||
<Card padding="lg">
|
||||
{/* Year Selector */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 mb-6">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4 text-gray-500" />
|
||||
انتخاب سال:
|
||||
</label>
|
||||
<select
|
||||
className="px-4 py-2 border-2 border-gray-200 rounded-xl focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all bg-white font-medium"
|
||||
value={year}
|
||||
onChange={e => setYear(Number(e.target.value))}
|
||||
>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<YearSelector
|
||||
years={years}
|
||||
selectedYear={year}
|
||||
onYearChange={setYear}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* 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="flex items-center justify-center gap-6 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-semibold text-green-700">{totalDays}</span> روز دارای داده
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-semibold text-green-700">{totalRecords}</span> رکورد
|
||||
</span>
|
||||
</div>
|
||||
<StatsCard
|
||||
icon={Database}
|
||||
label="روز دارای داده"
|
||||
value={totalDays}
|
||||
/>
|
||||
<StatsCard
|
||||
icon={TrendingUp}
|
||||
label="رکورد"
|
||||
value={totalRecords}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,40 +118,17 @@ export default function CalendarPage() {
|
||||
const isActive = activeMonths.includes(m)
|
||||
const stats = monthDays[m]
|
||||
return (
|
||||
<button
|
||||
<MonthCard
|
||||
key={m}
|
||||
name={name}
|
||||
isActive={isActive}
|
||||
stats={stats}
|
||||
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>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
"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 { api, TelemetryDto, DailyReportDto } from '@/lib/api'
|
||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date'
|
||||
import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react'
|
||||
import { api, TelemetryDto } from '@/lib/api'
|
||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
|
||||
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 Loading from '@/components/Loading'
|
||||
import {
|
||||
SummaryTab,
|
||||
ChartsTab,
|
||||
WeatherTab,
|
||||
AnalysisTab,
|
||||
TABS,
|
||||
TabType,
|
||||
WeatherData,
|
||||
ensureDateFormat,
|
||||
formatPersianDate,
|
||||
QOM_LAT,
|
||||
QOM_LON,
|
||||
detectDataGaps,
|
||||
DataGap
|
||||
} from '@/components/daily-report'
|
||||
import { SummaryTab } from '@/components/daily-report'
|
||||
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
|
||||
import { Tabs, PageHeader, Button } from '@/components/common'
|
||||
import { DateNavigation } from '@/components/navigation'
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
|
||||
|
||||
// Lazy load heavy components
|
||||
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
|
||||
const WeatherTab = lazy(() => import('@/components/daily-report/WeatherTab').then(m => ({ default: m.WeatherTab })))
|
||||
const AnalysisTab = lazy(() => import('@/components/daily-report/AnalysisTab').then(m => ({ default: m.AnalysisTab })))
|
||||
|
||||
function DailyReportContent() {
|
||||
const router = useRouter()
|
||||
@@ -28,15 +26,8 @@ function DailyReportContent() {
|
||||
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('summary')
|
||||
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
|
||||
const [analysisLoading, setAnalysisLoading] = 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 [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
|
||||
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
||||
|
||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
|
||||
@@ -103,156 +94,48 @@ function DailyReportContent() {
|
||||
}
|
||||
}, [deviceId, selectedDate])
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
if (!selectedDate || dailyReport) return
|
||||
|
||||
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}¤t=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])
|
||||
// Pull-to-refresh for mobile
|
||||
usePullToRefresh(loadData)
|
||||
|
||||
useEffect(() => {
|
||||
// Reset states when date or device changes
|
||||
setDailyReport(null)
|
||||
setWeatherData(null)
|
||||
setAnalysisError(null)
|
||||
setWeatherError(null)
|
||||
loadData()
|
||||
}, [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(() => {
|
||||
if (activeTab === 'analysis') {
|
||||
loadAnalysis()
|
||||
if (!selectedDate || loading) {
|
||||
setForecastWeather(null)
|
||||
setForecastWeatherLoading(false)
|
||||
return
|
||||
}
|
||||
}, [activeTab, loadAnalysis])
|
||||
|
||||
// Load weather when switching to weather tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'weather') {
|
||||
loadWeather()
|
||||
const isTodayDate = checkIsToday(selectedDate)
|
||||
|
||||
if (isTodayDate) {
|
||||
// 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(() => {
|
||||
return [...telemetry].sort((a, b) => {
|
||||
@@ -269,141 +152,12 @@ function DailyReportContent() {
|
||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 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
|
||||
const dataGaps = useMemo(() => {
|
||||
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||
return detectDataGaps(timestamps, 30) // 30 minutes threshold
|
||||
}, [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) {
|
||||
return <Loading message="در حال بارگذاری دادهها..." />
|
||||
}
|
||||
@@ -414,13 +168,13 @@ function DailyReportContent() {
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||
<button
|
||||
<Button
|
||||
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>
|
||||
)
|
||||
@@ -430,21 +184,11 @@ function DailyReportContent() {
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<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-indigo-500 to-purple-600 rounded-xl shadow-md">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
گزارش روزانه {selectedDate}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
مشاهده خلاصه و نمودارهای روز
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
title="گزارش روزانه"
|
||||
iconGradient="from-indigo-500 to-purple-600"
|
||||
action={
|
||||
<Link
|
||||
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"
|
||||
@@ -452,161 +196,45 @@ function DailyReportContent() {
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Date Navigation Buttons */}
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<button
|
||||
onClick={goToPreviousDay}
|
||||
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"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
روز قبل
|
||||
</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>
|
||||
{selectedDate && (
|
||||
<DateNavigation
|
||||
selectedDate={selectedDate}
|
||||
onPrevious={goToPreviousDay}
|
||||
onNext={goToNextDay}
|
||||
onCalendar={goToCalendar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||
{/* Segmented Control for Mobile */}
|
||||
<div className="p-3 md:p-6 md:pb-0">
|
||||
<div className="bg-gray-100 rounded-xl p-1 flex md:hidden">
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
<Tabs
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
|
||||
>
|
||||
{{
|
||||
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} />,
|
||||
charts: (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />
|
||||
</Suspense>
|
||||
),
|
||||
weather: selectedDate ? (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری اطلاعات آب و هوا..." />}>
|
||||
<WeatherTab selectedDate={selectedDate} />
|
||||
</Suspense>
|
||||
) : null,
|
||||
analysis: selectedDate ? (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری تحلیل..." />}>
|
||||
<AnalysisTab deviceId={deviceId} selectedDate={selectedDate} />
|
||||
</Suspense>
|
||||
) : null,
|
||||
}}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { useEffect, useState, useMemo, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
|
||||
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
|
||||
import { Calendar as CalendarIcon, Database } from 'lucide-react'
|
||||
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 = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
@@ -72,34 +74,17 @@ function DayDetailsContent() {
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/calendar?deviceId=${deviceId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به تقویم
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
||||
<CalendarIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
{monthNames[month - 1]} {year}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
|
||||
<PageHeader
|
||||
icon={CalendarIcon}
|
||||
title={`${monthNames[month - 1]} ${year}`}
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
/>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
|
||||
<Card className="overflow-hidden" padding="none">
|
||||
{/* Weekday Headers */}
|
||||
<div className="grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
||||
{['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
|
||||
<div key={day} className="p-3 md:p-4 text-sm font-semibold text-gray-700">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<WeekdayHeaders />
|
||||
|
||||
{/* Days Grid */}
|
||||
<div className="grid grid-cols-7 gap-1 p-1 bg-gray-50">
|
||||
@@ -123,41 +108,34 @@ function DayDetailsContent() {
|
||||
const dateStr = `${year}/${monthStr}/${dayStr}`
|
||||
|
||||
return (
|
||||
<button
|
||||
<CalendarDayCell
|
||||
key={day}
|
||||
day={day}
|
||||
hasData={true}
|
||||
recordCount={recordCount}
|
||||
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 {
|
||||
return (
|
||||
<div
|
||||
<CalendarDayCell
|
||||
key={day}
|
||||
className="min-h-[90px] md:min-h-[100px] bg-gray-50 border border-gray-200 rounded-lg p-2 md:p-3 flex items-center justify-center text-gray-400"
|
||||
>
|
||||
<div className="text-sm md:text-base">{day}</div>
|
||||
</div>
|
||||
day={day}
|
||||
hasData={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="mt-6 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-gray-700">
|
||||
<Database className="w-4 h-4 text-green-600" />
|
||||
<span>
|
||||
<span className="font-semibold text-green-700">{items.length}</span> روز دارای داده از{' '}
|
||||
<span className="font-semibold text-green-700">{totalDays}</span> روز ماه
|
||||
</span>
|
||||
</div>
|
||||
<StatsCard
|
||||
icon={Database}
|
||||
label={`روز دارای داده از ${totalDays} روز ماه`}
|
||||
value={items.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react'
|
||||
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
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) {
|
||||
if (typeof window === 'undefined') return null as string | null
|
||||
@@ -93,19 +95,13 @@ export default function DeviceSettingsPage() {
|
||||
|
||||
if (!deviceId) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">شناسه دستگاه مشخص نشده است</div>
|
||||
<Link
|
||||
href="/devices"
|
||||
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به انتخاب دستگاه
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
message="شناسه دستگاه مشخص نشده است"
|
||||
fullPage
|
||||
action={
|
||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,23 +109,12 @@ export default function DeviceSettingsPage() {
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href={`/devices`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
بازگشت به انتخاب دستگاه
|
||||
</Link>
|
||||
<div className="flex items-center 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>
|
||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title={`تنظیمات ${deviceName}`}
|
||||
iconGradient="from-blue-500 to-blue-600"
|
||||
action={
|
||||
<Link
|
||||
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"
|
||||
@@ -137,188 +122,107 @@ export default function DeviceSettingsPage() {
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorMessage message={error} className="mb-6" />}
|
||||
{success && <SuccessMessage message={success} className="mb-6" />}
|
||||
|
||||
{!settings ? (
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 text-center">
|
||||
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<div className="text-lg text-gray-600 mb-6">
|
||||
تنظیمات برای این دستگاه وجود ندارد
|
||||
</div>
|
||||
<button
|
||||
onClick={initializeDefaultSettings}
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
ایجاد تنظیمات پیشفرض
|
||||
</button>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="تنظیمات وجود ندارد"
|
||||
message="تنظیمات برای این دستگاه وجود ندارد"
|
||||
action={
|
||||
<button
|
||||
onClick={initializeDefaultSettings}
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
ایجاد تنظیمات پیشفرض
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div 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">
|
||||
{/* Temperature Settings */}
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-orange-500 rounded-lg flex items-center justify-center">
|
||||
<Thermometer className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
تنظیمات دما
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداکثر دما (°C)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={settings.maxTemperature}
|
||||
onChange={(e) => handleInputChange('maxTemperature', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداقل دما (°C)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={settings.minTemperature}
|
||||
onChange={(e) => handleInputChange('minTemperature', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSection
|
||||
icon={Thermometer}
|
||||
title="تنظیمات دما"
|
||||
iconGradient="from-red-500 to-orange-500"
|
||||
>
|
||||
<SettingsInputGroup
|
||||
label="محدوده دما"
|
||||
minLabel="حداقل دما"
|
||||
maxLabel="حداکثر دما"
|
||||
minValue={settings.minTemperature}
|
||||
maxValue={settings.maxTemperature}
|
||||
onMinChange={(value) => handleInputChange('minTemperature', value)}
|
||||
onMaxChange={(value) => handleInputChange('maxTemperature', value)}
|
||||
minUnit="°C"
|
||||
maxUnit="°C"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Gas Settings */}
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg flex items-center justify-center">
|
||||
<Wind className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
تنظیمات گاز
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداکثر گاز CO (ppm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxGasPPM}
|
||||
onChange={(e) => handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداقل گاز CO (ppm)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.minGasPPM}
|
||||
onChange={(e) => handleInputChange('minGasPPM', parseInt(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSection
|
||||
icon={Wind}
|
||||
title="تنظیمات گاز"
|
||||
iconGradient="from-gray-600 to-gray-700"
|
||||
>
|
||||
<SettingsInputGroup
|
||||
label="محدوده گاز CO"
|
||||
minLabel="حداقل گاز"
|
||||
maxLabel="حداکثر گاز"
|
||||
minValue={settings.minGasPPM}
|
||||
maxValue={settings.maxGasPPM}
|
||||
onMinChange={(value) => handleInputChange('minGasPPM', value)}
|
||||
onMaxChange={(value) => handleInputChange('maxGasPPM', value)}
|
||||
minUnit="ppm"
|
||||
maxUnit="ppm"
|
||||
minStep={1}
|
||||
maxStep={1}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Light Settings */}
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-lg flex items-center justify-center">
|
||||
<Sun className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
تنظیمات نور
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداکثر نور (Lux)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={settings.maxLux}
|
||||
onChange={(e) => handleInputChange('maxLux', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداقل نور (Lux)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={settings.minLux}
|
||||
onChange={(e) => handleInputChange('minLux', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSection
|
||||
icon={Sun}
|
||||
title="تنظیمات نور"
|
||||
iconGradient="from-yellow-500 to-orange-500"
|
||||
>
|
||||
<SettingsInputGroup
|
||||
label="محدوده نور"
|
||||
minLabel="حداقل نور"
|
||||
maxLabel="حداکثر نور"
|
||||
minValue={settings.minLux}
|
||||
maxValue={settings.maxLux}
|
||||
onMinChange={(value) => handleInputChange('minLux', value)}
|
||||
onMaxChange={(value) => handleInputChange('maxLux', value)}
|
||||
minUnit="Lux"
|
||||
maxUnit="Lux"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Humidity Settings */}
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-lg flex items-center justify-center">
|
||||
<Droplets className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
تنظیمات رطوبت هوا
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداکثر رطوبت (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={settings.maxHumidityPercent}
|
||||
onChange={(e) => handleInputChange('maxHumidityPercent', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
حداقل رطوبت (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={settings.minHumidityPercent}
|
||||
onChange={(e) => handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSection
|
||||
icon={Droplets}
|
||||
title="تنظیمات رطوبت هوا"
|
||||
iconGradient="from-blue-500 to-cyan-500"
|
||||
>
|
||||
<SettingsInputGroup
|
||||
label="محدوده رطوبت"
|
||||
minLabel="حداقل رطوبت"
|
||||
maxLabel="حداکثر رطوبت"
|
||||
minValue={settings.minHumidityPercent}
|
||||
maxValue={settings.maxHumidityPercent}
|
||||
onMinChange={(value) => handleInputChange('minHumidityPercent', value)}
|
||||
onMaxChange={(value) => handleInputChange('maxHumidityPercent', value)}
|
||||
minUnit="%"
|
||||
maxUnit="%"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
@@ -345,7 +249,7 @@ export default function DeviceSettingsPage() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api, DeviceDto, PagedResult } from '@/lib/api'
|
||||
import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Settings, LogOut } from 'lucide-react'
|
||||
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() {
|
||||
const router = useRouter()
|
||||
@@ -75,9 +78,10 @@ export default function DevicesPage() {
|
||||
}
|
||||
}, [user, currentPage, searchTerm, fetchDevices])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSearchTerm(searchInput)
|
||||
const handleSearch = (searchValue?: string) => {
|
||||
const valueToUse = searchValue ?? searchInput
|
||||
setSearchTerm(valueToUse)
|
||||
setSearchInput(valueToUse)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
@@ -96,24 +100,19 @@ export default function DevicesPage() {
|
||||
|
||||
if (error && (!pagedResult || pagedResult.items.length === 0)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<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">
|
||||
<Settings 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">{error}</p>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
fullPage
|
||||
action={
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
خروج
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,47 +120,30 @@ export default function DevicesPage() {
|
||||
<div className="min-h-screen p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
|
||||
انتخاب دستگاه
|
||||
</h1>
|
||||
{user && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{user.name} {user.family} ({user.mobile})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title="انتخاب دستگاه"
|
||||
subtitle={user ? `${user.name} ${user.family} (${user.mobile})` : undefined}
|
||||
action={
|
||||
<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"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
خروج
|
||||
</button>
|
||||
}
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<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={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>
|
||||
<SearchInput
|
||||
value={searchInput}
|
||||
onValueChange={setSearchInput}
|
||||
onSubmit={handleSearch}
|
||||
placeholder="جستجو در نام دستگاه، نام صاحب، نام خانوادگی صاحب، موقعیت..."
|
||||
/>
|
||||
{searchTerm && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
@@ -195,113 +177,36 @@ export default function DevicesPage() {
|
||||
{pagedResult.items.map((device) => {
|
||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||
return (
|
||||
<Link
|
||||
<DeviceCard
|
||||
key={device.id}
|
||||
device={device}
|
||||
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>
|
||||
|
||||
{/* Pagination */}
|
||||
{showPagination && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`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">
|
||||
{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>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
className="mt-6"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!loading && (
|
||||
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-8 text-center">
|
||||
<p className="text-gray-600">هیچ دستگاهی یافت نشد</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
message="هیچ دستگاهی یافت نشد"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Back to Home */}
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
بازگشت به صفحه اصلی
|
||||
</Link>
|
||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
|
||||
* {
|
||||
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 {
|
||||
@@ -29,6 +36,27 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
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 {
|
||||
@@ -107,4 +135,25 @@ body {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
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;
|
||||
}
|
||||
@@ -16,8 +16,13 @@ export const metadata: Metadata = {
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false
|
||||
maximumScale: 5, // Allow zoom for accessibility
|
||||
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 }>) {
|
||||
|
||||
@@ -1,44 +1,17 @@
|
||||
"use client"
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Smartphone, ArrowLeft } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Loading from '@/components/Loading'
|
||||
import { MobileInput } from '@/components/forms'
|
||||
import { ErrorMessage } from '@/components/common'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mobile, setMobile] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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 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>
|
||||
<input
|
||||
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"
|
||||
<MobileInput
|
||||
value={mobile}
|
||||
onInput={handleInputChange}
|
||||
onValueChange={(value) => {
|
||||
setMobile(value)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={loading}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{error && <ErrorMessage message={error} />}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { Smartphone, ArrowLeft } from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
import { MobileInput } from '@/components/forms'
|
||||
import { ErrorMessage } from '@/components/common'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
const [mobile, setMobile] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const mobileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is logged in
|
||||
@@ -28,34 +29,6 @@ export default function Home() {
|
||||
}
|
||||
}, [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 digitsOnly = mobile.replace(/\D/g, '')
|
||||
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>
|
||||
<input
|
||||
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"
|
||||
<MobileInput
|
||||
value={mobile}
|
||||
onInput={handleInputChange}
|
||||
onValueChange={(value) => {
|
||||
setMobile(value)
|
||||
setError(null)
|
||||
}}
|
||||
disabled={loading}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{error && <ErrorMessage message={error} />}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -43,18 +43,41 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
(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 {
|
||||
const preloadResponse = await event.preloadResponse;
|
||||
if (preloadResponse) {
|
||||
cache.put(event.request, preloadResponse.clone());
|
||||
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 {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
return await cache.match('/') || new Response('', {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
return await cache.match('/') || new Response('Offline', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
})()
|
||||
@@ -62,15 +85,28 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate for other requests
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cachedResponse = await cache.match(event.request);
|
||||
|
||||
// Return cached version immediately if available
|
||||
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;
|
||||
}
|
||||
|
||||
// If no cache, fetch from network
|
||||
try {
|
||||
const response = await fetch(event.request);
|
||||
|
||||
@@ -85,9 +121,9 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
||||
|
||||
return response;
|
||||
} catch {
|
||||
return new Response('', {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
return new Response('Resource not available offline', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
});
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client"
|
||||
import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
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 Loading from '@/components/Loading'
|
||||
import { CodeInput } from '@/components/forms'
|
||||
import { ErrorMessage } from '@/components/common'
|
||||
import { ResendButton } from '@/components/utils'
|
||||
|
||||
function VerifyCodeContent() {
|
||||
const searchParams = useSearchParams()
|
||||
@@ -16,7 +19,6 @@ function VerifyCodeContent() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [resendCooldown, setResendCooldown] = useState(120)
|
||||
const [canResend, setCanResend] = useState(false)
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
const checkResendStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -54,41 +56,6 @@ function VerifyCodeContent() {
|
||||
return () => clearInterval(interval)
|
||||
}, [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 codeValue = codeToVerify || code.join('')
|
||||
@@ -137,15 +104,12 @@ function VerifyCodeContent() {
|
||||
}
|
||||
} else {
|
||||
setError(result.message || 'کد وارد شده نادرست است')
|
||||
// Clear code inputs
|
||||
setCode(['', '', '', ''])
|
||||
inputRefs.current[0]?.focus()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Error verifying code:', error)
|
||||
setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور')
|
||||
setCode(['', '', '', ''])
|
||||
inputRefs.current[0]?.focus()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -164,9 +128,7 @@ function VerifyCodeContent() {
|
||||
|
||||
if (result.success) {
|
||||
setResendCooldown(result.resendAfterSeconds || 120)
|
||||
// Clear code inputs
|
||||
setCode(['', '', '', ''])
|
||||
inputRefs.current[0]?.focus()
|
||||
} else {
|
||||
setError(result.message || 'خطا در ارسال مجدد کد')
|
||||
setCanResend(true)
|
||||
@@ -215,29 +177,14 @@ function VerifyCodeContent() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleVerify(); }} className="space-y-5">
|
||||
<div className="flex justify-center gap-3" style={{ direction: 'ltr' }}>
|
||||
{code.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => { inputRefs.current[index] = el }}
|
||||
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>
|
||||
<CodeInput
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={handleVerify}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{error && <ErrorMessage message={error} />}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@@ -262,23 +209,12 @@ function VerifyCodeContent() {
|
||||
</button>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={!canResend || loading}
|
||||
className={`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'
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{canResend ? (
|
||||
'ارسال مجدد کد'
|
||||
) : (
|
||||
`ارسال مجدد (${Math.floor(resendCooldown / 60)}:${String(resendCooldown % 60).padStart(2, '0')})`
|
||||
)}
|
||||
</button>
|
||||
<ResendButton
|
||||
canResend={canResend}
|
||||
cooldown={resendCooldown}
|
||||
onResend={handleResend}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
|
||||
Reference in New Issue
Block a user