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 = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
experimental: {},
|
experimental: {
|
||||||
|
optimizePackageImports: ['lucide-react', 'chart.js', 'react-chartjs-2'],
|
||||||
|
},
|
||||||
|
// Enable compression
|
||||||
|
compress: true,
|
||||||
|
// Optimize images
|
||||||
|
images: {
|
||||||
|
formats: ['image/avif', 'image/webp'],
|
||||||
|
deviceSizes: [640, 750, 828, 1080, 1200],
|
||||||
|
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||||
|
},
|
||||||
|
// Performance optimizations
|
||||||
|
swcMinify: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
// Better mobile experience
|
||||||
|
compiler: {
|
||||||
|
removeConsole: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
turbopack: { root: __dirname },
|
turbopack: { root: __dirname },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_NAME = 'greenhome-1766074058129';
|
const CACHE_NAME = 'greenhome-1766173610406';
|
||||||
const STATIC_CACHE_NAME = 'greenhome-static-1766074058129';
|
const STATIC_CACHE_NAME = 'greenhome-static-1766173610406';
|
||||||
|
|
||||||
// Static assets to cache on install
|
// Static assets to cache on install
|
||||||
const STATIC_FILES_TO_CACHE = [
|
const STATIC_FILES_TO_CACHE = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "1766074058129"
|
"version": "1766173610406"
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
LucideIcon
|
LucideIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
import { PageHeader, EmptyState, Modal, IconButton, Badge } from '@/components/common'
|
||||||
|
import { confirmDialog } from '@/components/utils'
|
||||||
|
|
||||||
type SensorType = 0 | 1 | 2 | 3 | 4
|
type SensorType = 0 | 1 | 2 | 3 | 4
|
||||||
type ComparisonType = 0 | 1 | 2 | 3
|
type ComparisonType = 0 | 1 | 2 | 3
|
||||||
@@ -172,9 +174,14 @@ function AlertSettingsContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!window.confirm('آیا از حذف این هشدار اطمینان دارید؟')) {
|
const confirmed = await confirmDialog({
|
||||||
return
|
message: 'آیا از حذف این هشدار اطمینان دارید؟',
|
||||||
}
|
variant: 'danger',
|
||||||
|
confirmText: 'حذف',
|
||||||
|
cancelText: 'انصراف'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteAlertCondition(id)
|
await api.deleteAlertCondition(id)
|
||||||
@@ -338,21 +345,12 @@ function AlertSettingsContent() {
|
|||||||
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
|
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
icon={Bell}
|
||||||
<div className="flex items-center gap-3">
|
title="تنظیمات هشدارها"
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-xl shadow-md">
|
subtitle="مدیریت شرایط و هشدارهای دستگاه"
|
||||||
<Bell className="w-6 h-6 text-white" />
|
iconGradient="from-orange-500 to-red-600"
|
||||||
</div>
|
action={
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
|
||||||
تنظیمات هشدارها
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
مدیریت شرایط و هشدارهای دستگاه
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={openCreateModal}
|
onClick={openCreateModal}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
||||||
@@ -360,8 +358,8 @@ function AlertSettingsContent() {
|
|||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
افزودن هشدار
|
افزودن هشدار
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Alerts List */}
|
{/* Alerts List */}
|
||||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||||
@@ -372,9 +370,10 @@ function AlertSettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{alerts.length === 0 ? (
|
{alerts.length === 0 ? (
|
||||||
<div className="p-12 text-center">
|
<EmptyState
|
||||||
<AlertTriangle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
icon={AlertTriangle}
|
||||||
<p className="text-gray-500 mb-4">هیچ هشداری ثبت نشده است</p>
|
message="هیچ هشداری ثبت نشده است"
|
||||||
|
action={
|
||||||
<button
|
<button
|
||||||
onClick={openCreateModal}
|
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"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"
|
||||||
@@ -382,7 +381,8 @@ function AlertSettingsContent() {
|
|||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
افزودن اولین هشدار
|
افزودن اولین هشدار
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-gray-200">
|
||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
@@ -391,28 +391,18 @@ function AlertSettingsContent() {
|
|||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">
|
<Badge
|
||||||
{NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon && (
|
variant="warning"
|
||||||
<span className="w-4 h-4">
|
icon={NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)?.icon}
|
||||||
{(() => {
|
>
|
||||||
const Icon = NOTIFICATION_TYPES.find(n => n.value === alert.notificationType)!.icon
|
|
||||||
return <Icon className="w-4 h-4" />
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{getNotificationLabel(alert.notificationType)}
|
{getNotificationLabel(alert.notificationType)}
|
||||||
</div>
|
</Badge>
|
||||||
<div className="flex items-center gap-2 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
<Badge
|
||||||
{TIME_TYPES.find(t => t.value === alert.timeType)?.icon && (
|
variant="info"
|
||||||
<span className="w-4 h-4">
|
icon={TIME_TYPES.find(t => t.value === alert.timeType)?.icon}
|
||||||
{(() => {
|
>
|
||||||
const Icon = TIME_TYPES.find(t => t.value === alert.timeType)!.icon
|
|
||||||
return <Icon className="w-4 h-4" />
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{getTimeTypeLabel(alert.timeType)}
|
{getTimeTypeLabel(alert.timeType)}
|
||||||
</div>
|
</Badge>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleActive(alert)}
|
onClick={() => toggleActive(alert)}
|
||||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
@@ -455,20 +445,18 @@ function AlertSettingsContent() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<IconButton
|
||||||
|
icon={Pencil}
|
||||||
|
variant="primary"
|
||||||
onClick={() => openEditModal(alert)}
|
onClick={() => openEditModal(alert)}
|
||||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
||||||
title="ویرایش"
|
title="ویرایش"
|
||||||
>
|
/>
|
||||||
<Pencil className="w-4 h-4" />
|
<IconButton
|
||||||
</button>
|
icon={Trash2}
|
||||||
<button
|
variant="danger"
|
||||||
onClick={() => handleDelete(alert.id)}
|
onClick={() => handleDelete(alert.id)}
|
||||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
title="حذف"
|
title="حذف"
|
||||||
>
|
/>
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,26 +467,16 @@ function AlertSettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{showModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
isOpen={showModal}
|
||||||
<div className="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
onClose={closeModal}
|
||||||
{/* Modal Header */}
|
title={editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
size="xl"
|
||||||
<h2 className="text-xl font-bold text-gray-900">
|
|
||||||
{editingAlert ? 'ویرایش هشدار' : 'افزودن هشدار جدید'}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={closeModal}
|
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modal Body */}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Preview - Sticky at top */}
|
{/* Preview - Sticky at top */}
|
||||||
<div className="sticky top-[73px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg">
|
<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="px-6 py-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
<div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||||
@@ -514,7 +492,7 @@ function AlertSettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="px-6 space-y-6">
|
||||||
{/* Notification Type */}
|
{/* Notification Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
@@ -605,9 +583,6 @@ function AlertSettingsContent() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{formData.rules.map((rule, index) => {
|
{formData.rules.map((rule, index) => {
|
||||||
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
||||||
const selectedSensor = SENSOR_TYPES.find(s => s.value === rule.sensorType)
|
|
||||||
const SensorIcon = selectedSensor?.icon
|
|
||||||
const selectedComp = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4 space-y-3 relative shadow-sm">
|
<div key={index} className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4 space-y-3 relative shadow-sm">
|
||||||
@@ -761,7 +736,7 @@ function AlertSettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 px-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
@@ -780,9 +755,7 @@ function AlertSettingsContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/persian-date'
|
import { getCurrentPersianYear, getCurrentPersianMonth } from '@/lib/date/persian-date'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
function useQueryParam(name: string) {
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getCurrentPersianYear } from '@/lib/persian-date'
|
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
||||||
import { Calendar as CalendarIcon, ChevronRight, Database, TrendingUp } from 'lucide-react'
|
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
import { PageHeader, BackLink, Card } from '@/components/common'
|
||||||
|
import { YearSelector } from '@/components/calendar'
|
||||||
|
import { MonthCard } from '@/components/cards'
|
||||||
|
import { StatsCard } from '@/components/cards'
|
||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
@@ -75,54 +78,36 @@ export default function CalendarPage() {
|
|||||||
<div className="min-h-screen p-4 md:p-6">
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
||||||
<Link
|
<PageHeader
|
||||||
href="/"
|
icon={CalendarIcon}
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
title="انتخاب سال و ماه"
|
||||||
>
|
iconGradient="from-green-500 to-green-600"
|
||||||
<ChevronRight className="w-4 h-4" />
|
/>
|
||||||
بازگشت به صفحه اصلی
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
|
||||||
<CalendarIcon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">انتخاب سال و ماه</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Card */}
|
{/* Main Card */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
|
<Card padding="lg">
|
||||||
{/* Year Selector */}
|
{/* Year Selector */}
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4 mb-6">
|
<YearSelector
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
years={years}
|
||||||
<CalendarIcon className="w-4 h-4 text-gray-500" />
|
selectedYear={year}
|
||||||
انتخاب سال:
|
onYearChange={setYear}
|
||||||
</label>
|
className="mb-6"
|
||||||
<select
|
/>
|
||||||
className="px-4 py-2 border-2 border-gray-200 rounded-xl focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all bg-white font-medium"
|
|
||||||
value={year}
|
|
||||||
onChange={e => setYear(Number(e.target.value))}
|
|
||||||
>
|
|
||||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 mb-6">
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 mb-6">
|
||||||
<div className="flex items-center justify-center gap-6 flex-wrap">
|
<div className="flex items-center justify-center gap-6 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<StatsCard
|
||||||
<Database className="w-5 h-5 text-green-600" />
|
icon={Database}
|
||||||
<span className="text-sm text-gray-700">
|
label="روز دارای داده"
|
||||||
<span className="font-semibold text-green-700">{totalDays}</span> روز دارای داده
|
value={totalDays}
|
||||||
</span>
|
/>
|
||||||
</div>
|
<StatsCard
|
||||||
<div className="flex items-center gap-2">
|
icon={TrendingUp}
|
||||||
<TrendingUp className="w-5 h-5 text-green-600" />
|
label="رکورد"
|
||||||
<span className="text-sm text-gray-700">
|
value={totalRecords}
|
||||||
<span className="font-semibold text-green-700">{totalRecords}</span> رکورد
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,40 +118,17 @@ export default function CalendarPage() {
|
|||||||
const isActive = activeMonths.includes(m)
|
const isActive = activeMonths.includes(m)
|
||||||
const stats = monthDays[m]
|
const stats = monthDays[m]
|
||||||
return (
|
return (
|
||||||
<button
|
<MonthCard
|
||||||
key={m}
|
key={m}
|
||||||
|
name={name}
|
||||||
|
isActive={isActive}
|
||||||
|
stats={stats}
|
||||||
onClick={() => isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)}
|
onClick={() => isActive && router.replace(`/day-details?deviceId=${deviceId}&year=${year}&month=${m}`)}
|
||||||
disabled={!isActive}
|
/>
|
||||||
className={`group relative rounded-xl border-2 p-5 text-center transition-all duration-300 ${
|
|
||||||
isActive
|
|
||||||
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
|
|
||||||
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`text-lg font-semibold mb-2 ${isActive ? 'text-gray-900' : 'text-gray-400'}`}>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
{isActive && stats ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-full px-3 py-1.5 font-medium">
|
|
||||||
<Database className="w-3 h-3" />
|
|
||||||
{stats.days} روز
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600 mt-2">
|
|
||||||
{stats.records} رکورد
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-gray-400">بدون داده</div>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 rounded-b-xl transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useMemo, useState, useCallback, Suspense } from 'react'
|
import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { api, TelemetryDto, DailyReportDto } from '@/lib/api'
|
import { api, TelemetryDto } from '@/lib/api'
|
||||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/persian-date'
|
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
|
||||||
import { BarChart3, ChevronRight, ChevronLeft, Calendar as CalendarIcon, Bell } from 'lucide-react'
|
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
|
||||||
|
import { TABS, TabType } from '@/features/daily-report'
|
||||||
|
import { detectDataGaps } from '@/features/daily-report/utils'
|
||||||
|
import { BarChart3, Bell, CalendarIcon, ChevronRight } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import {
|
import { SummaryTab } from '@/components/daily-report'
|
||||||
SummaryTab,
|
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
|
||||||
ChartsTab,
|
import { Tabs, PageHeader, Button } from '@/components/common'
|
||||||
WeatherTab,
|
import { DateNavigation } from '@/components/navigation'
|
||||||
AnalysisTab,
|
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
|
||||||
TABS,
|
|
||||||
TabType,
|
// Lazy load heavy components
|
||||||
WeatherData,
|
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
|
||||||
ensureDateFormat,
|
const WeatherTab = lazy(() => import('@/components/daily-report/WeatherTab').then(m => ({ default: m.WeatherTab })))
|
||||||
formatPersianDate,
|
const AnalysisTab = lazy(() => import('@/components/daily-report/AnalysisTab').then(m => ({ default: m.AnalysisTab })))
|
||||||
QOM_LAT,
|
|
||||||
QOM_LON,
|
|
||||||
detectDataGaps,
|
|
||||||
DataGap
|
|
||||||
} from '@/components/daily-report'
|
|
||||||
|
|
||||||
function DailyReportContent() {
|
function DailyReportContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -28,15 +26,8 @@ function DailyReportContent() {
|
|||||||
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
const [telemetry, setTelemetry] = useState<TelemetryDto[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('summary')
|
const [activeTab, setActiveTab] = useState<TabType>('summary')
|
||||||
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
|
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
|
||||||
const [analysisLoading, setAnalysisLoading] = useState(false)
|
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
||||||
const [analysisError, setAnalysisError] = useState<string | null>(null)
|
|
||||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
|
|
||||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
|
||||||
const [weatherError, setWeatherError] = useState<string | null>(null)
|
|
||||||
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
|
|
||||||
const [chartStartMinute, setChartStartMinute] = useState(0) // 00:00
|
|
||||||
const [chartEndMinute, setChartEndMinute] = useState(1439) // 23:59
|
|
||||||
|
|
||||||
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
const deviceId = Number(searchParams.get('deviceId') ?? '1')
|
||||||
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
|
const dateParam = searchParams.get('date') ?? formatPersianDate(getCurrentPersianYear(), getCurrentPersianMonth(), getCurrentPersianDay())
|
||||||
@@ -103,156 +94,48 @@ function DailyReportContent() {
|
|||||||
}
|
}
|
||||||
}, [deviceId, selectedDate])
|
}, [deviceId, selectedDate])
|
||||||
|
|
||||||
const loadAnalysis = useCallback(async () => {
|
// Pull-to-refresh for mobile
|
||||||
if (!selectedDate || dailyReport) return
|
usePullToRefresh(loadData)
|
||||||
|
|
||||||
setAnalysisLoading(true)
|
|
||||||
setAnalysisError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const report = await api.getDailyReport(deviceId, selectedDate)
|
|
||||||
setDailyReport(report)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading analysis:', error)
|
|
||||||
setAnalysisError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
|
|
||||||
} finally {
|
|
||||||
setAnalysisLoading(false)
|
|
||||||
}
|
|
||||||
}, [deviceId, selectedDate, dailyReport])
|
|
||||||
|
|
||||||
const loadWeather = useCallback(async () => {
|
|
||||||
if (weatherData) return
|
|
||||||
|
|
||||||
setWeatherLoading(true)
|
|
||||||
setWeatherError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!selectedDate) {
|
|
||||||
setWeatherError('تاریخ انتخاب نشده است')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// تبدیل تاریخ شمسی به میلادی
|
|
||||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
|
||||||
const gregorianDate = persianToGregorian(year, month, day)
|
|
||||||
|
|
||||||
// بررسی اینکه تاریخ امروز است یا گذشته
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
gregorianDate.setHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
const isPast = gregorianDate.getTime() < today.getTime()
|
|
||||||
|
|
||||||
let weather: WeatherData
|
|
||||||
|
|
||||||
if (isPast) {
|
|
||||||
// استفاده از Historical API برای روزهای گذشته
|
|
||||||
const dateStr = gregorianDate.toISOString().split('T')[0] // YYYY-MM-DD
|
|
||||||
const response = await fetch(
|
|
||||||
`https://archive-api.open-meteo.com/v1/archive?latitude=${QOM_LAT}&longitude=${QOM_LON}&start_date=${dateStr}&end_date=${dateStr}&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch historical weather data')
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
// ساختار داده برای روزهای گذشته (بدون current و hourly)
|
|
||||||
weather = {
|
|
||||||
current: {
|
|
||||||
temperature: data.daily.temperature_2m_max?.[0] || 0,
|
|
||||||
humidity: 0, // Historical API رطوبت ندارد
|
|
||||||
windSpeed: data.daily.wind_speed_10m_max?.[0] || 0,
|
|
||||||
weatherCode: data.daily.weather_code?.[0] || 0,
|
|
||||||
},
|
|
||||||
hourly: [], // برای گذشته hourly نداریم
|
|
||||||
daily: [{
|
|
||||||
date: data.daily.time?.[0] || dateStr,
|
|
||||||
tempMax: data.daily.temperature_2m_max?.[0] || 0,
|
|
||||||
tempMin: data.daily.temperature_2m_min?.[0] || 0,
|
|
||||||
weatherCode: data.daily.weather_code?.[0] || 0,
|
|
||||||
precipitation: data.daily.precipitation_sum?.[0] || 0,
|
|
||||||
precipitationProbability: 0,
|
|
||||||
uvIndexMax: 0,
|
|
||||||
sunshineDuration: data.daily.sunshine_duration?.[0] || 0,
|
|
||||||
windSpeedMax: data.daily.wind_speed_10m_max?.[0] || 0,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// استفاده از Forecast API برای امروز و آینده
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${QOM_LAT}&longitude=${QOM_LON}¤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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset states when date or device changes
|
// Reset states when date or device changes
|
||||||
setDailyReport(null)
|
|
||||||
setWeatherData(null)
|
|
||||||
setAnalysisError(null)
|
|
||||||
setWeatherError(null)
|
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData, deviceId, selectedDate])
|
}, [loadData, deviceId, selectedDate])
|
||||||
|
|
||||||
// Load analysis when switching to analysis tab
|
// Load forecast weather data when selectedDate is today - lazy load after main data is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'analysis') {
|
if (!selectedDate || loading) {
|
||||||
loadAnalysis()
|
setForecastWeather(null)
|
||||||
|
setForecastWeatherLoading(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, [activeTab, loadAnalysis])
|
|
||||||
|
|
||||||
// Load weather when switching to weather tab
|
const isTodayDate = checkIsToday(selectedDate)
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'weather') {
|
if (isTodayDate) {
|
||||||
loadWeather()
|
// Delay fetch to ensure page is fully loaded first
|
||||||
|
setForecastWeatherLoading(true)
|
||||||
|
setForecastWeather(null)
|
||||||
|
|
||||||
|
// Use setTimeout to ensure page is fully rendered before fetching
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchForecastWeather()
|
||||||
|
.then(setForecastWeather)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error loading forecast weather:', error)
|
||||||
|
setForecastWeather(null)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setForecastWeatherLoading(false)
|
||||||
|
})
|
||||||
|
}, 100) // Small delay to ensure page is rendered
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
} else {
|
||||||
|
setForecastWeather(null)
|
||||||
|
setForecastWeatherLoading(false)
|
||||||
}
|
}
|
||||||
}, [activeTab, loadWeather])
|
}, [selectedDate, loading])
|
||||||
|
|
||||||
const sortedTelemetry = useMemo(() => {
|
const sortedTelemetry = useMemo(() => {
|
||||||
return [...telemetry].sort((a, b) => {
|
return [...telemetry].sort((a, b) => {
|
||||||
@@ -269,141 +152,12 @@ function DailyReportContent() {
|
|||||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
||||||
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
||||||
|
|
||||||
// Min/Max calculations (not currently used but kept for potential future use)
|
|
||||||
// const tempMinMax = useMemo(() => {
|
|
||||||
// const min = Math.min(...temp)
|
|
||||||
// const max = Math.max(...temp)
|
|
||||||
// return {
|
|
||||||
// min: min < 0 ? Math.floor(min / 10) * 10 : 0,
|
|
||||||
// max: max > 40 ? Math.floor(max / 10) * 10 : 40
|
|
||||||
// }
|
|
||||||
// }, [temp])
|
|
||||||
|
|
||||||
// const luxMinMax = useMemo(() => {
|
|
||||||
// const max = Math.max(...lux)
|
|
||||||
// return {
|
|
||||||
// min: 0,
|
|
||||||
// max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
|
|
||||||
// }
|
|
||||||
// }, [lux])
|
|
||||||
|
|
||||||
// Detect data gaps in the full day data
|
// Detect data gaps in the full day data
|
||||||
const dataGaps = useMemo(() => {
|
const dataGaps = useMemo(() => {
|
||||||
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||||
return detectDataGaps(timestamps, 30) // 30 minutes threshold
|
return detectDataGaps(timestamps, 30) // 30 minutes threshold
|
||||||
}, [sortedTelemetry])
|
}, [sortedTelemetry])
|
||||||
|
|
||||||
// Filtered telemetry for charts based on minute range
|
|
||||||
const filteredTelemetryForCharts = useMemo(() => {
|
|
||||||
return sortedTelemetry.filter(t => {
|
|
||||||
const timestamp = t.serverTimestampUtc || t.timestampUtc
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const minuteOfDay = date.getHours() * 60 + date.getMinutes()
|
|
||||||
return minuteOfDay >= chartStartMinute && minuteOfDay <= chartEndMinute
|
|
||||||
})
|
|
||||||
}, [sortedTelemetry, chartStartMinute, chartEndMinute])
|
|
||||||
|
|
||||||
// Detect gaps in filtered data
|
|
||||||
const filteredDataGaps = useMemo(() => {
|
|
||||||
const timestamps = filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc)
|
|
||||||
return detectDataGaps(timestamps, 30)
|
|
||||||
}, [filteredTelemetryForCharts])
|
|
||||||
|
|
||||||
// Filtered chart labels
|
|
||||||
const chartLabels = useMemo(() => {
|
|
||||||
return filteredTelemetryForCharts.map(t => {
|
|
||||||
const timestamp = t.serverTimestampUtc || t.timestampUtc
|
|
||||||
const date = new Date(timestamp)
|
|
||||||
const hours = date.getHours().toString().padStart(2, '0')
|
|
||||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
|
||||||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
|
||||||
return `${hours}:${minutes}:${seconds}`
|
|
||||||
})
|
|
||||||
}, [filteredTelemetryForCharts])
|
|
||||||
|
|
||||||
// Helper function to insert nulls for gaps
|
|
||||||
const insertGapsInData = (data: number[], timestamps: string[], gaps: DataGap[]): (number | null)[] => {
|
|
||||||
if (gaps.length === 0 || data.length < 2) return data
|
|
||||||
|
|
||||||
const result: (number | null)[] = []
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
result.push(data[i])
|
|
||||||
|
|
||||||
// Check if there's a gap after this point
|
|
||||||
if (i < data.length - 1) {
|
|
||||||
const currentTime = new Date(timestamps[i])
|
|
||||||
const nextTime = new Date(timestamps[i + 1])
|
|
||||||
const currentMinute = currentTime.getHours() * 60 + currentTime.getMinutes()
|
|
||||||
const nextMinute = nextTime.getHours() * 60 + nextTime.getMinutes()
|
|
||||||
|
|
||||||
// Find if any gap exists between current and next
|
|
||||||
const hasGap = gaps.some(gap =>
|
|
||||||
currentMinute <= gap.startMinute && nextMinute >= gap.endMinute
|
|
||||||
)
|
|
||||||
|
|
||||||
if (hasGap) {
|
|
||||||
result.push(null) // Insert null to break the line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtered data arrays for charts (with gaps as null)
|
|
||||||
const filteredTimestamps = useMemo(() =>
|
|
||||||
filteredTelemetryForCharts.map(t => t.serverTimestampUtc || t.timestampUtc),
|
|
||||||
[filteredTelemetryForCharts]
|
|
||||||
)
|
|
||||||
|
|
||||||
const chartSoil = useMemo(() => {
|
|
||||||
const data = filteredTelemetryForCharts.map(t => Number(t.soilPercent ?? 0))
|
|
||||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
|
||||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
|
||||||
|
|
||||||
const chartTemp = useMemo(() => {
|
|
||||||
const data = filteredTelemetryForCharts.map(t => Number(t.temperatureC ?? 0))
|
|
||||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
|
||||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
|
||||||
|
|
||||||
const chartHum = useMemo(() => {
|
|
||||||
const data = filteredTelemetryForCharts.map(t => Number(t.humidityPercent ?? 0))
|
|
||||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
|
||||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
|
||||||
|
|
||||||
const chartGas = useMemo(() => {
|
|
||||||
const data = filteredTelemetryForCharts.map(t => Number(t.gasPPM ?? 0))
|
|
||||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
|
||||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
|
||||||
|
|
||||||
const chartLux = useMemo(() => {
|
|
||||||
const data = filteredTelemetryForCharts.map(t => Number(t.lux ?? 0))
|
|
||||||
return insertGapsInData(data, filteredTimestamps, filteredDataGaps)
|
|
||||||
}, [filteredTelemetryForCharts, filteredTimestamps, filteredDataGaps])
|
|
||||||
|
|
||||||
// Min/Max calculations for filtered charts (filter out nulls)
|
|
||||||
const chartTempMinMax = useMemo(() => {
|
|
||||||
const validTemps = chartTemp.filter((t): t is number => t !== null)
|
|
||||||
if (validTemps.length === 0) return { min: 0, max: 40 }
|
|
||||||
const min = Math.min(...validTemps)
|
|
||||||
const max = Math.max(...validTemps)
|
|
||||||
return {
|
|
||||||
min: min < 0 ? Math.floor(min / 10) * 10 : 0,
|
|
||||||
max: max > 40 ? Math.floor(max / 10) * 10 : 40
|
|
||||||
}
|
|
||||||
}, [chartTemp])
|
|
||||||
|
|
||||||
const chartLuxMinMax = useMemo(() => {
|
|
||||||
const validLux = chartLux.filter((l): l is number => l !== null)
|
|
||||||
if (validLux.length === 0) return { min: 0, max: 2000 }
|
|
||||||
const max = Math.max(...validLux)
|
|
||||||
return {
|
|
||||||
min: 0,
|
|
||||||
max: max > 2000 ? Math.floor(max / 1000) * 1000 : 2000
|
|
||||||
}
|
|
||||||
}, [chartLux])
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loading message="در حال بارگذاری دادهها..." />
|
return <Loading message="در حال بارگذاری دادهها..." />
|
||||||
}
|
}
|
||||||
@@ -414,13 +168,13 @@ function DailyReportContent() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||||
<button
|
<Button
|
||||||
onClick={goToCalendar}
|
onClick={goToCalendar}
|
||||||
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
variant="outline"
|
||||||
|
icon={ChevronRight}
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
بازگشت به تقویم
|
بازگشت به تقویم
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -430,21 +184,11 @@ function DailyReportContent() {
|
|||||||
<div className="min-h-screen p-4 md:p-6">
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
icon={BarChart3}
|
||||||
<div className="flex items-center gap-3">
|
title="گزارش روزانه"
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-md">
|
iconGradient="from-indigo-500 to-purple-600"
|
||||||
<BarChart3 className="w-6 h-6 text-white" />
|
action={
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
|
||||||
گزارش روزانه {selectedDate}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
مشاهده خلاصه و نمودارهای روز
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/alert-settings?deviceId=${deviceId}`}
|
href={`/alert-settings?deviceId=${deviceId}`}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||||
@@ -452,161 +196,45 @@ function DailyReportContent() {
|
|||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Date Navigation Buttons */}
|
{/* Date Navigation Buttons */}
|
||||||
<div className="flex items-center justify-center gap-3 mb-4">
|
{selectedDate && (
|
||||||
<button
|
<DateNavigation
|
||||||
onClick={goToPreviousDay}
|
selectedDate={selectedDate}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
onPrevious={goToPreviousDay}
|
||||||
>
|
onNext={goToNextDay}
|
||||||
<ChevronRight className="w-5 h-5" />
|
onCalendar={goToCalendar}
|
||||||
روز قبل
|
/>
|
||||||
</button>
|
)}
|
||||||
<button
|
|
||||||
onClick={goToCalendar}
|
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
|
||||||
>
|
|
||||||
<CalendarIcon className="w-5 h-5" />
|
|
||||||
انتخاب تاریخ
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={goToNextDay}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
|
||||||
>
|
|
||||||
روز بعد
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
<Tabs
|
||||||
{/* Segmented Control for Mobile */}
|
tabs={TABS}
|
||||||
<div className="p-3 md:p-6 md:pb-0">
|
activeTab={activeTab}
|
||||||
<div className="bg-gray-100 rounded-xl p-1 flex md:hidden">
|
setActiveTab={setActiveTab}
|
||||||
{TABS.map(tab => (
|
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
|
||||||
<button
|
|
||||||
key={tab.value}
|
|
||||||
onClick={() => setActiveTab(tab.value)}
|
|
||||||
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>
|
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} />,
|
||||||
))}
|
charts: (
|
||||||
</div>
|
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||||
|
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} />
|
||||||
{/* Desktop Tabs */}
|
</Suspense>
|
||||||
<div className="hidden md:flex border-b border-gray-200 -mx-6 -mt-6 mb-6">
|
),
|
||||||
{TABS.map(tab => (
|
weather: selectedDate ? (
|
||||||
<button
|
<Suspense fallback={<Loading message="در حال بارگذاری اطلاعات آب و هوا..." />}>
|
||||||
key={tab.value}
|
<WeatherTab selectedDate={selectedDate} />
|
||||||
onClick={() => setActiveTab(tab.value)}
|
</Suspense>
|
||||||
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
) : null,
|
||||||
activeTab === tab.value
|
analysis: selectedDate ? (
|
||||||
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
|
<Suspense fallback={<Loading message="در حال بارگذاری تحلیل..." />}>
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
<AnalysisTab deviceId={deviceId} selectedDate={selectedDate} />
|
||||||
}`}
|
</Suspense>
|
||||||
>
|
) : null,
|
||||||
{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={{
|
</Tabs>
|
||||||
current: hum.at(-1) ?? 0,
|
|
||||||
min: Math.min(...hum),
|
|
||||||
max: Math.max(...hum),
|
|
||||||
data: hum
|
|
||||||
}}
|
|
||||||
soil={{
|
|
||||||
current: soil.at(-1) ?? 0,
|
|
||||||
min: Math.min(...soil),
|
|
||||||
max: Math.max(...soil),
|
|
||||||
data: soil
|
|
||||||
}}
|
|
||||||
gas={{
|
|
||||||
current: gas.at(-1) ?? 0,
|
|
||||||
min: Math.min(...gas),
|
|
||||||
max: Math.max(...gas),
|
|
||||||
data: gas
|
|
||||||
}}
|
|
||||||
lux={{
|
|
||||||
current: lux.at(-1) ?? 0,
|
|
||||||
min: Math.min(...lux),
|
|
||||||
max: Math.max(...lux),
|
|
||||||
data: lux
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Charts Tab */}
|
|
||||||
{activeTab === 'charts' && (
|
|
||||||
<ChartsTab
|
|
||||||
chartStartMinute={chartStartMinute}
|
|
||||||
chartEndMinute={chartEndMinute}
|
|
||||||
onStartMinuteChange={setChartStartMinute}
|
|
||||||
onEndMinuteChange={setChartEndMinute}
|
|
||||||
labels={chartLabels}
|
|
||||||
soil={chartSoil}
|
|
||||||
humidity={chartHum}
|
|
||||||
temperature={chartTemp}
|
|
||||||
lux={chartLux}
|
|
||||||
gas={chartGas}
|
|
||||||
tempMinMax={chartTempMinMax}
|
|
||||||
luxMinMax={chartLuxMinMax}
|
|
||||||
totalRecords={filteredTelemetryForCharts.length}
|
|
||||||
dataGaps={dataGaps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Weather Tab */}
|
|
||||||
{activeTab === 'weather' && (
|
|
||||||
<WeatherTab
|
|
||||||
loading={weatherLoading}
|
|
||||||
error={weatherError}
|
|
||||||
weatherData={weatherData}
|
|
||||||
onRetry={() => {
|
|
||||||
setWeatherData(null)
|
|
||||||
setWeatherError(null)
|
|
||||||
loadWeather()
|
|
||||||
}}
|
|
||||||
expandedDayIndex={expandedDayIndex}
|
|
||||||
onDayToggle={setExpandedDayIndex}
|
|
||||||
selectedDate={selectedDate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Analysis Tab */}
|
|
||||||
{activeTab === 'analysis' && (
|
|
||||||
<AnalysisTab
|
|
||||||
loading={analysisLoading}
|
|
||||||
error={analysisError}
|
|
||||||
dailyReport={dailyReport}
|
|
||||||
onRetry={() => {
|
|
||||||
setDailyReport(null)
|
|
||||||
setAnalysisError(null)
|
|
||||||
loadAnalysis()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import { useEffect, useState, useMemo, Suspense } from 'react'
|
import { useEffect, useState, useMemo, Suspense } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/persian-date'
|
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
|
||||||
import { Calendar as CalendarIcon, ChevronRight, Database } from 'lucide-react'
|
import { Calendar as CalendarIcon, Database } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
import { PageHeader, BackLink, Card } from '@/components/common'
|
||||||
|
import { WeekdayHeaders } from '@/components/calendar'
|
||||||
|
import { CalendarDayCell, StatsCard } from '@/components/cards'
|
||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
@@ -72,34 +74,17 @@ function DayDetailsContent() {
|
|||||||
<div className="min-h-screen p-4 md:p-6">
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
|
||||||
<Link
|
<PageHeader
|
||||||
href={`/calendar?deviceId=${deviceId}`}
|
icon={CalendarIcon}
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
title={`${monthNames[month - 1]} ${year}`}
|
||||||
>
|
iconGradient="from-green-500 to-green-600"
|
||||||
<ChevronRight className="w-4 h-4" />
|
/>
|
||||||
بازگشت به تقویم
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
|
||||||
<CalendarIcon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
|
||||||
{monthNames[month - 1]} {year}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendar Grid */}
|
{/* Calendar Grid */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
|
<Card className="overflow-hidden" padding="none">
|
||||||
{/* Weekday Headers */}
|
{/* Weekday Headers */}
|
||||||
<div className="grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200">
|
<WeekdayHeaders />
|
||||||
{['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'].map(day => (
|
|
||||||
<div key={day} className="p-3 md:p-4 text-sm font-semibold text-gray-700">
|
|
||||||
{day}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Days Grid */}
|
{/* Days Grid */}
|
||||||
<div className="grid grid-cols-7 gap-1 p-1 bg-gray-50">
|
<div className="grid grid-cols-7 gap-1 p-1 bg-gray-50">
|
||||||
@@ -123,41 +108,34 @@ function DayDetailsContent() {
|
|||||||
const dateStr = `${year}/${monthStr}/${dayStr}`
|
const dateStr = `${year}/${monthStr}/${dayStr}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<CalendarDayCell
|
||||||
key={day}
|
key={day}
|
||||||
|
day={day}
|
||||||
|
hasData={true}
|
||||||
|
recordCount={recordCount}
|
||||||
onClick={() => router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)}
|
onClick={() => router.replace(`/daily-report?deviceId=${deviceId}&date=${encodeURIComponent(dateStr)}`)}
|
||||||
className="group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md"
|
/>
|
||||||
>
|
|
||||||
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
|
|
||||||
<div className="flex items-center gap-1 bg-gradient-to-r from-green-500 to-green-600 text-white text-xs rounded-full px-2.5 py-1 font-medium">
|
|
||||||
<Database className="w-3 h-3" />
|
|
||||||
{recordCount}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div
|
<CalendarDayCell
|
||||||
key={day}
|
key={day}
|
||||||
className="min-h-[90px] md:min-h-[100px] bg-gray-50 border border-gray-200 rounded-lg p-2 md:p-3 flex items-center justify-center text-gray-400"
|
day={day}
|
||||||
>
|
hasData={false}
|
||||||
<div className="text-sm md:text-base">{day}</div>
|
/>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="mt-6 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
|
<div className="mt-6 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-xl p-4 text-center">
|
||||||
<div className="flex items-center justify-center gap-2 text-sm text-gray-700">
|
<StatsCard
|
||||||
<Database className="w-4 h-4 text-green-600" />
|
icon={Database}
|
||||||
<span>
|
label={`روز دارای داده از ${totalDays} روز ماه`}
|
||||||
<span className="font-semibold text-green-700">{items.length}</span> روز دارای داده از{' '}
|
value={items.length}
|
||||||
<span className="font-semibold text-green-700">{totalDays}</span> روز ماه
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||||
import { Settings, ChevronRight, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, CheckCircle2, Bell } from 'lucide-react'
|
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
import { PageHeader, BackLink, ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common'
|
||||||
|
import { SettingsSection, SettingsInputGroup } from '@/components/settings'
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
function useQueryParam(name: string) {
|
||||||
if (typeof window === 'undefined') return null as string | null
|
if (typeof window === 'undefined') return null as string | null
|
||||||
@@ -93,19 +95,13 @@ export default function DeviceSettingsPage() {
|
|||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<ErrorMessage
|
||||||
<div className="text-center">
|
message="شناسه دستگاه مشخص نشده است"
|
||||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
fullPage
|
||||||
<div className="text-lg text-red-600 mb-4">شناسه دستگاه مشخص نشده است</div>
|
action={
|
||||||
<Link
|
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
||||||
href="/devices"
|
}
|
||||||
className="inline-flex items-center gap-2 border border-gray-300 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
/>
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
بازگشت به انتخاب دستگاه
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,23 +109,12 @@ export default function DeviceSettingsPage() {
|
|||||||
<div className="min-h-screen p-4 md:p-6">
|
<div className="min-h-screen p-4 md:p-6">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
||||||
<Link
|
<PageHeader
|
||||||
href={`/devices`}
|
icon={Settings}
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
title={`تنظیمات ${deviceName}`}
|
||||||
>
|
iconGradient="from-blue-500 to-blue-600"
|
||||||
<ChevronRight className="w-4 h-4" />
|
action={
|
||||||
بازگشت به انتخاب دستگاه
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-md">
|
|
||||||
<Settings className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
|
||||||
تنظیمات {deviceName}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/alert-settings?deviceId=${deviceId}`}
|
href={`/alert-settings?deviceId=${deviceId}`}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||||
@@ -137,188 +122,107 @@ export default function DeviceSettingsPage() {
|
|||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{error && (
|
{error && <ErrorMessage message={error} className="mb-6" />}
|
||||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 flex items-center gap-2">
|
{success && <SuccessMessage message={success} className="mb-6" />}
|
||||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!settings ? (
|
{!settings ? (
|
||||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 text-center">
|
<EmptyState
|
||||||
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
icon={AlertCircle}
|
||||||
<div className="text-lg text-gray-600 mb-6">
|
title="تنظیمات وجود ندارد"
|
||||||
تنظیمات برای این دستگاه وجود ندارد
|
message="تنظیمات برای این دستگاه وجود ندارد"
|
||||||
</div>
|
action={
|
||||||
<button
|
<button
|
||||||
onClick={initializeDefaultSettings}
|
onClick={initializeDefaultSettings}
|
||||||
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
|
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white px-8 py-3 rounded-xl transition-all shadow-md hover:shadow-lg font-medium"
|
||||||
>
|
>
|
||||||
ایجاد تنظیمات پیشفرض
|
ایجاد تنظیمات پیشفرض
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 md:p-8">
|
<Card padding="lg">
|
||||||
<div className="grid gap-8 md:grid-cols-2">
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
{/* Temperature Settings */}
|
{/* Temperature Settings */}
|
||||||
<div className="space-y-5">
|
<SettingsSection
|
||||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
icon={Thermometer}
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-red-500 to-orange-500 rounded-lg flex items-center justify-center">
|
title="تنظیمات دما"
|
||||||
<Thermometer className="w-5 h-5 text-white" />
|
iconGradient="from-red-500 to-orange-500"
|
||||||
</div>
|
>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
<SettingsInputGroup
|
||||||
تنظیمات دما
|
label="محدوده دما"
|
||||||
</h3>
|
minLabel="حداقل دما"
|
||||||
</div>
|
maxLabel="حداکثر دما"
|
||||||
|
minValue={settings.minTemperature}
|
||||||
<div>
|
maxValue={settings.maxTemperature}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
onMinChange={(value) => handleInputChange('minTemperature', value)}
|
||||||
حداکثر دما (°C)
|
onMaxChange={(value) => handleInputChange('maxTemperature', value)}
|
||||||
</label>
|
minUnit="°C"
|
||||||
<input
|
maxUnit="°C"
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
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>
|
</SettingsSection>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
حداقل دما (°C)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.minTemperature}
|
|
||||||
onChange={(e) => handleInputChange('minTemperature', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-all bg-gray-50 focus:bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gas Settings */}
|
{/* Gas Settings */}
|
||||||
<div className="space-y-5">
|
<SettingsSection
|
||||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
icon={Wind}
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-gray-600 to-gray-700 rounded-lg flex items-center justify-center">
|
title="تنظیمات گاز"
|
||||||
<Wind className="w-5 h-5 text-white" />
|
iconGradient="from-gray-600 to-gray-700"
|
||||||
</div>
|
>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
<SettingsInputGroup
|
||||||
تنظیمات گاز
|
label="محدوده گاز CO"
|
||||||
</h3>
|
minLabel="حداقل گاز"
|
||||||
</div>
|
maxLabel="حداکثر گاز"
|
||||||
|
minValue={settings.minGasPPM}
|
||||||
<div>
|
maxValue={settings.maxGasPPM}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
onMinChange={(value) => handleInputChange('minGasPPM', value)}
|
||||||
حداکثر گاز CO (ppm)
|
onMaxChange={(value) => handleInputChange('maxGasPPM', value)}
|
||||||
</label>
|
minUnit="ppm"
|
||||||
<input
|
maxUnit="ppm"
|
||||||
type="number"
|
minStep={1}
|
||||||
value={settings.maxGasPPM}
|
maxStep={1}
|
||||||
onChange={(e) => handleInputChange('maxGasPPM', parseInt(e.target.value) || 0)}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsSection>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
حداقل گاز CO (ppm)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={settings.minGasPPM}
|
|
||||||
onChange={(e) => handleInputChange('minGasPPM', parseInt(e.target.value) || 0)}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-600/20 transition-all bg-gray-50 focus:bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Light Settings */}
|
{/* Light Settings */}
|
||||||
<div className="space-y-5">
|
<SettingsSection
|
||||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
icon={Sun}
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-yellow-500 to-orange-500 rounded-lg flex items-center justify-center">
|
title="تنظیمات نور"
|
||||||
<Sun className="w-5 h-5 text-white" />
|
iconGradient="from-yellow-500 to-orange-500"
|
||||||
</div>
|
>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
<SettingsInputGroup
|
||||||
تنظیمات نور
|
label="محدوده نور"
|
||||||
</h3>
|
minLabel="حداقل نور"
|
||||||
</div>
|
maxLabel="حداکثر نور"
|
||||||
|
minValue={settings.minLux}
|
||||||
<div>
|
maxValue={settings.maxLux}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
onMinChange={(value) => handleInputChange('minLux', value)}
|
||||||
حداکثر نور (Lux)
|
onMaxChange={(value) => handleInputChange('maxLux', value)}
|
||||||
</label>
|
minUnit="Lux"
|
||||||
<input
|
maxUnit="Lux"
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
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>
|
</SettingsSection>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
حداقل نور (Lux)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.minLux}
|
|
||||||
onChange={(e) => handleInputChange('minLux', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500/20 transition-all bg-gray-50 focus:bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Humidity Settings */}
|
{/* Humidity Settings */}
|
||||||
<div className="space-y-5">
|
<SettingsSection
|
||||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
icon={Droplets}
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-lg flex items-center justify-center">
|
title="تنظیمات رطوبت هوا"
|
||||||
<Droplets className="w-5 h-5 text-white" />
|
iconGradient="from-blue-500 to-cyan-500"
|
||||||
</div>
|
>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
<SettingsInputGroup
|
||||||
تنظیمات رطوبت هوا
|
label="محدوده رطوبت"
|
||||||
</h3>
|
minLabel="حداقل رطوبت"
|
||||||
</div>
|
maxLabel="حداکثر رطوبت"
|
||||||
|
minValue={settings.minHumidityPercent}
|
||||||
<div>
|
maxValue={settings.maxHumidityPercent}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
onMinChange={(value) => handleInputChange('minHumidityPercent', value)}
|
||||||
حداکثر رطوبت (%)
|
onMaxChange={(value) => handleInputChange('maxHumidityPercent', value)}
|
||||||
</label>
|
minUnit="%"
|
||||||
<input
|
maxUnit="%"
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
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>
|
</SettingsSection>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
حداقل رطوبت (%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
value={settings.minHumidityPercent}
|
|
||||||
onChange={(e) => handleInputChange('minHumidityPercent', parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all bg-gray-50 focus:bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
@@ -345,7 +249,7 @@ export default function DeviceSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { api, DeviceDto, PagedResult } from '@/lib/api'
|
import { api, DeviceDto, PagedResult } from '@/lib/api'
|
||||||
import { Settings, Calendar, LogOut, ArrowRight, Search, ChevronRight, ChevronLeft } from 'lucide-react'
|
import { Settings, LogOut } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
|
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
|
||||||
|
import { PageHeader, ErrorMessage, EmptyState, BackLink } from '@/components/common'
|
||||||
|
import { SearchInput } from '@/components/forms'
|
||||||
|
import { DeviceCard } from '@/components/cards'
|
||||||
|
import { Pagination } from '@/components/navigation'
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -75,9 +78,10 @@ export default function DevicesPage() {
|
|||||||
}
|
}
|
||||||
}, [user, currentPage, searchTerm, fetchDevices])
|
}, [user, currentPage, searchTerm, fetchDevices])
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (searchValue?: string) => {
|
||||||
e.preventDefault()
|
const valueToUse = searchValue ?? searchInput
|
||||||
setSearchTerm(searchInput)
|
setSearchTerm(valueToUse)
|
||||||
|
setSearchInput(valueToUse)
|
||||||
setCurrentPage(1)
|
setCurrentPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,14 +100,10 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
if (error && (!pagedResult || pagedResult.items.length === 0)) {
|
if (error && (!pagedResult || pagedResult.items.length === 0)) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<ErrorMessage
|
||||||
<div className="w-full max-w-md">
|
message={error}
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100 text-center">
|
fullPage
|
||||||
<div className="text-red-600 mb-4">
|
action={
|
||||||
<Settings className="w-16 h-16 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-2">خطا</h2>
|
|
||||||
<p className="text-gray-600 mb-6">{error}</p>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
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"
|
||||||
@@ -111,9 +111,8 @@ export default function DevicesPage() {
|
|||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="w-5 h-5" />
|
||||||
خروج
|
خروج
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,17 +120,11 @@ export default function DevicesPage() {
|
|||||||
<div className="min-h-screen p-4 md:p-8">
|
<div className="min-h-screen p-4 md:p-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6 flex items-center justify-between flex-wrap gap-4">
|
<PageHeader
|
||||||
<div>
|
icon={Settings}
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 mb-2">
|
title="انتخاب دستگاه"
|
||||||
انتخاب دستگاه
|
subtitle={user ? `${user.name} ${user.family} (${user.mobile})` : undefined}
|
||||||
</h1>
|
action={
|
||||||
{user && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{user.name} {user.family} ({user.mobile})
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
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"
|
||||||
@@ -139,29 +132,18 @@ export default function DevicesPage() {
|
|||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
خروج
|
خروج
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
iconGradient="from-green-500 to-green-600"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
<SearchInput
|
||||||
<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}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onValueChange={setSearchInput}
|
||||||
|
onSubmit={handleSearch}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5" />
|
|
||||||
جستجو
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
@@ -195,113 +177,36 @@ export default function DevicesPage() {
|
|||||||
{pagedResult.items.map((device) => {
|
{pagedResult.items.map((device) => {
|
||||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||||
return (
|
return (
|
||||||
<Link
|
<DeviceCard
|
||||||
key={device.id}
|
key={device.id}
|
||||||
|
device={device}
|
||||||
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
|
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
|
||||||
className="group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative"
|
/>
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex-shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
|
|
||||||
<Settings className="w-7 h-7 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
|
||||||
{device.deviceName}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 mb-2">
|
|
||||||
{device.location || 'بدون موقعیت'}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
|
||||||
<span>{device.userName} {device.userFamily}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{showPagination && (
|
{showPagination && (
|
||||||
<div className="flex items-center justify-center gap-2 mt-6">
|
<Pagination
|
||||||
<button
|
currentPage={currentPage}
|
||||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
totalPages={totalPages}
|
||||||
disabled={currentPage === 1}
|
onPageChange={setCurrentPage}
|
||||||
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2 ${
|
className="mt-6"
|
||||||
currentPage === 1
|
/>
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
قبلی
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
||||||
let pageNum: number
|
|
||||||
if (totalPages <= 5) {
|
|
||||||
pageNum = i + 1
|
|
||||||
} else if (currentPage <= 3) {
|
|
||||||
pageNum = i + 1
|
|
||||||
} else if (currentPage >= totalPages - 2) {
|
|
||||||
pageNum = totalPages - 4 + i
|
|
||||||
} else {
|
|
||||||
pageNum = currentPage - 2 + i
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={pageNum}
|
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
|
||||||
className={`w-10 h-10 rounded-xl transition-all duration-200 ${
|
|
||||||
currentPage === pageNum
|
|
||||||
? 'bg-gradient-to-r from-green-500 to-green-600 text-white shadow-md'
|
|
||||||
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pageNum}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className={`px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2 ${
|
|
||||||
currentPage === totalPages
|
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
بعدی
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
!loading && (
|
!loading && (
|
||||||
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-8 text-center">
|
<EmptyState
|
||||||
<p className="text-gray-600">هیچ دستگاهی یافت نشد</p>
|
message="هیچ دستگاهی یافت نشد"
|
||||||
</div>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Back to Home */}
|
{/* Back to Home */}
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<Link
|
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
بازگشت به صفحه اصلی
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,13 @@
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -29,6 +36,27 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
overscroll-behavior-y: contain; /* Prevent pull-to-refresh on Android */
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better touch targets (minimum 44x44px for mobile) */
|
||||||
|
button, a, [role="button"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
touch-action: manipulation; /* Disable double-tap zoom */
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hardware acceleration for animations */
|
||||||
|
.transition-all,
|
||||||
|
.transition-colors,
|
||||||
|
.transition-transform,
|
||||||
|
.transition-opacity {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
transform: translateZ(0); /* Force GPU acceleration */
|
||||||
}
|
}
|
||||||
|
|
||||||
.persian-number {
|
.persian-number {
|
||||||
@@ -108,3 +136,24 @@ body {
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
margin: 1.5em 0;
|
margin: 1.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading skeleton animation */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -1000px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 1000px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#f0f0f0 0%,
|
||||||
|
#e0e0e0 50%,
|
||||||
|
#f0f0f0 100%
|
||||||
|
);
|
||||||
|
background-size: 2000px 100%;
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
@@ -16,8 +16,13 @@ export const metadata: Metadata = {
|
|||||||
export const viewport = {
|
export const viewport = {
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 5, // Allow zoom for accessibility
|
||||||
userScalable: false
|
userScalable: true, // Better for accessibility
|
||||||
|
viewportFit: 'cover', // For notch support
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: '#16a34a' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: '#16a34a' },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
|||||||
@@ -1,44 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Smartphone, ArrowLeft } from 'lucide-react'
|
import { Smartphone, ArrowLeft } from 'lucide-react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
import { MobileInput } from '@/components/forms'
|
||||||
|
import { ErrorMessage } from '@/components/common'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [mobile, setMobile] = useState('')
|
const [mobile, setMobile] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const mobileRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkAutoFill = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (mobileRef.current) {
|
|
||||||
const filledMobile = mobileRef.current.value
|
|
||||||
if (filledMobile && filledMobile !== mobile) {
|
|
||||||
setMobile(filledMobile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAutoFill()
|
|
||||||
window.addEventListener('load', checkAutoFill)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('load', checkAutoFill)
|
|
||||||
}
|
|
||||||
}, [mobile])
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.FormEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.currentTarget.value
|
|
||||||
// Only allow digits
|
|
||||||
const digitsOnly = value.replace(/\D/g, '')
|
|
||||||
setMobile(digitsOnly)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeMobile = (mobile: string): string => {
|
const normalizeMobile = (mobile: string): string => {
|
||||||
const digitsOnly = mobile.replace(/\D/g, '')
|
const digitsOnly = mobile.replace(/\D/g, '')
|
||||||
@@ -111,24 +84,17 @@ export default function LoginPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
شماره موبایل
|
شماره موبایل
|
||||||
</label>
|
</label>
|
||||||
<input
|
<MobileInput
|
||||||
ref={mobileRef}
|
|
||||||
type="tel"
|
|
||||||
inputMode="numeric"
|
|
||||||
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200 text-left"
|
|
||||||
placeholder="09123456789"
|
|
||||||
value={mobile}
|
value={mobile}
|
||||||
onInput={handleInputChange}
|
onValueChange={(value) => {
|
||||||
|
setMobile(value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
maxLength={11}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage message={error} />}
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Smartphone, ArrowLeft } from 'lucide-react'
|
import { Smartphone, ArrowLeft } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
import { MobileInput } from '@/components/forms'
|
||||||
|
import { ErrorMessage } from '@/components/common'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [mobile, setMobile] = useState('')
|
const [mobile, setMobile] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const mobileRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
@@ -28,34 +29,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkAutoFill = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (mobileRef.current) {
|
|
||||||
const filledMobile = mobileRef.current.value
|
|
||||||
if (filledMobile && filledMobile !== mobile) {
|
|
||||||
setMobile(filledMobile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAutoFill()
|
|
||||||
window.addEventListener('load', checkAutoFill)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('load', checkAutoFill)
|
|
||||||
}
|
|
||||||
}, [mobile])
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.FormEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.currentTarget.value
|
|
||||||
// Only allow digits
|
|
||||||
const digitsOnly = value.replace(/\D/g, '')
|
|
||||||
setMobile(digitsOnly)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeMobile = (mobile: string): string => {
|
const normalizeMobile = (mobile: string): string => {
|
||||||
const digitsOnly = mobile.replace(/\D/g, '')
|
const digitsOnly = mobile.replace(/\D/g, '')
|
||||||
if (digitsOnly.startsWith('9') && digitsOnly.length === 10) {
|
if (digitsOnly.startsWith('9') && digitsOnly.length === 10) {
|
||||||
@@ -127,24 +100,17 @@ export default function Home() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
شماره موبایل
|
شماره موبایل
|
||||||
</label>
|
</label>
|
||||||
<input
|
<MobileInput
|
||||||
ref={mobileRef}
|
|
||||||
type="tel"
|
|
||||||
inputMode="numeric"
|
|
||||||
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200 text-left"
|
|
||||||
placeholder="09123456789"
|
|
||||||
value={mobile}
|
value={mobile}
|
||||||
onInput={handleInputChange}
|
onValueChange={(value) => {
|
||||||
|
setMobile(value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
maxLength={11}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage message={error} />}
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -43,18 +43,41 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
|||||||
if (event.request.mode === 'navigate') {
|
if (event.request.mode === 'navigate') {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(event.request);
|
||||||
|
|
||||||
|
// Return cached version immediately (fast)
|
||||||
|
if (cachedResponse) {
|
||||||
|
// Update cache in background (stale-while-revalidate)
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(event.request, response.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore network errors in background update
|
||||||
|
});
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no cache, fetch from network
|
||||||
try {
|
try {
|
||||||
const preloadResponse = await event.preloadResponse;
|
const preloadResponse = await event.preloadResponse;
|
||||||
if (preloadResponse) {
|
if (preloadResponse) {
|
||||||
|
cache.put(event.request, preloadResponse.clone());
|
||||||
return preloadResponse;
|
return preloadResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetch(event.request);
|
const networkResponse = await fetch(event.request);
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
cache.put(event.request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
} catch {
|
} catch {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
return await cache.match('/') || new Response('Offline', {
|
||||||
return await cache.match('/') || new Response('', {
|
status: 503,
|
||||||
status: 404,
|
statusText: 'Service Unavailable',
|
||||||
statusText: 'Not Found',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -62,15 +85,28 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stale-while-revalidate for other requests
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
(async () => {
|
(async () => {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
const cachedResponse = await cache.match(event.request);
|
const cachedResponse = await cache.match(event.request);
|
||||||
|
|
||||||
|
// Return cached version immediately if available
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
|
// Update cache in background
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok && response.type === 'basic') {
|
||||||
|
cache.put(event.request, response.clone());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore network errors in background update
|
||||||
|
});
|
||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no cache, fetch from network
|
||||||
try {
|
try {
|
||||||
const response = await fetch(event.request);
|
const response = await fetch(event.request);
|
||||||
|
|
||||||
@@ -85,9 +121,9 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch {
|
} catch {
|
||||||
return new Response('', {
|
return new Response('Resource not available offline', {
|
||||||
status: 404,
|
status: 503,
|
||||||
statusText: 'Not Found',
|
statusText: 'Service Unavailable',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
|
import { useState, useEffect, useCallback, Suspense } from 'react'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Shield, ArrowRight, ArrowLeft, RotateCcw } from 'lucide-react'
|
import { Shield, ArrowRight, ArrowLeft } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
|
import { CodeInput } from '@/components/forms'
|
||||||
|
import { ErrorMessage } from '@/components/common'
|
||||||
|
import { ResendButton } from '@/components/utils'
|
||||||
|
|
||||||
function VerifyCodeContent() {
|
function VerifyCodeContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@@ -16,7 +19,6 @@ function VerifyCodeContent() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [resendCooldown, setResendCooldown] = useState(120)
|
const [resendCooldown, setResendCooldown] = useState(120)
|
||||||
const [canResend, setCanResend] = useState(false)
|
const [canResend, setCanResend] = useState(false)
|
||||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
|
|
||||||
|
|
||||||
const checkResendStatus = useCallback(async () => {
|
const checkResendStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -54,41 +56,6 @@ function VerifyCodeContent() {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [mobile, router, checkResendStatus])
|
}, [mobile, router, checkResendStatus])
|
||||||
|
|
||||||
const handleCodeChange = (index: number, value: string) => {
|
|
||||||
if (!/^\d*$/.test(value)) return
|
|
||||||
|
|
||||||
const newCode = [...code]
|
|
||||||
newCode[index] = value.slice(-1)
|
|
||||||
setCode(newCode)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Auto-focus next input
|
|
||||||
if (value && index < 3) {
|
|
||||||
inputRefs.current[index + 1]?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-submit when all fields are filled
|
|
||||||
if (newCode.every(c => c !== '') && newCode.join('').length === 4) {
|
|
||||||
handleVerify(newCode.join(''))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
|
||||||
inputRefs.current[index - 1]?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaste = (e: React.ClipboardEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 4)
|
|
||||||
if (pastedData.length === 4) {
|
|
||||||
const newCode = pastedData.split('')
|
|
||||||
setCode(newCode)
|
|
||||||
inputRefs.current[3]?.focus()
|
|
||||||
handleVerify(pastedData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVerify = async (codeToVerify?: string) => {
|
const handleVerify = async (codeToVerify?: string) => {
|
||||||
const codeValue = codeToVerify || code.join('')
|
const codeValue = codeToVerify || code.join('')
|
||||||
@@ -137,15 +104,12 @@ function VerifyCodeContent() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'کد وارد شده نادرست است')
|
setError(result.message || 'کد وارد شده نادرست است')
|
||||||
// Clear code inputs
|
|
||||||
setCode(['', '', '', ''])
|
setCode(['', '', '', ''])
|
||||||
inputRefs.current[0]?.focus()
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Error verifying code:', error)
|
console.error('Error verifying code:', error)
|
||||||
setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور')
|
setError(error instanceof Error ? error.message : 'خطا در ارتباط با سرور')
|
||||||
setCode(['', '', '', ''])
|
setCode(['', '', '', ''])
|
||||||
inputRefs.current[0]?.focus()
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -164,9 +128,7 @@ function VerifyCodeContent() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setResendCooldown(result.resendAfterSeconds || 120)
|
setResendCooldown(result.resendAfterSeconds || 120)
|
||||||
// Clear code inputs
|
|
||||||
setCode(['', '', '', ''])
|
setCode(['', '', '', ''])
|
||||||
inputRefs.current[0]?.focus()
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'خطا در ارسال مجدد کد')
|
setError(result.message || 'خطا در ارسال مجدد کد')
|
||||||
setCanResend(true)
|
setCanResend(true)
|
||||||
@@ -215,29 +177,14 @@ function VerifyCodeContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleVerify(); }} className="space-y-5">
|
<form onSubmit={(e) => { e.preventDefault(); handleVerify(); }} className="space-y-5">
|
||||||
<div className="flex justify-center gap-3" style={{ direction: 'ltr' }}>
|
<CodeInput
|
||||||
{code.map((digit, index) => (
|
value={code}
|
||||||
<input
|
onChange={setCode}
|
||||||
key={index}
|
onComplete={handleVerify}
|
||||||
ref={(el) => { inputRefs.current[index] = el }}
|
|
||||||
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}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage message={error} />}
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-xl text-sm text-center">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -262,23 +209,12 @@ function VerifyCodeContent() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-gray-200 space-y-3">
|
<div className="pt-4 border-t border-gray-200 space-y-3">
|
||||||
<button
|
<ResendButton
|
||||||
type="button"
|
canResend={canResend}
|
||||||
onClick={handleResend}
|
cooldown={resendCooldown}
|
||||||
disabled={!canResend || loading}
|
onResend={handleResend}
|
||||||
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium transition-all duration-200 ${
|
loading={loading}
|
||||||
canResend && !loading
|
/>
|
||||||
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md hover:shadow-lg'
|
|
||||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
{canResend ? (
|
|
||||||
'ارسال مجدد کد'
|
|
||||||
) : (
|
|
||||||
`ارسال مجدد (${Math.floor(resendCooldown / 60)}:${String(resendCooldown % 60).padStart(2, '0')})`
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
|||||||
backgroundColor: s.backgroundColor ?? s.borderColor,
|
backgroundColor: s.backgroundColor ?? s.borderColor,
|
||||||
fill: s.fill ?? false,
|
fill: s.fill ?? false,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
pointRadius: 1.5,
|
pointRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 2 : 1.5, // Smaller points on mobile
|
||||||
pointHoverRadius: 4,
|
pointHoverRadius: typeof window !== 'undefined' && window.innerWidth < 768 ? 3 : 4,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
spanGaps: false // Don't connect points across null values (gaps)
|
spanGaps: false // Don't connect points across null values (gaps)
|
||||||
}))
|
}))
|
||||||
@@ -159,6 +159,13 @@ export function LineChart({ labels, series, yAxisMin, yAxisMax }: Props) {
|
|||||||
options={{
|
options={{
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: true,
|
maintainAspectRatio: true,
|
||||||
|
animation: {
|
||||||
|
duration: typeof window !== 'undefined' && window.innerWidth < 768 ? 300 : 1000, // Faster animations on mobile
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index' as const,
|
||||||
|
},
|
||||||
layout: {
|
layout: {
|
||||||
padding: {
|
padding: {
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type LoadingProps = {
|
type LoadingProps = {
|
||||||
message?: string
|
message?: string
|
||||||
@@ -25,3 +26,38 @@ export default function Loading({ message = 'در حال بارگذاری...', f
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skeleton loading component
|
||||||
|
type SkeletonProps = {
|
||||||
|
className?: string
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
rounded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className, width, height, rounded = true }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'skeleton',
|
||||||
|
rounded && 'rounded',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: width || '100%',
|
||||||
|
height: height || '1rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card skeleton for loading states
|
||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-md p-6 space-y-4">
|
||||||
|
<Skeleton height="1.5rem" width="60%" />
|
||||||
|
<Skeleton height="1rem" />
|
||||||
|
<Skeleton height="1rem" width="80%" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
100
src/components/README.md
Normal file
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 ReactMarkdown from 'react-markdown'
|
||||||
import { DailyReportDto } from '@/lib/api'
|
import { DailyReportDto, api } from '@/lib/api'
|
||||||
import { toPersianDigits } from './utils'
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
import { ErrorMessage, EmptyState } from '@/components/common'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { FileText } from 'lucide-react'
|
||||||
|
|
||||||
type AnalysisTabProps = {
|
type AnalysisTabProps = {
|
||||||
loading: boolean
|
deviceId: number
|
||||||
error: string | null
|
selectedDate: string
|
||||||
dailyReport: DailyReportDto | null
|
|
||||||
onRetry: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnalysisTab({ loading, error, dailyReport, onRetry }: AnalysisTabProps) {
|
export function AnalysisTab({ deviceId, selectedDate }: AnalysisTabProps) {
|
||||||
|
const [dailyReport, setDailyReport] = useState<DailyReportDto | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadAnalysis = useCallback(async () => {
|
||||||
|
// اگر قبلاً لود شده، دوباره لود نکن
|
||||||
|
if (dailyReport) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await api.getDailyReport(deviceId, selectedDate)
|
||||||
|
setDailyReport(report)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading analysis:', error)
|
||||||
|
setError('خطا در دریافت تحلیل. لطفاً دوباره تلاش کنید.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [deviceId, selectedDate, dailyReport])
|
||||||
|
|
||||||
|
// Reset analysis data when selectedDate changes
|
||||||
|
useEffect(() => {
|
||||||
|
setDailyReport(null)
|
||||||
|
setError(null)
|
||||||
|
}, [selectedDate])
|
||||||
|
|
||||||
|
// Load analysis when component mounts (user clicked on tab)
|
||||||
|
useEffect(() => {
|
||||||
|
loadAnalysis()
|
||||||
|
}, [loadAnalysis])
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <Loading message="در حال دریافت تحلیل..." fullScreen={false} />
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
|
||||||
<Loader2 className="w-12 h-12 text-indigo-500 animate-spin mb-4" />
|
|
||||||
<p className="text-gray-600">در حال دریافت تحلیل...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
<ErrorMessage
|
||||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
message={error}
|
||||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
action={
|
||||||
</div>
|
<Button
|
||||||
<p className="text-gray-700 mb-4">{error}</p>
|
onClick={() => {
|
||||||
<button
|
setDailyReport(null)
|
||||||
onClick={onRetry}
|
loadAnalysis()
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg transition-colors"
|
}}
|
||||||
|
variant="primary"
|
||||||
|
icon={RefreshCw}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
تلاش مجدد
|
تلاش مجدد
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dailyReport) {
|
if (!dailyReport) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
<EmptyState
|
||||||
<p className="text-gray-600">تحلیلی برای نمایش وجود ندارد</p>
|
icon={FileText}
|
||||||
</div>
|
title="تحلیلی موجود نیست"
|
||||||
|
message="تحلیلی برای نمایش وجود ندارد"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +1,106 @@
|
|||||||
|
import { useState, useMemo, memo, useCallback } from 'react'
|
||||||
import { BarChart3 } from 'lucide-react'
|
import { BarChart3 } from 'lucide-react'
|
||||||
import { LineChart, Panel } from '@/components/Charts'
|
import { LineChart, Panel } from '@/components/Charts'
|
||||||
import { TimeRangeSelector } from './TimeRangeSelector'
|
import { TimeRangeSelector } from './TimeRangeSelector'
|
||||||
import { DataGap } from './utils'
|
import { DataGap, detectDataGaps, normalizeTelemetryData } from '@/features/daily-report/utils'
|
||||||
|
import { TelemetryDto } from '@/lib/api'
|
||||||
|
import { EmptyState } from '@/components/common'
|
||||||
|
import { useTelemetryCharts } from '@/features/daily-report/hooks/useTelemetryCharts'
|
||||||
|
|
||||||
type ChartsTabProps = {
|
type ChartsTabProps = {
|
||||||
chartStartMinute: number
|
sortedTelemetry: TelemetryDto[]
|
||||||
chartEndMinute: number
|
|
||||||
onStartMinuteChange: (minute: number) => void
|
|
||||||
onEndMinuteChange: (minute: number) => void
|
|
||||||
labels: string[]
|
|
||||||
soil: (number | null)[]
|
|
||||||
humidity: (number | null)[]
|
|
||||||
temperature: (number | null)[]
|
|
||||||
lux: (number | null)[]
|
|
||||||
gas: (number | null)[]
|
|
||||||
tempMinMax: { min: number; max: number }
|
|
||||||
luxMinMax: { min: number; max: number }
|
|
||||||
totalRecords: number
|
|
||||||
dataGaps?: DataGap[]
|
dataGaps?: DataGap[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChartsTab({
|
export const ChartsTab = memo(function ChartsTab({
|
||||||
chartStartMinute,
|
sortedTelemetry,
|
||||||
chartEndMinute,
|
dataGaps = [],
|
||||||
onStartMinuteChange,
|
|
||||||
onEndMinuteChange,
|
|
||||||
labels,
|
|
||||||
soil,
|
|
||||||
humidity,
|
|
||||||
temperature,
|
|
||||||
lux,
|
|
||||||
gas,
|
|
||||||
tempMinMax,
|
|
||||||
luxMinMax,
|
|
||||||
totalRecords,
|
|
||||||
dataGaps = []
|
|
||||||
}: ChartsTabProps) {
|
}: ChartsTabProps) {
|
||||||
|
const [chartStartMinute, setChartStartMinute] = useState(0)
|
||||||
|
const [chartEndMinute, setChartEndMinute] = useState(1439)
|
||||||
|
|
||||||
|
const handleStartMinuteChange = useCallback((minute: number) => {
|
||||||
|
setChartStartMinute(minute)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleEndMinuteChange = useCallback((minute: number) => {
|
||||||
|
setChartEndMinute(minute)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Normalize telemetry data
|
||||||
|
const normalizedTelemetry = useMemo(
|
||||||
|
() => normalizeTelemetryData(sortedTelemetry),
|
||||||
|
[sortedTelemetry]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter by time range
|
||||||
|
const filteredTelemetry = useMemo(() => {
|
||||||
|
return normalizedTelemetry.filter(
|
||||||
|
t => t.minute >= chartStartMinute && t.minute <= chartEndMinute
|
||||||
|
)
|
||||||
|
}, [normalizedTelemetry, chartStartMinute, chartEndMinute])
|
||||||
|
|
||||||
|
// Detect data gaps in filtered data
|
||||||
|
const timestamps = useMemo(
|
||||||
|
() => filteredTelemetry.map(t => t.timestamp),
|
||||||
|
[filteredTelemetry]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredDataGaps = useMemo(
|
||||||
|
() => detectDataGaps(timestamps, 30),
|
||||||
|
[timestamps]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build charts using custom hook
|
||||||
|
const { charts, chartLabels } = useTelemetryCharts({
|
||||||
|
filteredTelemetry,
|
||||||
|
filteredDataGaps,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Time Range Selector */}
|
|
||||||
<TimeRangeSelector
|
<TimeRangeSelector
|
||||||
startMinute={chartStartMinute}
|
startMinute={chartStartMinute}
|
||||||
endMinute={chartEndMinute}
|
endMinute={chartEndMinute}
|
||||||
onStartMinuteChange={onStartMinuteChange}
|
onStartMinuteChange={handleStartMinuteChange}
|
||||||
onEndMinuteChange={onEndMinuteChange}
|
onEndMinuteChange={handleEndMinuteChange}
|
||||||
totalRecords={totalRecords}
|
totalRecords={filteredTelemetry.length}
|
||||||
dataGaps={dataGaps}
|
dataGaps={dataGaps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Charts Grid */}
|
{filteredTelemetry.length === 0 ? (
|
||||||
{totalRecords === 0 ? (
|
<EmptyState
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
icon={BarChart3}
|
||||||
<BarChart3 className="w-12 h-12 text-gray-300 mb-4" />
|
title="دادهای موجود نیست"
|
||||||
<p className="text-gray-600">دادهای برای این بازه زمانی موجود نیست</p>
|
message="دادهای برای این بازه زمانی موجود نیست"
|
||||||
</div>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Panel title="رطوبت خاک">
|
{charts.map(chart => (
|
||||||
|
<Panel key={chart.key} title={chart.title}>
|
||||||
<LineChart
|
<LineChart
|
||||||
labels={labels}
|
labels={chartLabels}
|
||||||
series={[{ label: 'رطوبت خاک (%)', data: soil as (number | null)[], borderColor: '#16a34a', backgroundColor: '#dcfce7', fill: true }]}
|
series={[
|
||||||
yAxisMin={0}
|
{
|
||||||
yAxisMax={100}
|
label: chart.seriesLabel,
|
||||||
/>
|
data: chart.data,
|
||||||
</Panel>
|
borderColor: chart.color,
|
||||||
<Panel title="رطوبت">
|
backgroundColor: chart.bgColor,
|
||||||
<LineChart
|
fill: true,
|
||||||
labels={labels}
|
},
|
||||||
series={[{ label: 'رطوبت (%)', data: humidity as (number | null)[], borderColor: '#3b82f6', backgroundColor: '#dbeafe', fill: true }]}
|
]}
|
||||||
yAxisMin={0}
|
yAxisMin={chart.yAxisMin}
|
||||||
yAxisMax={100}
|
yAxisMax={chart.yAxisMax}
|
||||||
/>
|
|
||||||
</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>
|
</Panel>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}, (prevProps, nextProps) => {
|
||||||
|
// Custom comparison for better performance
|
||||||
|
return prevProps.sortedTelemetry.length === nextProps.sortedTelemetry.length &&
|
||||||
|
(prevProps.dataGaps?.length ?? 0) === (nextProps.dataGaps?.length ?? 0) &&
|
||||||
|
prevProps.sortedTelemetry[0]?.id === nextProps.sortedTelemetry[0]?.id
|
||||||
|
})
|
||||||
|
|||||||
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 { TrendingUp, TrendingDown } from 'lucide-react'
|
||||||
import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges'
|
import { TemperatureGauge, HumidityGauge, LuxGauge, GasGauge } from '@/components/Gauges'
|
||||||
import { MiniLineChart } from '@/components/MiniChart'
|
import { MiniLineChart } from '@/components/MiniChart'
|
||||||
import { paramConfig, toPersianDigits } from './utils'
|
import { paramConfig } from '@/features/daily-report/utils'
|
||||||
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
|
||||||
type SummaryCardProps = {
|
type SummaryCardProps = {
|
||||||
param: string
|
param: string
|
||||||
|
|||||||
@@ -1,77 +1,151 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
import { SummaryCard } from './SummaryCard'
|
import { SummaryCard } from './SummaryCard'
|
||||||
|
import { WeatherData } from '@/features/weather'
|
||||||
|
import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts'
|
||||||
|
import { Dialog } from '@/components/common/Dialog'
|
||||||
|
import { WeatherAlertBanner } from '@/components/alerts'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
type SummaryTabProps = {
|
type SummaryTabProps = {
|
||||||
temperature: {
|
temperature: number[]
|
||||||
current: number
|
humidity: number[]
|
||||||
min: number
|
soil: number[]
|
||||||
max: number
|
gas: number[]
|
||||||
data: number[]
|
lux: number[]
|
||||||
}
|
forecastWeather?: WeatherData | null
|
||||||
humidity: {
|
forecastWeatherLoading?: boolean
|
||||||
current: number
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
data: number[]
|
|
||||||
}
|
|
||||||
soil: {
|
|
||||||
current: number
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
data: number[]
|
|
||||||
}
|
|
||||||
gas: {
|
|
||||||
current: number
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
data: number[]
|
|
||||||
}
|
|
||||||
lux: {
|
|
||||||
current: number
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
data: number[]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SummaryTab({ temperature, humidity, soil, gas, lux }: SummaryTabProps) {
|
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false }: SummaryTabProps) {
|
||||||
|
const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
const alertsCount = useMemo(() => {
|
||||||
|
return getForecastAlertsCount(forecastWeather ?? null)
|
||||||
|
}, [forecastWeather])
|
||||||
|
// Memoized summary statistics for each parameter
|
||||||
|
const temperatureSummary = useMemo(() => {
|
||||||
|
if (temperature.length === 0) return { current: 0, min: 0, max: 0 }
|
||||||
|
return {
|
||||||
|
current: temperature.at(-1) ?? 0,
|
||||||
|
min: Math.min(...temperature),
|
||||||
|
max: Math.max(...temperature),
|
||||||
|
}
|
||||||
|
}, [temperature])
|
||||||
|
|
||||||
|
const humiditySummary = useMemo(() => {
|
||||||
|
if (humidity.length === 0) return { current: 0, min: 0, max: 0 }
|
||||||
|
return {
|
||||||
|
current: humidity.at(-1) ?? 0,
|
||||||
|
min: Math.min(...humidity),
|
||||||
|
max: Math.max(...humidity),
|
||||||
|
}
|
||||||
|
}, [humidity])
|
||||||
|
|
||||||
|
const soilSummary = useMemo(() => {
|
||||||
|
if (soil.length === 0) return { current: 0, min: 0, max: 0 }
|
||||||
|
return {
|
||||||
|
current: soil.at(-1) ?? 0,
|
||||||
|
min: Math.min(...soil),
|
||||||
|
max: Math.max(...soil),
|
||||||
|
}
|
||||||
|
}, [soil])
|
||||||
|
|
||||||
|
const gasSummary = useMemo(() => {
|
||||||
|
if (gas.length === 0) return { current: 0, min: 0, max: 0 }
|
||||||
|
return {
|
||||||
|
current: gas.at(-1) ?? 0,
|
||||||
|
min: Math.min(...gas),
|
||||||
|
max: Math.max(...gas),
|
||||||
|
}
|
||||||
|
}, [gas])
|
||||||
|
|
||||||
|
const luxSummary = useMemo(() => {
|
||||||
|
if (lux.length === 0) return { current: 0, min: 0, max: 0 }
|
||||||
|
return {
|
||||||
|
current: lux.at(-1) ?? 0,
|
||||||
|
min: Math.min(...lux),
|
||||||
|
max: Math.max(...lux),
|
||||||
|
}
|
||||||
|
}, [lux])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* 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">
|
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="temperature"
|
param="temperature"
|
||||||
currentValue={temperature.current}
|
currentValue={temperatureSummary.current}
|
||||||
minValue={temperature.min}
|
minValue={temperatureSummary.min}
|
||||||
maxValue={temperature.max}
|
maxValue={temperatureSummary.max}
|
||||||
data={temperature.data}
|
data={temperature}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="humidity"
|
param="humidity"
|
||||||
currentValue={humidity.current}
|
currentValue={humiditySummary.current}
|
||||||
minValue={humidity.min}
|
minValue={humiditySummary.min}
|
||||||
maxValue={humidity.max}
|
maxValue={humiditySummary.max}
|
||||||
data={humidity.data}
|
data={humidity}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="soil"
|
param="soil"
|
||||||
currentValue={soil.current}
|
currentValue={soilSummary.current}
|
||||||
minValue={soil.min}
|
minValue={soilSummary.min}
|
||||||
maxValue={soil.max}
|
maxValue={soilSummary.max}
|
||||||
data={soil.data}
|
data={soil}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="gas"
|
param="gas"
|
||||||
currentValue={gas.current}
|
currentValue={gasSummary.current}
|
||||||
minValue={gas.min}
|
minValue={gasSummary.min}
|
||||||
maxValue={gas.max}
|
maxValue={gasSummary.max}
|
||||||
data={gas.data}
|
data={gas}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="lux"
|
param="lux"
|
||||||
currentValue={lux.current}
|
currentValue={luxSummary.current}
|
||||||
minValue={lux.min}
|
minValue={luxSummary.min}
|
||||||
maxValue={lux.max}
|
maxValue={luxSummary.max}
|
||||||
data={lux.data}
|
data={lux}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { useMemo } from 'react'
|
||||||
import { toPersianDigits, DataGap } from './utils'
|
import { DataGap } from '@/features/daily-report/utils'
|
||||||
|
import { calculateSunTimes } from '@/lib/utils/sun-utils'
|
||||||
|
import {
|
||||||
|
TimeRangeHeader,
|
||||||
|
TimelineTrack,
|
||||||
|
TimelineSlider,
|
||||||
|
TimeLabel,
|
||||||
|
TimeRangeInfo,
|
||||||
|
} from './timeline'
|
||||||
|
|
||||||
type TimeRangeSelectorProps = {
|
type TimeRangeSelectorProps = {
|
||||||
startMinute: number // دقیقه از نیمه شب (0-1439)
|
startMinute: number // دقیقه از نیمه شب (0-1439)
|
||||||
@@ -10,49 +18,6 @@ type TimeRangeSelectorProps = {
|
|||||||
dataGaps?: DataGap[] // گپهای داده
|
dataGaps?: DataGap[] // گپهای داده
|
||||||
}
|
}
|
||||||
|
|
||||||
// محاسبه زمان طلوع و غروب خورشید برای قم
|
|
||||||
// عرض جغرافیایی: 34.6416° شمالی، طول جغرافیایی: 50.8746° شرقی
|
|
||||||
function calculateSunTimes() {
|
|
||||||
const latitude = 34.6416
|
|
||||||
const now = new Date()
|
|
||||||
const dayOfYear = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 86400000)
|
|
||||||
|
|
||||||
// محاسبه انحراف خورشید (Solar Declination)
|
|
||||||
const declination = -23.44 * Math.cos((2 * Math.PI / 365) * (dayOfYear + 10))
|
|
||||||
|
|
||||||
// محاسبه زاویه ساعتی طلوع (Hour Angle)
|
|
||||||
const latRad = latitude * Math.PI / 180
|
|
||||||
const decRad = declination * Math.PI / 180
|
|
||||||
const cosHourAngle = -Math.tan(latRad) * Math.tan(decRad)
|
|
||||||
|
|
||||||
// در صورتی که خورشید طلوع/غروب میکند
|
|
||||||
if (Math.abs(cosHourAngle) <= 1) {
|
|
||||||
const hourAngle = Math.acos(cosHourAngle) * 180 / Math.PI
|
|
||||||
|
|
||||||
// زمان طلوع و غروب به ساعت محلی (با دقیقه دقیق)
|
|
||||||
const sunriseDecimal = 12 - hourAngle / 15 + (50.8746 / 15 - 3.5) // تصحیح برای طول جغرافیایی و منطقه زمانی ایران
|
|
||||||
const sunsetDecimal = 12 + hourAngle / 15 + (50.8746 / 15 - 3.5)
|
|
||||||
|
|
||||||
// تبدیل به ساعت و دقیقه
|
|
||||||
const sunriseHour = Math.floor(sunriseDecimal)
|
|
||||||
const sunriseMinute = Math.round((sunriseDecimal - sunriseHour) * 60)
|
|
||||||
|
|
||||||
const sunsetHour = Math.floor(sunsetDecimal)
|
|
||||||
const sunsetMinute = Math.round((sunsetDecimal - sunsetHour) * 60)
|
|
||||||
|
|
||||||
return {
|
|
||||||
sunrise: { hour: sunriseHour, minute: sunriseMinute, decimal: sunriseDecimal },
|
|
||||||
sunset: { hour: sunsetHour, minute: sunsetMinute, decimal: sunsetDecimal }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// مقادیر پیشفرض
|
|
||||||
return {
|
|
||||||
sunrise: { hour: 6, minute: 0, decimal: 6 },
|
|
||||||
sunset: { hour: 18, minute: 0, decimal: 18 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimeRangeSelector({
|
export function TimeRangeSelector({
|
||||||
startMinute,
|
startMinute,
|
||||||
endMinute,
|
endMinute,
|
||||||
@@ -61,205 +26,37 @@ export function TimeRangeSelector({
|
|||||||
totalRecords,
|
totalRecords,
|
||||||
dataGaps = []
|
dataGaps = []
|
||||||
}: TimeRangeSelectorProps) {
|
}: TimeRangeSelectorProps) {
|
||||||
const { sunrise, sunset } = calculateSunTimes()
|
const sunTimes = useMemo(() => calculateSunTimes(), [])
|
||||||
|
|
||||||
// تبدیل دقیقه به ساعت برای نمایش
|
const handleReset = () => {
|
||||||
const startHour = Math.floor(startMinute / 60)
|
onStartMinuteChange(0)
|
||||||
const startMin = startMinute % 60
|
onEndMinuteChange(1439) // 23:59
|
||||||
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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{/* Header */}
|
<TimeRangeHeader dataGaps={dataGaps} onReset={handleReset} />
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-slate-700 dark:text-slate-300 font-medium text-lg">محدوده زمانی</span>
|
|
||||||
{dataGaps.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 rounded-lg text-xs">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
|
||||||
<span>{toPersianDigits(dataGaps.length)} گپ در دادهها</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onStartMinuteChange(0)
|
|
||||||
onEndMinuteChange(1439) // 23:59
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
کل روز
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline Selector */}
|
{/* Timeline Selector */}
|
||||||
<div className="relative h-32 pt-10 mb-4 select-none">
|
<div className="relative h-32 pt-10 mb-4 select-none">
|
||||||
{/* Track background */}
|
<TimelineTrack sunTimes={sunTimes} dataGaps={dataGaps} />
|
||||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20 rounded-lg bg-slate-200 dark:bg-slate-700">
|
|
||||||
{/* Sunrise dashed line */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Sunset dashed line */}
|
<TimeLabel minute={startMinute} variant="start" />
|
||||||
<div
|
<TimeLabel minute={endMinute} variant="end" />
|
||||||
className="absolute top-[-20px] bottom-[-20px] inset-y-0 w-0 border-r-2 border-dashed border-slate-400 dark:border-slate-500 z-10"
|
|
||||||
style={{ right: `${sunsetPercent}%` }}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div
|
<TimelineSlider
|
||||||
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"
|
startMinute={startMinute}
|
||||||
style={{ right: `${sunrisePercent}%`, transform: 'translateX(50%)' }}
|
endMinute={endMinute}
|
||||||
>
|
onStartMinuteChange={onStartMinuteChange}
|
||||||
طلوع {toPersianDigits(sunrise.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunrise.minute.toString().padStart(2, '0'))}
|
onEndMinuteChange={onEndMinuteChange}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute right-0 top-0 bottom-0 bg-gray-300"
|
|
||||||
style={{width: `${sunsetPercent}%`}}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 bg-gray-300"
|
|
||||||
style={{width: `calc(${100 -sunrisePercent}% - 1px)`}}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data gaps visualization */}
|
|
||||||
{dataGaps.map((gap, idx) => {
|
|
||||||
const gapStartPercent = ((1439 - gap.startMinute) / 1439) * 100
|
|
||||||
const gapEndPercent = ((1439 - gap.endMinute) / 1439) * 100
|
|
||||||
const gapWidth = gapStartPercent - gapEndPercent
|
|
||||||
const gapHours = Math.floor(gap.durationMinutes / 60)
|
|
||||||
const gapMins = gap.durationMinutes % 60
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={idx}>
|
|
||||||
{/* Gap area */}
|
|
||||||
<div
|
|
||||||
className="absolute top-0 bottom-0 bg-red-400/30 dark:bg-red-500/40 border-r-2 border-l-2 border-red-500 dark:border-red-400 z-15"
|
|
||||||
style={{
|
|
||||||
right: `${gapEndPercent}%`,
|
|
||||||
width: `${gapWidth}%`
|
|
||||||
}}
|
|
||||||
title={`گپ ${toPersianDigits(gapHours)}:${toPersianDigits(gapMins.toString().padStart(2, '0'))}`}
|
|
||||||
>
|
|
||||||
{/* Warning icon in gap */}
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gap tooltip */}
|
|
||||||
{gapWidth > 5 && (
|
|
||||||
<div
|
|
||||||
className="absolute top-[-30px] z-30 pointer-events-none bg-red-500 text-white px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
|
|
||||||
style={{ right: `${gapEndPercent + gapWidth / 2}%`, transform: 'translateX(50%)' }}
|
|
||||||
>
|
|
||||||
گپ {toPersianDigits(gapHours)}:{toPersianDigits(gapMins.toString().padStart(2, '0'))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute bottom-[-30px] z-30 pointer-events-none bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs font-mono whitespace-nowrap"
|
|
||||||
style={{ right: `${sunsetPercent}%`, transform: 'translateX(50%)' }}
|
|
||||||
>
|
|
||||||
غروب {toPersianDigits(sunset.hour.toString().padStart(2, '0'))}:{toPersianDigits(sunset.minute.toString().padStart(2, '0'))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hour markers inside track */}
|
|
||||||
{[0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22].map(hour => (
|
|
||||||
<div
|
|
||||||
key={hour}
|
|
||||||
className="absolute bottom-0 z-20"
|
|
||||||
style={{ right: `${((23 - hour) / 23) * 100}%`, transform: 'translateX(50%)' }}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<span className="text-[10px] text-slate-600 dark:text-slate-400 font-mono">
|
|
||||||
{toPersianDigits(hour.toString().padStart(2, '0'))}
|
|
||||||
</span>
|
|
||||||
<div className="w-px h-2 bg-slate-400 dark:bg-slate-500 mb-1"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Start time label - above handle */}
|
|
||||||
<div
|
|
||||||
className="absolute top-[-8px] z-30 pointer-events-none"
|
|
||||||
style={{ right: `calc(${((1439 - startMinute) / 1439) * 100}% - 4px)`, transform: 'translateX(50%)' }}
|
|
||||||
>
|
|
||||||
<div className="bg-emerald-500 text-white px-2 py-1 rounded text-sm font-mono whitespace-nowrap">
|
|
||||||
{toPersianDigits(startHour.toString().padStart(2, '0'))}:{toPersianDigits(startMin.toString().padStart(2, '0'))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* End time label - above handle */}
|
|
||||||
<div
|
|
||||||
className="absolute top-[-8px] z-30 pointer-events-none"
|
|
||||||
style={{ right: `calc(${((1439 - endMinute) / 1439) * 100}% + 4px)`, transform: 'translateX(50%)' }}
|
|
||||||
>
|
|
||||||
<div className="bg-rose-500 text-white px-2 py-1 rounded text-sm font-mono whitespace-nowrap">
|
|
||||||
{toPersianDigits(endHour.toString().padStart(2, '0'))}:{toPersianDigits(endMin.toString().padStart(2, '0'))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Range inputs container */}
|
|
||||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-20">
|
|
||||||
{/* Start time slider */}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1439"
|
|
||||||
value={1439 - startMinute}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = 1439 - Number(e.target.value)
|
|
||||||
if (val <= endMinute) onStartMinuteChange(val)
|
|
||||||
}}
|
|
||||||
className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-emerald-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* End time slider */}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1439"
|
|
||||||
value={1439 - endMinute}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = 1439 - Number(e.target.value)
|
|
||||||
if (val >= startMinute) onEndMinuteChange(val)
|
|
||||||
}}
|
|
||||||
className="absolute inset-0 w-full h-full appearance-none bg-transparent cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-20 [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:bg-rose-500 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-20 [&::-moz-range-thumb]:rounded-sm [&::-moz-range-thumb]:bg-rose-500 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:border-0 pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info section */}
|
<TimeRangeInfo
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-slate-200 dark:border-slate-700">
|
startMinute={startMinute}
|
||||||
<div className="text-slate-600 dark:text-slate-400">
|
endMinute={endMinute}
|
||||||
<span className="text-2xl font-bold text-slate-800 dark:text-slate-200 font-mono">
|
totalRecords={totalRecords}
|
||||||
{toPersianDigits(Math.floor((endMinute - startMinute + 1) / 60))}:{toPersianDigits(((endMinute - startMinute + 1) % 60).toString().padStart(2, '0'))}
|
/>
|
||||||
</span>
|
|
||||||
<span className="text-sm mr-2">بازه انتخاب شده</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-slate-500 dark:text-slate-400 text-sm">
|
|
||||||
{totalRecords > 0
|
|
||||||
? <>{toPersianDigits(totalRecords)} رکورد</>
|
|
||||||
: <>بدون رکورد</>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +1,94 @@
|
|||||||
import { Loader2, AlertCircle, RefreshCw, MapPin, Droplets, Wind, Thermometer, Sun, CloudRain, Calendar as CalendarIcon, ChevronDown } from 'lucide-react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { WeatherData, toPersianDigits, getWeatherInfo, getPersianDayName, getGreenhouseAlerts } from '.'
|
import { RefreshCw, MapPin } from 'lucide-react'
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/persian-date'
|
import { WeatherData } from '@/features/weather'
|
||||||
|
import { fetchHistoricalWeather, fetchForecastWeather, isToday as checkIsToday, fetchLocationName } from '@/features/weather'
|
||||||
|
import { QOM_LAT, QOM_LON } from '@/features/weather/helpers'
|
||||||
|
import { TodayWeather } from './weather/TodayWeather'
|
||||||
|
import { HistoricalWeather } from './weather/HistoricalWeather'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
import { ErrorMessage } from '@/components/common'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
|
||||||
type WeatherTabProps = {
|
type WeatherTabProps = {
|
||||||
loading: boolean
|
selectedDate: string // Persian date in format "yyyy/MM/dd"
|
||||||
error: string | null
|
|
||||||
weatherData: WeatherData | null
|
|
||||||
onRetry: () => void
|
|
||||||
expandedDayIndex: number | null
|
|
||||||
onDayToggle: (index: number | null) => void
|
|
||||||
selectedDate: string | null // Persian date in format "yyyy/MM/dd"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeatherTab({
|
export function WeatherTab({ selectedDate }: WeatherTabProps) {
|
||||||
loading,
|
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
|
||||||
error,
|
const [loading, setLoading] = useState(false)
|
||||||
weatherData,
|
const [error, setError] = useState<string | null>(null)
|
||||||
onRetry,
|
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
|
||||||
expandedDayIndex,
|
const [locationName, setLocationName] = useState<string>('در حال دریافت...')
|
||||||
onDayToggle,
|
|
||||||
selectedDate
|
const loadWeather = useCallback(async () => {
|
||||||
}: WeatherTabProps) {
|
// اگر قبلاً لود شده، دوباره لود نکن
|
||||||
// Check if selected date is today by comparing Persian dates
|
if (weatherData) return
|
||||||
const isToday = (() => {
|
|
||||||
if (!selectedDate) return true
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get today's Persian date
|
const isTodayDate = checkIsToday(selectedDate)
|
||||||
const todayYear = getCurrentPersianYear()
|
|
||||||
const todayMonth = getCurrentPersianMonth()
|
|
||||||
const todayDay = getCurrentPersianDay()
|
|
||||||
const todayPersian = `${todayYear}/${String(todayMonth).padStart(2, '0')}/${String(todayDay).padStart(2, '0')}`
|
|
||||||
|
|
||||||
// Normalize selected date format
|
// Load weather data and location name in parallel
|
||||||
const [y, m, d] = selectedDate.split('/').map(s => s.trim())
|
const [weather, location] = await Promise.all([
|
||||||
const normalizedSelected = `${y}/${String(Number(m)).padStart(2, '0')}/${String(Number(d)).padStart(2, '0')}`
|
isTodayDate
|
||||||
|
? fetchForecastWeather()
|
||||||
|
: fetchHistoricalWeather(selectedDate),
|
||||||
|
fetchLocationName(QOM_LAT, QOM_LON)
|
||||||
|
])
|
||||||
|
|
||||||
return normalizedSelected === todayPersian
|
setWeatherData(weather)
|
||||||
} catch (e) {
|
setLocationName(location)
|
||||||
console.error('Error checking if today:', e)
|
} catch (error) {
|
||||||
return true
|
console.error('Error loading weather:', error)
|
||||||
|
setError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
|
||||||
|
// Set fallback location name on error
|
||||||
|
setLocationName('کهک قم، ایران')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
}, [selectedDate, weatherData])
|
||||||
|
|
||||||
|
// Reset weather data when selectedDate changes
|
||||||
|
useEffect(() => {
|
||||||
|
setWeatherData(null)
|
||||||
|
setError(null)
|
||||||
|
setExpandedDayIndex(null)
|
||||||
|
setLocationName('در حال دریافت...')
|
||||||
|
}, [selectedDate])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <Loading message="در حال دریافت اطلاعات آب و هوا..." fullScreen={false} />
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
|
||||||
<Loader2 className="w-12 h-12 text-sky-500 animate-spin mb-4" />
|
|
||||||
<p className="text-gray-600">در حال دریافت اطلاعات آب و هوا...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
<ErrorMessage
|
||||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
message={error}
|
||||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
action={
|
||||||
</div>
|
<Button
|
||||||
<p className="text-gray-700 mb-4">{error}</p>
|
onClick={() => {
|
||||||
<button
|
setWeatherData(null)
|
||||||
onClick={onRetry}
|
loadWeather()
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white rounded-lg transition-colors"
|
}}
|
||||||
|
variant="primary"
|
||||||
|
icon={RefreshCw}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
تلاش مجدد
|
تلاش مجدد
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!weatherData) {
|
if (!weatherData) {
|
||||||
|
// Trigger load when component is mounted (user clicked on weather tab)
|
||||||
|
loadWeather()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const alerts = getGreenhouseAlerts(weatherData)
|
const isTodayDate = checkIsToday(selectedDate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -82,433 +96,24 @@ export function WeatherTab({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-gray-600">
|
<div className="flex items-center gap-2 text-gray-600">
|
||||||
<MapPin className="w-5 h-5 text-sky-500" />
|
<MapPin className="w-5 h-5 text-sky-500" />
|
||||||
<span className="font-medium">قم، ایران</span>
|
<span className="font-medium">{locationName}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{isToday ? 'پیشبینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
|
{isTodayDate ? 'پیشبینی هواشناسی برای مدیریت گلخانه' : 'وضعیت آب و هوای روز انتخاب شده'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Greenhouse Alerts - Only for today */}
|
{isTodayDate ? (
|
||||||
{isToday && alerts.length > 0 && (
|
<TodayWeather
|
||||||
<div className="space-y-3">
|
weatherData={weatherData}
|
||||||
<h3 className="text-sm font-semibold text-gray-700">🌱 هشدارها و توصیههای گلخانه</h3>
|
expandedDayIndex={expandedDayIndex}
|
||||||
{alerts.map((alert, index) => (
|
onDayToggle={setExpandedDayIndex}
|
||||||
<div
|
/>
|
||||||
key={index}
|
|
||||||
className={`p-4 rounded-xl border-r-4 ${
|
|
||||||
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: میزان بارش واقعی */
|
<HistoricalWeather
|
||||||
<div className={`rounded-2xl p-4 ${
|
weatherData={weatherData}
|
||||||
(weatherData.daily[0]?.precipitation || 0) > 5 ? 'bg-blue-100 border-2 border-blue-300' :
|
selectedDate={selectedDate}
|
||||||
(weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-50 border-2 border-sky-200' :
|
/>
|
||||||
'bg-amber-50 border-2 border-amber-200'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
|
||||||
(weatherData.daily[0]?.precipitation || 0) > 5 ? 'bg-blue-500' :
|
|
||||||
(weatherData.daily[0]?.precipitation || 0) > 0 ? 'bg-sky-400' :
|
|
||||||
'bg-amber-400'
|
|
||||||
}`}>
|
|
||||||
{(weatherData.daily[0]?.precipitation || 0) > 0 ?
|
|
||||||
<CloudRain className="w-8 h-8 text-white" /> :
|
|
||||||
<Sun className="w-8 h-8 text-white" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
|
||||||
<p className={`text-sm ${
|
|
||||||
(weatherData.daily[0]?.precipitation || 0) > 5 ? 'text-blue-600' :
|
|
||||||
(weatherData.daily[0]?.precipitation || 0) > 0 ? 'text-sky-600' :
|
|
||||||
'text-amber-600'
|
|
||||||
}`}>
|
|
||||||
{(weatherData.daily[0]?.precipitation || 0) > 5 ? '🌧️ بارش زیاد' :
|
|
||||||
(weatherData.daily[0]?.precipitation || 0) > 0 ? '🌦️ بارش کم' :
|
|
||||||
'☀️ بدون بارش'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits((weatherData.daily[0]?.precipitation || 0).toFixed(1))}</p>
|
|
||||||
<p className="text-sm text-gray-500">میلیمتر بارش</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sunlight Card */}
|
|
||||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-2xl p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-14 h-14 rounded-xl bg-yellow-400 flex items-center justify-center">
|
|
||||||
<Sun className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
|
|
||||||
<p className="text-sm text-yellow-600">
|
|
||||||
{(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 8 ? '☀️ آفتاب زیاد' :
|
|
||||||
(weatherData.daily[0]?.sunshineDuration || 0) / 3600 > 4 ? '🌤️ آفتاب متوسط' :
|
|
||||||
'☁️ کمآفتاب'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-end">
|
|
||||||
<div>
|
|
||||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(Math.round((weatherData.daily[0]?.sunshineDuration || 0) / 3600))}</p>
|
|
||||||
<p className="text-sm text-gray-500">ساعت آفتاب</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-2xl font-bold text-orange-500">{toPersianDigits(Math.round(weatherData.daily[0]?.uvIndexMax || 0))}</p>
|
|
||||||
<p className="text-xs text-gray-500">شاخص UV</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wind & Humidity Card */}
|
|
||||||
<div className="bg-cyan-50 border-2 border-cyan-200 rounded-2xl p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-14 h-14 rounded-xl bg-cyan-400 flex items-center justify-center">
|
|
||||||
<Wind className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
|
|
||||||
<p className="text-sm text-cyan-600">
|
|
||||||
{(weatherData.daily[0]?.windSpeedMax || 0) > 40 ? '💨 باد شدید!' :
|
|
||||||
(weatherData.daily[0]?.windSpeedMax || 0) > 20 ? '🍃 وزش باد' :
|
|
||||||
'😌 آرام'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-end">
|
|
||||||
<div>
|
|
||||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(weatherData.daily[0]?.windSpeedMax || 0))}</p>
|
|
||||||
<p className="text-xs text-gray-500">کیلومتر/ساعت باد</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-3xl font-bold text-blue-500">{toPersianDigits(weatherData.current.humidity)}%</p>
|
|
||||||
<p className="text-xs text-gray-500">رطوبت هوا</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hourly Forecast - Only for today */}
|
|
||||||
{isToday && (
|
|
||||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
|
||||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 p-4">
|
|
||||||
<h4 className="text-lg font-bold text-white flex items-center gap-2">
|
|
||||||
🕐 وضعیت ساعت به ساعت امروز
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="overflow-x-auto pb-2">
|
|
||||||
<div className="flex gap-2" style={{ minWidth: 'max-content' }}>
|
|
||||||
{weatherData.hourly.map((hour) => {
|
|
||||||
const hourNum = new Date(hour.time).getHours()
|
|
||||||
const isNow = hourNum === new Date().getHours()
|
|
||||||
const IconComponent = getWeatherInfo(hour.weatherCode).icon
|
|
||||||
const isHot = hour.temperature > 35
|
|
||||||
const isCold = hour.temperature < 10
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={hour.time}
|
|
||||||
className={`flex flex-col items-center p-3 rounded-xl min-w-[85px] transition-all ${
|
|
||||||
isNow
|
|
||||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-500 text-white shadow-lg scale-105'
|
|
||||||
: isHot
|
|
||||||
? 'bg-red-50 border border-red-200'
|
|
||||||
: isCold
|
|
||||||
? 'bg-blue-50 border border-blue-200'
|
|
||||||
: 'bg-gray-50 border border-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className={`text-sm font-bold ${isNow ? 'text-white' : 'text-gray-600'}`}>
|
|
||||||
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
|
||||||
<IconComponent className={`w-6 h-6 ${
|
|
||||||
isNow ? 'text-white' :
|
|
||||||
isHot ? 'text-red-500' :
|
|
||||||
isCold ? 'text-blue-500' :
|
|
||||||
'text-gray-500'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className={`text-2xl font-bold ${
|
|
||||||
isNow ? 'text-white' :
|
|
||||||
isHot ? 'text-red-600' :
|
|
||||||
isCold ? 'text-blue-600' :
|
|
||||||
'text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{toPersianDigits(Math.round(hour.temperature))}°
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className={`flex items-center gap-1 mt-2 text-xs ${isNow ? 'text-white/80' : 'text-blue-500'}`}>
|
|
||||||
<Droplets className="w-3 h-3" />
|
|
||||||
<span>{toPersianDigits(hour.humidity)}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hour.precipitation > 0 && (
|
|
||||||
<div className={`mt-1 px-2 py-0.5 rounded-full text-xs ${isNow ? 'bg-white/20 text-white' : 'bg-blue-100 text-blue-600'}`}>
|
|
||||||
🌧️ {toPersianDigits(hour.precipitation.toFixed(1))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 text-center mt-3">👈 برای دیدن ساعتهای بیشتر به چپ بکشید</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 7-Day Forecast - Only for today */}
|
|
||||||
{isToday && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
|
||||||
<CalendarIcon className="w-5 h-5 text-emerald-500" />
|
|
||||||
پیشبینی ۷ روز آینده
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{weatherData.daily.map((day, index) => {
|
|
||||||
const weatherInfo = getWeatherInfo(day.weatherCode)
|
|
||||||
const IconComponent = weatherInfo.icon
|
|
||||||
const isToday = index === 0
|
|
||||||
const hasFrost = day.tempMin < 5
|
|
||||||
const hasHeat = day.tempMax > 35
|
|
||||||
const isExpanded = expandedDayIndex === index
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={day.date} className="overflow-hidden rounded-xl">
|
|
||||||
<button
|
|
||||||
onClick={() => onDayToggle(isExpanded ? null : index)}
|
|
||||||
className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
|
|
||||||
isExpanded
|
|
||||||
? 'bg-emerald-500 text-white'
|
|
||||||
: isToday
|
|
||||||
? 'bg-emerald-50 hover:bg-emerald-100'
|
|
||||||
: hasFrost
|
|
||||||
? 'bg-blue-50 hover:bg-blue-100'
|
|
||||||
: hasHeat
|
|
||||||
? 'bg-red-50 hover:bg-red-100'
|
|
||||||
: 'bg-gray-50 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 flex-1">
|
|
||||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
|
||||||
isExpanded ? 'bg-white/20' : 'bg-white'
|
|
||||||
}`}>
|
|
||||||
<IconComponent className={`w-6 h-6 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
|
|
||||||
</div>
|
|
||||||
<div className="text-right flex-1">
|
|
||||||
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
|
||||||
{isToday ? 'امروز' : getPersianDayName(day.date)}
|
|
||||||
</p>
|
|
||||||
<p className={`text-sm ${isExpanded ? 'text-white/80' : 'text-gray-500'}`}>
|
|
||||||
{weatherInfo.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className={`text-2xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
|
||||||
{toPersianDigits(Math.round(day.tempMax))}°
|
|
||||||
</p>
|
|
||||||
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
|
|
||||||
{toPersianDigits(Math.round(day.tempMin))}°
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="bg-white p-4 border-t border-emerald-100">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Thermometer className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-600 font-medium">دما</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
|
|
||||||
<p className="text-sm text-gray-500">حداکثر</p>
|
|
||||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
|
|
||||||
<p className="text-sm text-gray-500">حداقل</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<CloudRain className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-600 font-medium">بارش</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
|
|
||||||
<p className="text-sm text-gray-500">احتمال</p>
|
|
||||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
|
|
||||||
<p className="text-sm text-gray-500">میلیمتر</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sun className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
|
|
||||||
<p className="text-sm text-gray-500">ساعت</p>
|
|
||||||
<p className="text-xl font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
|
|
||||||
<p className="text-sm text-gray-500">UV Index</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Wind className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-600 font-medium">باد</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
|
|
||||||
<p className="text-sm text-gray-500">کیلومتر/ساعت</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
// Daily report UI components only
|
||||||
export { SummaryCard } from './SummaryCard'
|
export { SummaryCard } from './SummaryCard'
|
||||||
export { SummaryTab } from './SummaryTab'
|
export { SummaryTab } from './SummaryTab'
|
||||||
export { TimeRangeSelector } from './TimeRangeSelector'
|
export { TimeRangeSelector } from './TimeRangeSelector'
|
||||||
export { ChartsTab } from './ChartsTab'
|
export { ChartsTab } from './ChartsTab'
|
||||||
export { WeatherTab } from './WeatherTab'
|
export { WeatherTab } from './WeatherTab'
|
||||||
export { AnalysisTab } from './AnalysisTab'
|
export { AnalysisTab } from './AnalysisTab'
|
||||||
export * from './types'
|
export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
|
||||||
export * from './utils'
|
|
||||||
export * from './weather-helpers'
|
|
||||||
|
|
||||||
|
|||||||
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'
|
import { Cloud, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-react'
|
||||||
|
import { TelemetryDto } from '@/lib/api'
|
||||||
// Format date to yyyy/MM/dd
|
import { NormalizedTelemetry } from './types'
|
||||||
export function formatPersianDate(year: number, month: number, day: number): string {
|
|
||||||
const mm = month.toString().padStart(2, '0')
|
|
||||||
const dd = day.toString().padStart(2, '0')
|
|
||||||
return `${year}/${mm}/${dd}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure date string is in yyyy/MM/dd format
|
|
||||||
export function ensureDateFormat(dateStr: string): string {
|
|
||||||
const parts = dateStr.split('/')
|
|
||||||
if (parts.length !== 3) return dateStr
|
|
||||||
const [year, month, day] = parts.map(Number)
|
|
||||||
return formatPersianDate(year, month, day)
|
|
||||||
}
|
|
||||||
|
|
||||||
// تابع تبدیل ارقام انگلیسی به فارسی
|
|
||||||
export function toPersianDigits(num: number | string): string {
|
|
||||||
const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']
|
|
||||||
return num.toString().replace(/\d/g, (digit) => persianDigits[parseInt(digit)])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weather code to description and icon mapping
|
// Weather code to description and icon mapping
|
||||||
export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
|
export const weatherCodeMap: Record<number, { description: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||||
@@ -169,3 +150,76 @@ export function fillGapsWithNull<T>(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert gaps in data (alternative implementation used in charts)
|
||||||
|
export function insertGapsInData(
|
||||||
|
data: number[],
|
||||||
|
timestamps: string[],
|
||||||
|
gaps: DataGap[]
|
||||||
|
): (number | null)[] {
|
||||||
|
if (gaps.length === 0 || data.length < 2) return data
|
||||||
|
|
||||||
|
const result: (number | null)[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
result.push(data[i])
|
||||||
|
|
||||||
|
if (i < data.length - 1) {
|
||||||
|
const cur = new Date(timestamps[i])
|
||||||
|
const next = new Date(timestamps[i + 1])
|
||||||
|
|
||||||
|
const curMin = cur.getHours() * 60 + cur.getMinutes()
|
||||||
|
const nextMin = next.getHours() * 60 + next.getMinutes()
|
||||||
|
|
||||||
|
const hasGap = gaps.some(
|
||||||
|
g => curMin <= g.startMinute && nextMin >= g.endMinute
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasGap) result.push(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate min/max for chart axes with step rounding
|
||||||
|
export function calcMinMax(
|
||||||
|
data: (number | null)[],
|
||||||
|
defaultMin: number,
|
||||||
|
defaultMax: number,
|
||||||
|
step: number
|
||||||
|
) {
|
||||||
|
const valid = data.filter((v): v is number => v !== null)
|
||||||
|
if (!valid.length) return { min: defaultMin, max: defaultMax }
|
||||||
|
|
||||||
|
const min = Math.min(...valid)
|
||||||
|
const max = Math.max(...valid)
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: min < defaultMin ? Math.floor(min / step) * step : defaultMin,
|
||||||
|
max: max > defaultMax ? Math.ceil(max / step) * step : defaultMax,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize telemetry data
|
||||||
|
export function normalizeTelemetryData(telemetry: TelemetryDto[]): NormalizedTelemetry[] {
|
||||||
|
return telemetry.map(t => {
|
||||||
|
const ts = t.serverTimestampUtc || t.timestampUtc
|
||||||
|
const d = new Date(ts)
|
||||||
|
|
||||||
|
const h = d.getHours()
|
||||||
|
const m = d.getMinutes()
|
||||||
|
const s = d.getSeconds()
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: ts,
|
||||||
|
minute: h * 60 + m,
|
||||||
|
label: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`,
|
||||||
|
soil: Number(t.soilPercent ?? 0),
|
||||||
|
temp: Number(t.temperatureC ?? 0),
|
||||||
|
hum: Number(t.humidityPercent ?? 0),
|
||||||
|
gas: Number(t.gasPPM ?? 0),
|
||||||
|
lux: Number(t.lux ?? 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
159
src/features/weather/api.ts
Normal file
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 { Thermometer, Sun, Droplets, Wind, Leaf } from 'lucide-react'
|
||||||
import { WeatherData, GreenhouseAlert } from './types'
|
import { WeatherData, GreenhouseAlert } from './types'
|
||||||
import { toPersianDigits } from './utils'
|
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||||
|
|
||||||
// Qom coordinates
|
// Kahak Qom coordinates
|
||||||
export const QOM_LAT = 34.6416
|
export const QOM_LAT = 34.39674800
|
||||||
export const QOM_LON = 50.8746
|
export const QOM_LON = 50.86594800
|
||||||
|
|
||||||
// Greenhouse-specific recommendations
|
// Greenhouse-specific recommendations
|
||||||
export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] {
|
export function getGreenhouseAlerts(weather: WeatherData): GreenhouseAlert[] {
|
||||||
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 = {
|
export type WeatherData = {
|
||||||
current: {
|
current: {
|
||||||
temperature: number
|
temperature: number
|
||||||
@@ -34,10 +32,3 @@ export type GreenhouseAlert = {
|
|||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TABS: { value: TabType; label: string }[] = [
|
|
||||||
{ value: 'summary', label: 'خلاصه' },
|
|
||||||
{ value: 'charts', label: 'نمودار' },
|
|
||||||
{ value: 'weather', label: 'آب و هوا' },
|
|
||||||
{ value: 'analysis', label: 'تحلیل' },
|
|
||||||
]
|
|
||||||
|
|
||||||
24
src/hooks/useDebounce.ts
Normal file
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"
|
"use client"
|
||||||
|
|
||||||
export type DeviceDto = {
|
import type {
|
||||||
id: number
|
DeviceDto,
|
||||||
deviceName: string
|
TelemetryDto,
|
||||||
userId: number
|
DeviceSettingsDto,
|
||||||
userName: string
|
SendCodeRequest,
|
||||||
userFamily: string
|
SendCodeResponse,
|
||||||
userMobile: string
|
VerifyCodeRequest,
|
||||||
location: string
|
VerifyCodeResponse,
|
||||||
neshanLocation: string
|
PagedResult,
|
||||||
}
|
DailyReportDto,
|
||||||
|
AlertConditionDto,
|
||||||
export type TelemetryDto = {
|
CreateAlertConditionDto,
|
||||||
id: number
|
UpdateAlertConditionDto,
|
||||||
deviceId: number
|
} from './types'
|
||||||
timestampUtc: string
|
|
||||||
temperatureC: number
|
|
||||||
humidityPercent: number
|
|
||||||
soilPercent: number
|
|
||||||
gasPPM: number
|
|
||||||
lux: number
|
|
||||||
persianYear: number
|
|
||||||
persianMonth: number
|
|
||||||
persianDate: string
|
|
||||||
deviceName?: string
|
|
||||||
serverTimestampUtc?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeviceSettingsDto = {
|
|
||||||
id: number
|
|
||||||
deviceId: number
|
|
||||||
deviceName: string
|
|
||||||
dangerMaxTemperature: number
|
|
||||||
dangerMinTemperature: number
|
|
||||||
maxTemperature: number
|
|
||||||
minTemperature: number
|
|
||||||
maxGasPPM: number
|
|
||||||
minGasPPM: number
|
|
||||||
maxLux: number
|
|
||||||
minLux: number
|
|
||||||
maxHumidityPercent: number
|
|
||||||
minHumidityPercent: number
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SendCodeRequest = {
|
|
||||||
mobile: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SendCodeResponse = {
|
|
||||||
success: boolean
|
|
||||||
message?: string
|
|
||||||
resendAfterSeconds: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VerifyCodeRequest = {
|
|
||||||
mobile: string
|
|
||||||
code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VerifyCodeResponse = {
|
|
||||||
success: boolean
|
|
||||||
message?: string
|
|
||||||
token?: string
|
|
||||||
user?: {
|
|
||||||
id: number
|
|
||||||
mobile: string
|
|
||||||
name: string
|
|
||||||
family: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PagedResult<T> = {
|
|
||||||
items: T[]
|
|
||||||
totalCount: number
|
|
||||||
page: number
|
|
||||||
pageSize: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DailyReportDto = {
|
|
||||||
id: number
|
|
||||||
deviceId: number
|
|
||||||
deviceName: string
|
|
||||||
persianDate: string
|
|
||||||
analysis: string
|
|
||||||
recordCount: number
|
|
||||||
sampledRecordCount: number
|
|
||||||
totalTokens: number
|
|
||||||
createdAt: string
|
|
||||||
fromCache: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AlertRuleDto = {
|
|
||||||
id: number
|
|
||||||
alertConditionId: number
|
|
||||||
sensorType: 0 | 1 | 2 | 3 | 4 // Temperature=0, Humidity=1, Soil=2, Gas=3, Lux=4
|
|
||||||
comparisonType: 0 | 1 | 2 | 3 // GreaterThan=0, LessThan=1, Between=2, OutOfRange=3
|
|
||||||
value1: number
|
|
||||||
value2?: number // برای Between و OutOfRange
|
|
||||||
order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateAlertRuleRequest = {
|
|
||||||
sensorType: 0 | 1 | 2 | 3 | 4
|
|
||||||
comparisonType: 0 | 1 | 2 | 3
|
|
||||||
value1: number
|
|
||||||
value2?: number
|
|
||||||
order: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AlertConditionDto = {
|
|
||||||
id: number
|
|
||||||
deviceId: number
|
|
||||||
deviceName: string
|
|
||||||
notificationType: 0 | 1 // Call=0, SMS=1
|
|
||||||
timeType: 0 | 1 | 2 // Day=0, Night=1, Always=2
|
|
||||||
callCooldownMinutes: number
|
|
||||||
smsCooldownMinutes: number
|
|
||||||
isEnabled: boolean
|
|
||||||
rules: AlertRuleDto[]
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateAlertConditionDto = {
|
|
||||||
deviceId: number
|
|
||||||
notificationType: 0 | 1
|
|
||||||
timeType: 0 | 1 | 2
|
|
||||||
callCooldownMinutes?: number
|
|
||||||
smsCooldownMinutes?: number
|
|
||||||
isEnabled: boolean
|
|
||||||
rules: CreateAlertRuleRequest[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateAlertConditionDto = {
|
|
||||||
id: number
|
|
||||||
notificationType: 0 | 1
|
|
||||||
timeType: 0 | 1 | 2
|
|
||||||
callCooldownMinutes?: number
|
|
||||||
smsCooldownMinutes?: number
|
|
||||||
isEnabled: boolean
|
|
||||||
rules: CreateAlertRuleRequest[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
|
||||||
|
|
||||||
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
|
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -224,3 +96,4 @@ export const api = {
|
|||||||
deleteAlertCondition: (id: number) =>
|
deleteAlertCondition: (id: number) =>
|
||||||
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
|
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
4
src/lib/api/index.ts
Normal file
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)
|
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