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:
@@ -2,7 +2,24 @@ import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
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 },
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = 'greenhome-1766074058129';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1766074058129';
|
||||
const CACHE_NAME = 'greenhome-1766173610406';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1766173610406';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_FILES_TO_CACHE = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "1766074058129"
|
||||
"version": "1766173610406"
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -108,3 +136,24 @@ body {
|
||||
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"
|
||||
|
||||
@@ -150,8 +150,8 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||
backgroundColor: s.backgroundColor ?? s.borderColor,
|
||||
fill: s.fill ?? false,
|
||||
tension: 0.3,
|
||||
pointRadius: 1.5,
|
||||
pointHoverRadius: 4,
|
||||
pointRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 2 : 1.5, // Smaller points on mobile
|
||||
pointHoverRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 3 : 4,
|
||||
borderWidth: 2,
|
||||
spanGaps: false // Don't connect points across null values (gaps)
|
||||
}))
|
||||
@@ -159,6 +159,13 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
||||
options={{
|
||||
responsive: 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: {
|
||||
padding: {
|
||||
left: 0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type LoadingProps = {
|
||||
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
100
src/components/README.md
Normal 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'
|
||||
```
|
||||
|
||||
17
src/components/alerts/AlertBadge.tsx
Normal file
17
src/components/alerts/AlertBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
39
src/components/alerts/WeatherAlertBanner.tsx
Normal file
39
src/components/alerts/WeatherAlertBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
3
src/components/alerts/index.ts
Normal file
3
src/components/alerts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { WeatherAlertBanner } from './WeatherAlertBanner'
|
||||
export { AlertBadge } from './AlertBadge'
|
||||
|
||||
27
src/components/calendar/WeekdayHeaders.tsx
Normal file
27
src/components/calendar/WeekdayHeaders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
37
src/components/calendar/YearSelector.tsx
Normal file
37
src/components/calendar/YearSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
3
src/components/calendar/index.ts
Normal file
3
src/components/calendar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { YearSelector } from './YearSelector'
|
||||
export { WeekdayHeaders } from './WeekdayHeaders'
|
||||
|
||||
48
src/components/cards/CalendarDayCell.tsx
Normal file
48
src/components/cards/CalendarDayCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
40
src/components/cards/DeviceCard.tsx
Normal file
40
src/components/cards/DeviceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
53
src/components/cards/MonthCard.tsx
Normal file
53
src/components/cards/MonthCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
32
src/components/cards/StatsCard.tsx
Normal file
32
src/components/cards/StatsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
5
src/components/cards/index.ts
Normal file
5
src/components/cards/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { DeviceCard } from './DeviceCard'
|
||||
export { MonthCard } from './MonthCard'
|
||||
export { CalendarDayCell } from './CalendarDayCell'
|
||||
export { StatsCard } from './StatsCard'
|
||||
|
||||
29
src/components/common/BackLink.tsx
Normal file
29
src/components/common/BackLink.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
47
src/components/common/Badge.tsx
Normal file
47
src/components/common/Badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
114
src/components/common/Button.tsx
Normal file
114
src/components/common/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
35
src/components/common/Card.tsx
Normal file
35
src/components/common/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
72
src/components/common/Dialog.tsx
Normal file
72
src/components/common/Dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
42
src/components/common/EmptyState.tsx
Normal file
42
src/components/common/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
61
src/components/common/ErrorMessage.tsx
Normal file
61
src/components/common/ErrorMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
51
src/components/common/IconButton.tsx
Normal file
51
src/components/common/IconButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
78
src/components/common/Modal.tsx
Normal file
78
src/components/common/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
52
src/components/common/PageHeader.tsx
Normal file
52
src/components/common/PageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/components/common/SegmentTab.tsx
Normal file
26
src/components/common/SegmentTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/components/common/SuccessMessage.tsx
Normal file
33
src/components/common/SuccessMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
62
src/components/common/Tabs.tsx
Normal file
62
src/components/common/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
14
src/components/common/index.ts
Normal file
14
src/components/common/index.ts
Normal 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'
|
||||
|
||||
@@ -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 { DailyReportDto } from '@/lib/api'
|
||||
import { toPersianDigits } from './utils'
|
||||
import { DailyReportDto, api } from '@/lib/api'
|
||||
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 = {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
dailyReport: DailyReportDto | null
|
||||
onRetry: () => void
|
||||
deviceId: number
|
||||
selectedDate: string
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return <Loading message="در حال دریافت تحلیل..." fullScreen={false} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDailyReport(null)
|
||||
loadAnalysis()
|
||||
}}
|
||||
variant="primary"
|
||||
icon={RefreshCw}
|
||||
>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!dailyReport) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<p className="text-gray-600">تحلیلی برای نمایش وجود ندارد</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="تحلیلی موجود نیست"
|
||||
message="تحلیلی برای نمایش وجود ندارد"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,104 +1,106 @@
|
||||
import { useState, useMemo, memo, useCallback } from 'react'
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
import { LineChart, Panel } from '@/components/Charts'
|
||||
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 = {
|
||||
chartStartMinute: number
|
||||
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
|
||||
sortedTelemetry: TelemetryDto[]
|
||||
dataGaps?: DataGap[]
|
||||
}
|
||||
|
||||
export function ChartsTab({
|
||||
chartStartMinute,
|
||||
chartEndMinute,
|
||||
onStartMinuteChange,
|
||||
onEndMinuteChange,
|
||||
labels,
|
||||
soil,
|
||||
humidity,
|
||||
temperature,
|
||||
lux,
|
||||
gas,
|
||||
tempMinMax,
|
||||
luxMinMax,
|
||||
totalRecords,
|
||||
dataGaps = []
|
||||
export const ChartsTab = memo(function ChartsTab({
|
||||
sortedTelemetry,
|
||||
dataGaps = [],
|
||||
}: 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 (
|
||||
<div className="space-y-6">
|
||||
{/* Time Range Selector */}
|
||||
<TimeRangeSelector
|
||||
startMinute={chartStartMinute}
|
||||
endMinute={chartEndMinute}
|
||||
onStartMinuteChange={onStartMinuteChange}
|
||||
onEndMinuteChange={onEndMinuteChange}
|
||||
totalRecords={totalRecords}
|
||||
onStartMinuteChange={handleStartMinuteChange}
|
||||
onEndMinuteChange={handleEndMinuteChange}
|
||||
totalRecords={filteredTelemetry.length}
|
||||
dataGaps={dataGaps}
|
||||
/>
|
||||
|
||||
{/* Charts Grid */}
|
||||
{totalRecords === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<BarChart3 className="w-12 h-12 text-gray-300 mb-4" />
|
||||
<p className="text-gray-600">دادهای برای این بازه زمانی موجود نیست</p>
|
||||
</div>
|
||||
{filteredTelemetry.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="دادهای موجود نیست"
|
||||
message="دادهای برای این بازه زمانی موجود نیست"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Panel title="رطوبت خاک">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت خاک (%)', data: soil as (number | null)[], borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="رطوبت">
|
||||
<LineChart
|
||||
labels={labels}
|
||||
series={[{ label: 'رطوبت (%)', data: humidity as (number | null)[], borderColor: '#3b82f6', backgroundColor: '#dbeafe', fill: true }]}
|
||||
yAxisMin={0}
|
||||
yAxisMax={100}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="دما">
|
||||
<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>
|
||||
{charts.map(chart => (
|
||||
<Panel key={chart.key} title={chart.title}>
|
||||
<LineChart
|
||||
labels={chartLabels}
|
||||
series={[
|
||||
{
|
||||
label: chart.seriesLabel,
|
||||
data: chart.data,
|
||||
borderColor: chart.color,
|
||||
backgroundColor: chart.bgColor,
|
||||
fill: true,
|
||||
},
|
||||
]}
|
||||
yAxisMin={chart.yAxisMin}
|
||||
yAxisMax={chart.yAxisMax}
|
||||
/>
|
||||
</Panel>
|
||||
))}
|
||||
</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
|
||||
})
|
||||
|
||||
226
src/components/daily-report/GreenhouseForecastAlerts.tsx
Normal file
226
src/components/daily-report/GreenhouseForecastAlerts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TrendingUp, TrendingDown } from 'lucide-react'
|
||||
import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges'
|
||||
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 = {
|
||||
param: string
|
||||
|
||||
@@ -1,77 +1,151 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 = {
|
||||
temperature: {
|
||||
current: number
|
||||
min: number
|
||||
max: number
|
||||
data: number[]
|
||||
}
|
||||
humidity: {
|
||||
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[]
|
||||
}
|
||||
temperature: number[]
|
||||
humidity: number[]
|
||||
soil: number[]
|
||||
gas: number[]
|
||||
lux: number[]
|
||||
forecastWeather?: WeatherData | null
|
||||
forecastWeatherLoading?: boolean
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
param="temperature"
|
||||
currentValue={temperature.current}
|
||||
minValue={temperature.min}
|
||||
maxValue={temperature.max}
|
||||
data={temperature.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="humidity"
|
||||
currentValue={humidity.current}
|
||||
minValue={humidity.min}
|
||||
maxValue={humidity.max}
|
||||
data={humidity.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="soil"
|
||||
currentValue={soil.current}
|
||||
minValue={soil.min}
|
||||
maxValue={soil.max}
|
||||
data={soil.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="gas"
|
||||
currentValue={gas.current}
|
||||
minValue={gas.min}
|
||||
maxValue={gas.max}
|
||||
data={gas.data}
|
||||
/>
|
||||
<SummaryCard
|
||||
param="lux"
|
||||
currentValue={lux.current}
|
||||
minValue={lux.min}
|
||||
maxValue={lux.max}
|
||||
data={lux.data}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Greenhouse Forecast Alerts Section */}
|
||||
{forecastWeatherLoading ? (
|
||||
<div className="mb-6">
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">
|
||||
در حال بارگذاری هشدارهای آب و هوایی...
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
لطفاً صبر کنید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : alertsCount > 0 && forecastWeather ? (
|
||||
<WeatherAlertBanner
|
||||
alertsCount={alertsCount}
|
||||
onClick={() => setIsAlertsDialogOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Summary Cards Grid */}
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
param="temperature"
|
||||
currentValue={temperatureSummary.current}
|
||||
minValue={temperatureSummary.min}
|
||||
maxValue={temperatureSummary.max}
|
||||
data={temperature}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { toPersianDigits, DataGap } from './utils'
|
||||
import { useMemo } from 'react'
|
||||
import { DataGap } from '@/features/daily-report/utils'
|
||||
import { calculateSunTimes } from '@/lib/utils/sun-utils'
|
||||
import {
|
||||
TimeRangeHeader,
|
||||
TimelineTrack,
|
||||
TimelineSlider,
|
||||
TimeLabel,
|
||||
TimeRangeInfo,
|
||||
} from './timeline'
|
||||
|
||||
type TimeRangeSelectorProps = {
|
||||
startMinute: number // دقیقه از نیمه شب (0-1439)
|
||||
@@ -10,49 +18,6 @@ type TimeRangeSelectorProps = {
|
||||
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({
|
||||
startMinute,
|
||||
endMinute,
|
||||
@@ -61,205 +26,37 @@ export function TimeRangeSelector({
|
||||
totalRecords,
|
||||
dataGaps = []
|
||||
}: 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 sunrisePercent = ((1439 - (sunrisePosition * 60)) / 1439) * 100
|
||||
const sunsetPercent = ((1439 - (sunsetPosition * 60)) / 1439) * 100
|
||||
const handleReset = () => {
|
||||
onStartMinuteChange(0)
|
||||
onEndMinuteChange(1439) // 23:59
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<TimeRangeHeader dataGaps={dataGaps} onReset={handleReset} />
|
||||
|
||||
{/* Timeline Selector */}
|
||||
<div className="relative h-32 pt-10 mb-4 select-none">
|
||||
{/* Track background */}
|
||||
<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}%` }}
|
||||
></div>
|
||||
<TimelineTrack sunTimes={sunTimes} dataGaps={dataGaps} />
|
||||
|
||||
{/* 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}%` }}
|
||||
></div>
|
||||
<TimeLabel minute={startMinute} variant="start" />
|
||||
<TimeLabel minute={endMinute} variant="end" />
|
||||
|
||||
<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>
|
||||
<TimelineSlider
|
||||
startMinute={startMinute}
|
||||
endMinute={endMinute}
|
||||
onStartMinuteChange={onStartMinuteChange}
|
||||
onEndMinuteChange={onEndMinuteChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info section */}
|
||||
<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(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>
|
||||
<TimeRangeInfo
|
||||
startMinute={startMinute}
|
||||
endMinute={endMinute}
|
||||
totalRecords={totalRecords}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,80 +1,94 @@
|
||||
import { Loader2, AlertCircle, RefreshCw, MapPin, Droplets, Wind, Thermometer, Sun, CloudRain, Calendar as CalendarIcon, ChevronDown } from 'lucide-react'
|
||||
import { WeatherData, toPersianDigits, getWeatherInfo, getPersianDayName, getGreenhouseAlerts } from '.'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, MapPin } from 'lucide-react'
|
||||
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 = {
|
||||
loading: boolean
|
||||
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"
|
||||
selectedDate: string // Persian date in format "yyyy/MM/dd"
|
||||
}
|
||||
|
||||
export function WeatherTab({
|
||||
loading,
|
||||
error,
|
||||
weatherData,
|
||||
onRetry,
|
||||
expandedDayIndex,
|
||||
onDayToggle,
|
||||
selectedDate
|
||||
}: WeatherTabProps) {
|
||||
// Check if selected date is today by comparing Persian dates
|
||||
const isToday = (() => {
|
||||
if (!selectedDate) return true
|
||||
export function WeatherTab({ selectedDate }: WeatherTabProps) {
|
||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
|
||||
const [locationName, setLocationName] = useState<string>('در حال دریافت...')
|
||||
|
||||
const loadWeather = useCallback(async () => {
|
||||
// اگر قبلاً لود شده، دوباره لود نکن
|
||||
if (weatherData) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Get today's Persian date
|
||||
const todayYear = getCurrentPersianYear()
|
||||
const todayMonth = getCurrentPersianMonth()
|
||||
const todayDay = getCurrentPersianDay()
|
||||
const todayPersian = `${todayYear}/${String(todayMonth).padStart(2, '0')}/${String(todayDay).padStart(2, '0')}`
|
||||
const isTodayDate = checkIsToday(selectedDate)
|
||||
|
||||
// Normalize selected date format
|
||||
const [y, m, d] = selectedDate.split('/').map(s => s.trim())
|
||||
const normalizedSelected = `${y}/${String(Number(m)).padStart(2, '0')}/${String(Number(d)).padStart(2, '0')}`
|
||||
// Load weather data and location name in parallel
|
||||
const [weather, location] = await Promise.all([
|
||||
isTodayDate
|
||||
? fetchForecastWeather()
|
||||
: fetchHistoricalWeather(selectedDate),
|
||||
fetchLocationName(QOM_LAT, QOM_LON)
|
||||
])
|
||||
|
||||
return normalizedSelected === todayPersian
|
||||
} catch (e) {
|
||||
console.error('Error checking if today:', e)
|
||||
return true
|
||||
setWeatherData(weather)
|
||||
setLocationName(location)
|
||||
} catch (error) {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return <Loading message="در حال دریافت اطلاعات آب و هوا..." fullScreen={false} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<p className="text-gray-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setWeatherData(null)
|
||||
loadWeather()
|
||||
}}
|
||||
variant="primary"
|
||||
icon={RefreshCw}
|
||||
>
|
||||
تلاش مجدد
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!weatherData) {
|
||||
// Trigger load when component is mounted (user clicked on weather tab)
|
||||
loadWeather()
|
||||
return null
|
||||
}
|
||||
|
||||
const alerts = getGreenhouseAlerts(weatherData)
|
||||
const isTodayDate = checkIsToday(selectedDate)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -82,433 +96,24 @@ export function WeatherTab({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<MapPin className="w-5 h-5 text-sky-500" />
|
||||
<span className="font-medium">قم، ایران</span>
|
||||
<span className="font-medium">{locationName}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isToday ? 'پیشبینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
|
||||
{isTodayDate ? 'پیشبینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Greenhouse Alerts - Only for today */}
|
||||
{isToday && alerts.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">🌱 هشدارها و توصیههای گلخانه</h3>
|
||||
{alerts.map((alert, index) => (
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{isTodayDate ? (
|
||||
<TodayWeather
|
||||
weatherData={weatherData}
|
||||
expandedDayIndex={expandedDayIndex}
|
||||
onDayToggle={setExpandedDayIndex}
|
||||
/>
|
||||
) : (
|
||||
<HistoricalWeather
|
||||
weatherData={weatherData}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Daily report UI components only
|
||||
export { SummaryCard } from './SummaryCard'
|
||||
export { SummaryTab } from './SummaryTab'
|
||||
export { TimeRangeSelector } from './TimeRangeSelector'
|
||||
export { ChartsTab } from './ChartsTab'
|
||||
export { WeatherTab } from './WeatherTab'
|
||||
export { AnalysisTab } from './AnalysisTab'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
export * from './weather-helpers'
|
||||
export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
|
||||
|
||||
|
||||
47
src/components/daily-report/timeline/DataGapMarker.tsx
Normal file
47
src/components/daily-report/timeline/DataGapMarker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
19
src/components/daily-report/timeline/DataGapsOverlay.tsx
Normal file
19
src/components/daily-report/timeline/DataGapsOverlay.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/components/daily-report/timeline/SunTimeLabel.tsx
Normal file
26
src/components/daily-report/timeline/SunTimeLabel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
39
src/components/daily-report/timeline/TimeLabel.tsx
Normal file
39
src/components/daily-report/timeline/TimeLabel.tsx
Normal 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)
|
||||
|
||||
34
src/components/daily-report/timeline/TimeRangeHeader.tsx
Normal file
34
src/components/daily-report/timeline/TimeRangeHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
30
src/components/daily-report/timeline/TimeRangeInfo.tsx
Normal file
30
src/components/daily-report/timeline/TimeRangeInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
25
src/components/daily-report/timeline/TimelineHourMarkers.tsx
Normal file
25
src/components/daily-report/timeline/TimelineHourMarkers.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
109
src/components/daily-report/timeline/TimelineSlider.tsx
Normal file
109
src/components/daily-report/timeline/TimelineSlider.tsx
Normal 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)
|
||||
|
||||
73
src/components/daily-report/timeline/TimelineTrack.tsx
Normal file
73
src/components/daily-report/timeline/TimelineTrack.tsx
Normal 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)
|
||||
10
src/components/daily-report/timeline/index.ts
Normal file
10
src/components/daily-report/timeline/index.ts
Normal 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'
|
||||
|
||||
32
src/components/daily-report/weather/HistoricalWeather.tsx
Normal file
32
src/components/daily-report/weather/HistoricalWeather.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
268
src/components/daily-report/weather/TodayWeather.tsx
Normal file
268
src/components/daily-report/weather/TodayWeather.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
227
src/components/daily-report/weather/WeatherCards.tsx
Normal file
227
src/components/daily-report/weather/WeatherCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
80
src/components/forms/CodeInput.tsx
Normal file
80
src/components/forms/CodeInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
71
src/components/forms/FormInput.tsx
Normal file
71
src/components/forms/FormInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
62
src/components/forms/MobileInput.tsx
Normal file
62
src/components/forms/MobileInput.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
73
src/components/forms/SearchInput.tsx
Normal file
73
src/components/forms/SearchInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
5
src/components/forms/index.ts
Normal file
5
src/components/forms/index.ts
Normal 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
29
src/components/index.ts
Normal 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'
|
||||
|
||||
47
src/components/navigation/DateNavigation.tsx
Normal file
47
src/components/navigation/DateNavigation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
88
src/components/navigation/Pagination.tsx
Normal file
88
src/components/navigation/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
3
src/components/navigation/index.ts
Normal file
3
src/components/navigation/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DateNavigation } from './DateNavigation'
|
||||
export { Pagination } from './Pagination'
|
||||
|
||||
56
src/components/settings/SettingsInputGroup.tsx
Normal file
56
src/components/settings/SettingsInputGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
37
src/components/settings/SettingsSection.tsx
Normal file
37
src/components/settings/SettingsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
3
src/components/settings/index.ts
Normal file
3
src/components/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { SettingsInputGroup } from './SettingsInputGroup'
|
||||
export { SettingsSection } from './SettingsSection'
|
||||
|
||||
84
src/components/utils/ConfirmDialog.tsx
Normal file
84
src/components/utils/ConfirmDialog.tsx
Normal 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)
|
||||
})
|
||||
}
|
||||
|
||||
49
src/components/utils/ResendButton.tsx
Normal file
49
src/components/utils/ResendButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
3
src/components/utils/index.ts
Normal file
3
src/components/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ResendButton } from './ResendButton'
|
||||
export { useConfirmDialog, confirmDialog } from './ConfirmDialog'
|
||||
|
||||
54
src/features/daily-report/chart-config.ts
Normal file
54
src/features/daily-report/chart-config.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
|
||||
58
src/features/daily-report/hooks/useTelemetryCharts.ts
Normal file
58
src/features/daily-report/hooks/useTelemetryCharts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
5
src/features/daily-report/index.ts
Normal file
5
src/features/daily-report/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Daily report feature exports
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
export * from './chart-config'
|
||||
|
||||
37
src/features/daily-report/types.ts
Normal file
37
src/features/daily-report/types.ts
Normal 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
|
||||
}
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react'
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// تابع تبدیل ارقام انگلیسی به فارسی
|
||||
export function toPersianDigits(num: number | string): string {
|
||||
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
|
||||
return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
|
||||
}
|
||||
import { TelemetryDto } from '@/lib/api'
|
||||
import { NormalizedTelemetry } from './types'
|
||||
|
||||
// Weather code to description and icon mapping
|
||||
export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
@@ -169,3 +150,76 @@ export function fillGapsWithNull<T>(
|
||||
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
159
src/features/weather/api.ts
Normal 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}¤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],
|
||||
}))
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Thermometer, Sun, Droplets, Wind, Leaf } from 'lucide-react'
|
||||
import { WeatherData, GreenhouseAlert } from './types'
|
||||
import { toPersianDigits } from './utils'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
|
||||
// Qom coordinates
|
||||
export const QOM_LAT = 34.6416
|
||||
export const QOM_LON = 50.8746
|
||||
// Kahak Qom coordinates
|
||||
export const QOM_LAT = 34.39674800
|
||||
export const QOM_LON = 50.86594800
|
||||
|
||||
// Greenhouse-specific recommendations
|
||||
export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] {
|
||||
5
src/features/weather/index.ts
Normal file
5
src/features/weather/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Weather feature exports
|
||||
export * from './types'
|
||||
export * from './api'
|
||||
export * from './helpers'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export type TabType = 'summary' | 'charts' | 'weather' | 'analysis'
|
||||
|
||||
export type WeatherData = {
|
||||
current: {
|
||||
temperature: number
|
||||
@@ -34,10 +32,3 @@ export type GreenhouseAlert = {
|
||||
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
24
src/hooks/useDebounce.ts
Normal 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
|
||||
}
|
||||
|
||||
73
src/hooks/usePullToRefresh.ts
Normal file
73
src/hooks/usePullToRefresh.ts
Normal 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 }
|
||||
}
|
||||
|
||||
@@ -1,150 +1,22 @@
|
||||
"use client"
|
||||
|
||||
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[]
|
||||
}
|
||||
import type {
|
||||
DeviceDto,
|
||||
TelemetryDto,
|
||||
DeviceSettingsDto,
|
||||
SendCodeRequest,
|
||||
SendCodeResponse,
|
||||
VerifyCodeRequest,
|
||||
VerifyCodeResponse,
|
||||
PagedResult,
|
||||
DailyReportDto,
|
||||
AlertConditionDto,
|
||||
CreateAlertConditionDto,
|
||||
UpdateAlertConditionDto,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
|
||||
|
||||
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
|
||||
if (!res.ok) {
|
||||
@@ -224,3 +96,4 @@ export const api = {
|
||||
deleteAlertCondition: (id: number) =>
|
||||
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
4
src/lib/api/index.ts
Normal file
4
src/lib/api/index.ts
Normal 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
146
src/lib/api/types.ts
Normal 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[]
|
||||
}
|
||||
|
||||
@@ -124,3 +124,4 @@ export function getNextPersianDay(dateStr: string): string | null {
|
||||
|
||||
return formatPersianDateString(nextPersian)
|
||||
}
|
||||
|
||||
4
src/lib/format/index.ts
Normal file
4
src/lib/format/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Re-export formatting utilities
|
||||
export * from './persian-digits'
|
||||
export * from './persian-date'
|
||||
|
||||
23
src/lib/format/persian-date.ts
Normal file
23
src/lib/format/persian-date.ts
Normal 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)
|
||||
}
|
||||
|
||||
12
src/lib/format/persian-digits.ts
Normal file
12
src/lib/format/persian-digits.ts
Normal 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
7
src/lib/utils.ts
Normal 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(' ')
|
||||
}
|
||||
|
||||
51
src/lib/utils/sun-utils.ts
Normal file
51
src/lib/utils/sun-utils.ts
Normal 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
Reference in New Issue
Block a user