fix ui and add powerOutage list
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Has been cancelled
Some checks failed
Deploy MyApp on Same Server / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = 'greenhome-1778524870692';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1778524870692';
|
||||
const CACHE_NAME = 'greenhome-1780331120215';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1780331120215';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_FILES_TO_CACHE = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "1778524870692"
|
||||
"version": "1780331120215"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
||||
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
||||
@@ -13,22 +13,10 @@ import { AppShell } from '@/components/layout'
|
||||
|
||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
const [value, setValue] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
setValue(params.get(name))
|
||||
}
|
||||
}, [name])
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
function CalendarContent() {
|
||||
const router = useRouter()
|
||||
const deviceIdParam = useQueryParam('deviceId')
|
||||
const searchParams = useSearchParams()
|
||||
const deviceIdParam = searchParams.get('deviceId')
|
||||
const [deviceId, setDeviceId] = useState<number>(1)
|
||||
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
||||
const [activeMonths, setActiveMonths] = useState<number[]>([])
|
||||
@@ -129,4 +117,12 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری تقویم..." />}>
|
||||
<CalendarContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api, TelemetryDto } from '@/lib/api'
|
||||
import { api, TelemetryDto, PowerOutageSummaryDto } from '@/lib/api'
|
||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
|
||||
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
|
||||
import { TABS, TabType } from '@/features/daily-report'
|
||||
import { detectDataGaps } from '@/features/daily-report/utils'
|
||||
import { BarChart3, CalendarIcon, ChevronRight } from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
import { SummaryTab } from '@/components/daily-report'
|
||||
import { SummaryTab, LiveSummaryCards } from '@/components/daily-report'
|
||||
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
|
||||
import { Tabs, Button } from '@/components/common'
|
||||
import { DateNavigation } from '@/components/navigation'
|
||||
@@ -29,6 +29,8 @@ function DailyReportContent() {
|
||||
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
|
||||
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
||||
const [visibleParams, setVisibleParams] = useState<string[] | null>(null)
|
||||
const [powerOutageSummary, setPowerOutageSummary] = useState<PowerOutageSummaryDto | null>(null)
|
||||
const [powerOutageLoading, setPowerOutageLoading] = useState(false)
|
||||
|
||||
// Map summary param to chart key
|
||||
const paramToChartKey = useCallback((param: string): string => {
|
||||
@@ -131,6 +133,38 @@ function DailyReportContent() {
|
||||
}
|
||||
}, [deviceId, selectedDate])
|
||||
|
||||
// Load power outage summary
|
||||
useEffect(() => {
|
||||
if (!selectedDate) {
|
||||
setPowerOutageSummary(null)
|
||||
return
|
||||
}
|
||||
|
||||
const loadPowerOutage = async () => {
|
||||
setPowerOutageLoading(true)
|
||||
try {
|
||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||
const startDate = persianToGregorian(year, month, day)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
|
||||
const startUtc = startDate.toISOString()
|
||||
const endUtc = endDate.toISOString()
|
||||
|
||||
const summary = await api.getPowerOutageSummary(deviceId, startUtc, endUtc)
|
||||
setPowerOutageSummary(summary)
|
||||
} catch (error) {
|
||||
console.error('Error loading power outage summary:', error)
|
||||
setPowerOutageSummary(null)
|
||||
} finally {
|
||||
setPowerOutageLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadPowerOutage()
|
||||
}, [deviceId, selectedDate])
|
||||
|
||||
// Pull-to-refresh for mobile
|
||||
usePullToRefresh(loadData)
|
||||
|
||||
@@ -189,6 +223,27 @@ function DailyReportContent() {
|
||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
||||
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
||||
|
||||
// Latest values for live summary
|
||||
const latestValues = useMemo(() => ({
|
||||
temperature: temp.length > 0 ? temp.at(-1) ?? 0 : 0,
|
||||
humidity: hum.length > 0 ? hum.at(-1) ?? 0 : 0,
|
||||
soil: soil.length > 0 ? soil.at(-1) ?? 0 : 0,
|
||||
gas: gas.length > 0 ? gas.at(-1) ?? 0 : 0,
|
||||
lux: lux.length > 0 ? lux.at(-1) ?? 0 : 0,
|
||||
}), [temp, hum, soil, gas, lux])
|
||||
|
||||
// Last updated time from the most recent telemetry entry
|
||||
const lastUpdated = useMemo(() => {
|
||||
if (sortedTelemetry.length === 0) return undefined
|
||||
const lastTimestamp = sortedTelemetry.at(-1)?.serverTimestampUtc || sortedTelemetry.at(-1)?.timestampUtc
|
||||
if (!lastTimestamp) return undefined
|
||||
const date = new Date(lastTimestamp)
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
}, [sortedTelemetry])
|
||||
|
||||
// Detect data gaps in the full day data
|
||||
const dataGaps = useMemo(() => {
|
||||
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||
@@ -247,7 +302,16 @@ function DailyReportContent() {
|
||||
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
|
||||
>
|
||||
{{
|
||||
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} onCardClick={handleCardClick} visibleParams={visibleParams} />,
|
||||
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} onCardClick={handleCardClick} visibleParams={visibleParams} powerOutageSummary={powerOutageSummary} powerOutageLoading={powerOutageLoading} />,
|
||||
live: <LiveSummaryCards
|
||||
temperature={latestValues.temperature}
|
||||
humidity={latestValues.humidity}
|
||||
soil={latestValues.soil}
|
||||
gas={latestValues.gas}
|
||||
lux={latestValues.lux}
|
||||
lastUpdated={lastUpdated}
|
||||
onCardClick={handleCardClick}
|
||||
/>,
|
||||
charts: (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} visibleParams={visibleParams} />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
/* --background: #ffffff; */
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* --color-background: var(--background); */
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
--font-mono: 'Vazirmatn', monospace;
|
||||
@@ -14,7 +12,6 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* --background: #0a0a0a; */
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
@@ -31,12 +28,11 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
/* background: var(--background); */
|
||||
background: linear-gradient(180deg, #eef7ff, #f6fbff);
|
||||
background: #f8fafc;
|
||||
color: var(--foreground);
|
||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
overscroll-behavior-y: contain; /* Prevent pull-to-refresh on Android */
|
||||
overscroll-behavior-y: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@@ -44,7 +40,7 @@ body {
|
||||
button, a, [role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
touch-action: manipulation; /* Disable double-tap zoom */
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@@ -56,7 +52,7 @@ button, a, [role="button"] {
|
||||
.transition-transform,
|
||||
.transition-opacity {
|
||||
will-change: transform, opacity;
|
||||
transform: translateZ(0); /* Force GPU acceleration */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.persian-number {
|
||||
@@ -176,4 +172,4 @@ button, a, [role="button"] {
|
||||
.has-bottom-nav {
|
||||
padding-bottom: 4.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ type Props = {
|
||||
|
||||
export function Panel({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) {
|
||||
return (
|
||||
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
|
||||
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-300 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
|
||||
@@ -77,19 +77,19 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
|
||||
const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل')
|
||||
|
||||
return (
|
||||
<div className={`group relative bg-white rounded-xl border-2 ${colors.border} shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden`}>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
<div className={`bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-150 overflow-hidden`}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-medium text-gray-500">
|
||||
{title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()}
|
||||
</div>
|
||||
{IconComponent && (
|
||||
<div className={`w-8 h-8 bg-gradient-to-br ${colors.iconBg} rounded-lg flex items-center justify-center shadow-sm`}>
|
||||
<IconComponent className="w-4 h-4 text-white" />
|
||||
<div className={`w-7 h-7 rounded-md ${colors.iconBg} flex items-center justify-center`}>
|
||||
<IconComponent className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-2">{value}</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">{value}</div>
|
||||
{subtitle && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap">
|
||||
{hasStats ? (
|
||||
@@ -109,7 +109,6 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r ${colors.gradient} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function Skeleton({ className, width, height, rounded = true }: SkeletonP
|
||||
// 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">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
<Skeleton height="1.5rem" width="60%" />
|
||||
<Skeleton height="1rem" />
|
||||
<Skeleton height="1rem" width="80%" />
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function UpdateNotification() {
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 z-50 animate-in slide-in-from-bottom-5">
|
||||
<div className="bg-white rounded-xl shadow-lg border-2 border-indigo-500 p-4 flex items-center gap-3">
|
||||
<div className="bg-white rounded-lg shadow-md border border-indigo-500 p-4 flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function AlertFormModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-1.5 text-xs bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-sm hover:shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-1.5 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function AlertListItem({ alert, onEdit, onDelete, onToggleActive }: Alert
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => onToggleActive(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-md text-sm font-medium transition-colors ${
|
||||
alert.isEnabled
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Bell } from 'lucide-react'
|
||||
import { SensorType, ComparisonType, NotificationType, TimeType } from './constants'
|
||||
import { generatePreviewText } from './utils'
|
||||
|
||||
@@ -12,14 +11,10 @@ type AlertPreviewProps = {
|
||||
|
||||
export function AlertPreview({ rules, notificationType, timeType }: AlertPreviewProps) {
|
||||
return (
|
||||
<div className="sticky top-[45px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg -mx-6 -mt-6">
|
||||
<div className="sticky top-[45px] z-10 bg-blue-600 -mx-6 -mt-6">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* <div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||
<Bell className="w-5 h-5 text-white" />
|
||||
</div> */}
|
||||
<div className="flex-1">
|
||||
{/* <div className="text-xs font-semibold text-white/90 mb-1.5">📢 پیشنمایش زنده</div> */}
|
||||
<div className="text-sm md:text-base text-white leading-relaxed font-medium">
|
||||
{generatePreviewText(rules, notificationType, timeType)}
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,9 @@ export function NotificationTypeSelector({ value, onChange }: NotificationTypeSe
|
||||
key={notif.value}
|
||||
type="button"
|
||||
onClick={() => onChange(notif.value)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all text-xs ${
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all duration-150 text-xs ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50 text-orange-700 shadow-sm'
|
||||
? 'border-orange-500 bg-orange-50 text-orange-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { SENSOR_TYPES, COMPARISON_TYPES, SensorType, ComparisonType } from './constants'
|
||||
import { getSensorIcon, getSensorLabel, getSensorUnit, getComparisonLabel } from './utils'
|
||||
@@ -13,16 +14,71 @@ type RuleFormCardProps = {
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
/** Validate and sanitize a decimal number string with max 1 decimal place */
|
||||
function sanitizeDecimal(input: string): string {
|
||||
// Remove any non-digit, non-dot, non-minus characters
|
||||
let cleaned = input.replace(/[^0-9.\-]/g, '')
|
||||
// Allow only one minus sign at the start
|
||||
const minusCount = (cleaned.match(/-/g) || []).length
|
||||
if (minusCount > 1) {
|
||||
cleaned = cleaned.replace(/-/g, '')
|
||||
if (input.startsWith('-')) cleaned = '-' + cleaned
|
||||
} else if (minusCount === 1 && !cleaned.startsWith('-')) {
|
||||
cleaned = cleaned.replace(/-/g, '')
|
||||
}
|
||||
// Allow only one decimal point
|
||||
const dotIndex = cleaned.indexOf('.')
|
||||
if (dotIndex !== -1) {
|
||||
const beforeDot = cleaned.slice(0, dotIndex + 1)
|
||||
const afterDot = cleaned.slice(dotIndex + 1).replace(/\./g, '')
|
||||
// Max 1 decimal place
|
||||
cleaned = beforeDot + afterDot.slice(0, 1)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: RuleFormCardProps) {
|
||||
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
||||
const SensorIcon = getSensorIcon(rule.sensorType)
|
||||
|
||||
// Helper to safely convert a number to string, handling null/undefined
|
||||
const toStr = (v: number | null | undefined): string => v != null ? String(v) : ''
|
||||
|
||||
// Local string state for each input to allow decimal point typing
|
||||
// without losing the dot on every keystroke (Number("5.") → 5 strips the dot)
|
||||
const [rawValue1, setRawValue1] = useState<string>(toStr(rule.value1))
|
||||
const [rawValue2, setRawValue2] = useState<string>(toStr(rule.value2))
|
||||
|
||||
// Track if each input is currently focused (being edited)
|
||||
const isFocused1 = useRef(false)
|
||||
const isFocused2 = useRef(false)
|
||||
|
||||
// Use the prop value when input is NOT focused (external updates)
|
||||
const displayValue1 = isFocused1.current ? rawValue1 : toStr(rule.value1)
|
||||
const displayValue2 = isFocused2.current ? rawValue2 : toStr(rule.value2)
|
||||
|
||||
const commitValue1 = useCallback((raw: string) => {
|
||||
const sanitized = sanitizeDecimal(raw)
|
||||
const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized)
|
||||
if (!isNaN(num)) {
|
||||
onUpdate(index, { value1: num })
|
||||
}
|
||||
}, [index, onUpdate])
|
||||
|
||||
const commitValue2 = useCallback((raw: string) => {
|
||||
const sanitized = sanitizeDecimal(raw)
|
||||
const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized)
|
||||
if (!isNaN(num)) {
|
||||
onUpdate(index, { value2: num })
|
||||
}
|
||||
}, [index, onUpdate])
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-orange-50 to-red-50 border border-orange-200 rounded-xl p-3 relative shadow-sm">
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
{/* Header - Compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-orange-700 bg-white/80 px-2 py-0.5 rounded-full shadow-sm">
|
||||
<span className="text-xs font-bold text-orange-700 bg-white px-2 py-0.5 rounded-md">
|
||||
{totalRules === 1 ? '⚡ شرط' : `⚡ شرط ${index + 1}`}
|
||||
</span>
|
||||
{/* Compact summary of current selection */}
|
||||
@@ -39,7 +95,7 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="p-1.5 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
|
||||
className="p-1.5 text-red-500 hover:bg-red-100 rounded-md transition-colors"
|
||||
title="حذف این شرط"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@@ -95,16 +151,18 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
|
||||
<div className="flex gap-1.5 items-center flex-1">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.value1}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
dir="ltr"
|
||||
value={displayValue1}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// Only update if value is not empty; otherwise keep previous value
|
||||
// to avoid Number('') → 0 breaking the input in some browsers
|
||||
if (val !== '') {
|
||||
onUpdate(index, { value1: Number(val) })
|
||||
}
|
||||
const sanitized = sanitizeDecimal(e.target.value)
|
||||
setRawValue1(sanitized)
|
||||
}}
|
||||
onFocus={() => { isFocused1.current = true }}
|
||||
onBlur={() => {
|
||||
isFocused1.current = false
|
||||
commitValue1(rawValue1)
|
||||
}}
|
||||
className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder={needsMax ? 'از' : 'مقدار'}
|
||||
@@ -119,16 +177,18 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
|
||||
<span className="text-gray-400 text-sm flex-shrink-0">-</span>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.value2 ?? ''}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
dir="ltr"
|
||||
value={displayValue2}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// Only update if value is not empty; otherwise keep previous value
|
||||
// to avoid Number('') → 0 breaking the input in some browsers
|
||||
if (val !== '') {
|
||||
onUpdate(index, { value2: Number(val) })
|
||||
}
|
||||
const sanitized = sanitizeDecimal(e.target.value)
|
||||
setRawValue2(sanitized)
|
||||
}}
|
||||
onFocus={() => { isFocused2.current = true }}
|
||||
onBlur={() => {
|
||||
isFocused2.current = false
|
||||
commitValue2(rawValue2)
|
||||
}}
|
||||
className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="تا"
|
||||
|
||||
@@ -23,9 +23,9 @@ export function TimeTypeSelector({ value, onChange }: TimeTypeSelectorProps) {
|
||||
type="button"
|
||||
onClick={() => onChange(time.value)}
|
||||
title={time.description}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all text-xs ${
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all duration-150 text-xs ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 shadow-sm'
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -16,22 +16,22 @@ export function WeatherAlertBanner({
|
||||
<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"
|
||||
className="w-full p-4 bg-amber-50 border border-amber-200 hover:border-amber-300 rounded-lg transition-all duration-150 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 className="w-9 h-9 bg-amber-500 rounded-md flex items-center justify-center group-hover:scale-105 transition-transform">
|
||||
<AlertTriangle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">
|
||||
<p className="font-semibold text-gray-800 text-sm">
|
||||
شما {toPersianDigits(alertsCount)} هشدار آب و هوایی برای روزهای آینده دارید
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
برای مشاهده جزئیات و توصیههای مدیریت گلخانه کلیک کنید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronLeft className="w-5 h-5 text-amber-600 group-hover:translate-x-[-4px] transition-transform" />
|
||||
<ChevronLeft className="w-4 h-4 text-amber-600 group-hover:translate-x-[-4px] transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export function WeekdayHeaders({
|
||||
}: 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',
|
||||
'grid grid-cols-7 text-center bg-gray-50 border-b border-gray-200',
|
||||
className
|
||||
)}>
|
||||
{weekdays.map((day) => (
|
||||
|
||||
@@ -21,7 +21,7 @@ export function YearSelector({
|
||||
انتخاب سال:
|
||||
</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"
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150 bg-white font-medium"
|
||||
value={selectedYear}
|
||||
onChange={(e) => onYearChange(Number(e.target.value))}
|
||||
>
|
||||
|
||||
@@ -21,12 +21,12 @@ export function CalendarDayCell({
|
||||
<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',
|
||||
'group min-h-[90px] md:min-h-[100px] bg-white border border-green-200 hover:border-green-400 transition-all duration-150 cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center',
|
||||
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">
|
||||
<div className="flex items-center gap-1 bg-green-600 text-white text-xs rounded-md px-2.5 py-1 font-medium">
|
||||
<Database className="w-3 h-3" />
|
||||
{recordCount}
|
||||
</div>
|
||||
|
||||
@@ -17,14 +17,14 @@ export function DeviceCard({ device, href, onClick, className }: DeviceCardProps
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); }}
|
||||
className={`group bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative cursor-pointer ${className || ''}`}
|
||||
className={`group bg-white rounded-lg border border-gray-200 hover:border-green-300 transition-all duration-150 overflow-hidden p-5 relative cursor-pointer ${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 className="flex-shrink-0 w-12 h-12 rounded-lg bg-green-600 flex items-center justify-center group-hover:scale-105 transition-transform duration-150">
|
||||
<Settings className="w-6 h-6 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">
|
||||
<h3 className="text-base 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">
|
||||
@@ -38,7 +38,6 @@ export function DeviceCard({ device, href, onClick, className }: DeviceCardProps
|
||||
<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" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -46,14 +45,14 @@ export function DeviceCard({ device, href, onClick, 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 || ''}`}
|
||||
className={`group bg-white rounded-lg border border-gray-200 hover:border-green-300 transition-all duration-150 overflow-hidden p-5 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 className="flex-shrink-0 w-12 h-12 rounded-lg bg-green-600 flex items-center justify-center group-hover:scale-105 transition-transform duration-150">
|
||||
<Settings className="w-6 h-6 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">
|
||||
<h3 className="text-base 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">
|
||||
@@ -67,7 +66,6 @@ export function DeviceCard({ device, href, onClick, className }: DeviceCardProps
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,32 +21,29 @@ export function MonthCard({
|
||||
onClick={onClick}
|
||||
disabled={!isActive}
|
||||
className={cn(
|
||||
'group relative rounded-xl border-2 p-5 text-center transition-all duration-300',
|
||||
'group relative rounded-lg border p-5 text-center transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
|
||||
? 'bg-white border-green-200 hover:border-green-400'
|
||||
: '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')}>
|
||||
<div className={cn('text-base 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">
|
||||
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-md 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">
|
||||
<div className="text-xs text-gray-500 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
}
|
||||
|
||||
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',
|
||||
default: 'bg-white border border-gray-300 hover:border-gray-400 hover:bg-gray-50 text-gray-700',
|
||||
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm',
|
||||
secondary: 'bg-white border border-orange-300 hover:border-orange-400 hover:bg-orange-50 text-orange-700',
|
||||
outline: 'border border-gray-300 hover:bg-gray-50 text-gray-700',
|
||||
ghost: 'hover:bg-gray-100 text-gray-700',
|
||||
}
|
||||
@@ -59,12 +59,11 @@ export function Button({
|
||||
|
||||
const buttonClasses = cn(
|
||||
'inline-flex items-center justify-center gap-1 sm:gap-2',
|
||||
'rounded-xl transition-all duration-200 font-medium',
|
||||
'rounded-lg transition-all duration-150 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
|
||||
'active:scale-[0.98]',
|
||||
'touch-manipulation',
|
||||
'select-none',
|
||||
sizeStyle.padding,
|
||||
sizeStyle.text,
|
||||
variantStyle,
|
||||
|
||||
@@ -23,9 +23,9 @@ export function Card({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'bg-white rounded-2xl shadow-md border border-gray-100',
|
||||
'bg-white rounded-lg border border-gray-200',
|
||||
paddingClasses[padding],
|
||||
hover && 'hover:shadow-xl transition-all duration-300',
|
||||
hover && 'hover:border-gray-300 hover:shadow-sm transition-all duration-200',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
|
||||
@@ -48,13 +48,13 @@ export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<div className="relative bg-white rounded-lg shadow-lg 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>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-gray-100 rounded-md transition-colors"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
|
||||
@@ -19,18 +19,18 @@ export function EmptyState({
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'bg-white rounded-2xl shadow-md border border-gray-100 p-8 md:p-12 text-center',
|
||||
'bg-white rounded-lg border border-gray-200 p-8 md:p-12 text-center',
|
||||
className
|
||||
)}>
|
||||
{Icon && (
|
||||
<div className="text-gray-300 mb-4">
|
||||
<Icon className="w-16 h-16 mx-auto" />
|
||||
<Icon className="w-14 h-14 mx-auto" />
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
)}
|
||||
<p className="text-gray-600 mb-6">{message}</p>
|
||||
<p className="text-sm text-gray-500 mb-6">{message}</p>
|
||||
{action && (
|
||||
<div className="flex justify-center">
|
||||
{action}
|
||||
|
||||
@@ -20,11 +20,11 @@ export function ErrorMessage({
|
||||
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="bg-white rounded-lg border border-gray-200 p-8 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>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">خطا</h2>
|
||||
<p className="text-gray-600 mb-6">{message}</p>
|
||||
{action}
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@ export function ErrorMessage({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-4 bg-red-50 border border-red-200 rounded-xl',
|
||||
'p-4 bg-red-50 border border-red-200 rounded-lg',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center gap-2 text-red-700 mb-3">
|
||||
@@ -44,7 +44,7 @@ export function ErrorMessage({
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 p-1 hover:bg-red-100 rounded transition-colors"
|
||||
className="flex-shrink-0 p-1 hover:bg-red-100 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function IconButton({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg transition-colors',
|
||||
'rounded-md transition-colors',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
|
||||
@@ -49,7 +49,7 @@ export function Modal({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-2xl shadow-2xl w-full max-h-[90vh] overflow-y-auto',
|
||||
'bg-white rounded-lg shadow-lg w-full max-h-[90vh] overflow-y-auto',
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
@@ -57,18 +57,18 @@ export function Modal({
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||
<h2 className="text-lg font-semibold 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"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(title ? 'p-6' : 'p-6', className)}>
|
||||
<div className={cn(title ? 'p-0' : 'p-0', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,17 +24,17 @@ export function PageHeader({
|
||||
<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',
|
||||
'inline-flex items-center justify-center w-10 h-10 rounded-lg',
|
||||
iconGradient
|
||||
)}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-gray-900">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -7,12 +7,12 @@ type SegmentTabProps<T extends string> = {
|
||||
|
||||
export function SegmentTab<T extends string>({ tabs, activeTab, className, setActiveTab }: SegmentTabProps<T>) {
|
||||
return (
|
||||
<div className={`bg-gray-100 rounded-xl p-1 flex ${className}`}>
|
||||
<div className={`bg-gray-100 rounded-lg 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 ${
|
||||
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-md transition-all duration-150 ${
|
||||
activeTab === tab.value
|
||||
? 'bg-white text-indigo-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SuccessMessage({
|
||||
}: SuccessMessageProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2',
|
||||
'p-4 bg-green-50 border border-green-200 rounded-lg text-green-700 flex items-center gap-2',
|
||||
className
|
||||
)}>
|
||||
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
||||
@@ -22,7 +22,7 @@ export function SuccessMessage({
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 p-1 hover:bg-green-100 rounded transition-colors"
|
||||
className="flex-shrink-0 p-1 hover:bg-green-100 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Tabs<T extends string>({
|
||||
className
|
||||
}: TabsProps<T>) {
|
||||
return (
|
||||
<div className={`bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden ${className || ''}`}>
|
||||
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className || ''}`}>
|
||||
{/* Segmented Control for Mobile */}
|
||||
<div className="p-3 md:p-6 md:pb-0">
|
||||
<SegmentTab
|
||||
@@ -38,10 +38,10 @@ export function Tabs<T extends string>({
|
||||
<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 ${
|
||||
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-150 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'
|
||||
? 'text-indigo-600 border-b-2 border-indigo-600'
|
||||
: 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
142
src/components/daily-report/LiveSummaryCards.tsx
Normal file
142
src/components/daily-report/LiveSummaryCards.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { Thermometer, Droplets, Sprout, Wind, Sun, Activity } from 'lucide-react'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import { paramConfig } from '@/features/daily-report/utils'
|
||||
|
||||
type LiveSummaryCardsProps = {
|
||||
temperature: number
|
||||
humidity: number
|
||||
soil: number
|
||||
gas: number
|
||||
lux: number
|
||||
lastUpdated?: string
|
||||
onCardClick?: (param: string) => void
|
||||
}
|
||||
|
||||
const cardMeta = {
|
||||
temperature: {
|
||||
icon: Thermometer,
|
||||
color: 'text-rose-600',
|
||||
bgColor: 'bg-rose-50',
|
||||
},
|
||||
humidity: {
|
||||
icon: Droplets,
|
||||
color: 'text-sky-600',
|
||||
bgColor: 'bg-sky-50',
|
||||
},
|
||||
soil: {
|
||||
icon: Sprout,
|
||||
color: 'text-emerald-600',
|
||||
bgColor: 'bg-emerald-50',
|
||||
},
|
||||
gas: {
|
||||
icon: Wind,
|
||||
color: 'text-amber-600',
|
||||
bgColor: 'bg-amber-50',
|
||||
},
|
||||
lux: {
|
||||
icon: Sun,
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
},
|
||||
} as const
|
||||
|
||||
type ParamKey = keyof typeof cardMeta
|
||||
|
||||
export function LiveSummaryCards({
|
||||
temperature,
|
||||
humidity,
|
||||
soil,
|
||||
gas,
|
||||
lux,
|
||||
lastUpdated,
|
||||
onCardClick,
|
||||
}: LiveSummaryCardsProps) {
|
||||
const values: Record<ParamKey, number> = {
|
||||
temperature,
|
||||
humidity,
|
||||
soil,
|
||||
gas,
|
||||
lux,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-md bg-indigo-100 flex items-center justify-center">
|
||||
<Activity className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">خلاصه لحظهای</h2>
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<span className="text-[11px] text-gray-400 bg-gray-50 px-2.5 py-1 rounded-md border border-gray-200">
|
||||
آخرین بهروزرسانی: {toPersianDigits(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
|
||||
{(Object.keys(cardMeta) as ParamKey[]).map((key) => {
|
||||
const meta = cardMeta[key]
|
||||
const config = paramConfig[key]
|
||||
const Icon = meta.icon
|
||||
const value = values[key]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onCardClick?.(key)}
|
||||
className={`
|
||||
bg-white border border-gray-200
|
||||
rounded-lg p-3.5
|
||||
flex flex-col items-center gap-2
|
||||
transition-all duration-150
|
||||
hover:border-gray-300 hover:shadow-sm
|
||||
active:scale-[0.98]
|
||||
group
|
||||
text-right
|
||||
w-full
|
||||
`}
|
||||
style={{ minHeight: 'auto', minWidth: 0 }}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className={`
|
||||
w-9 h-9 rounded-lg
|
||||
${meta.bgColor}
|
||||
flex items-center justify-center
|
||||
group-hover:scale-105
|
||||
transition-transform duration-150
|
||||
`}>
|
||||
<Icon className={`w-4.5 h-4.5 ${meta.color}`} />
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-gray-900"
|
||||
style={{ color: config?.chartColor }}>
|
||||
{toPersianDigits(
|
||||
key === 'temperature'
|
||||
? value.toFixed(1)
|
||||
: Math.round(value).toString()
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||
{config?.unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-[10px] font-medium text-gray-500">
|
||||
{config?.title}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
src/components/daily-report/PowerOutageCard.tsx
Normal file
171
src/components/daily-report/PowerOutageCard.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Zap, Clock, PowerOff, Power, AlertTriangle } from 'lucide-react'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import type { PowerOutageSummaryDto } from '@/lib/api'
|
||||
|
||||
type PowerOutageCardProps = {
|
||||
summary: PowerOutageSummaryDto | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds === null) return 'ادامه دارد'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${toPersianDigits(String(hours))} ساعت و ${toPersianDigits(String(minutes))} دقیقه`
|
||||
}
|
||||
return `${toPersianDigits(String(minutes))} دقیقه`
|
||||
}
|
||||
|
||||
function formatTime(utcString: string): string {
|
||||
const date = new Date(utcString)
|
||||
// Convert to Iran time (UTC+3:30)
|
||||
const iranOffset = 3.5 * 60 * 60 * 1000
|
||||
const iranTime = new Date(date.getTime() + iranOffset)
|
||||
const hours = String(iranTime.getUTCHours()).padStart(2, '0')
|
||||
const minutes = String(iranTime.getUTCMinutes()).padStart(2, '0')
|
||||
return `${toPersianDigits(hours)}:${toPersianDigits(minutes)}`
|
||||
}
|
||||
|
||||
export function PowerOutageCard({ summary, loading }: PowerOutageCardProps) {
|
||||
const totalDowntimeFormatted = useMemo(() => {
|
||||
if (!summary) return '—'
|
||||
return formatDuration(summary.totalDowntimeSeconds)
|
||||
}, [summary])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-md bg-amber-200/50 animate-pulse" />
|
||||
<div className="h-4 w-24 bg-amber-200/50 rounded animate-pulse" />
|
||||
</div>
|
||||
{/* Stats skeleton */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 h-14 bg-white/50 rounded-lg animate-pulse" />
|
||||
<div className="flex-1 h-14 bg-white/50 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary || summary.totalOutageCount === 0) {
|
||||
return (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-md bg-emerald-100 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">وضعیت برق</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 bg-white rounded-lg px-3.5 py-3 border border-emerald-100">
|
||||
<div className="w-7 h-7 rounded-md bg-emerald-100 flex items-center justify-center">
|
||||
<Power className="w-3.5 h-3.5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-emerald-700">برق وصل است</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">هیچ قطعی برقی در این روز ثبت نشده</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-md bg-amber-100 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">قطعی برق</h3>
|
||||
{summary.totalOutageCount > 0 && (
|
||||
<span className="mr-auto bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
{toPersianDigits(String(summary.totalOutageCount))} قطعی
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex-1 bg-white rounded-lg px-3 py-2.5 border border-amber-100">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-600" />
|
||||
<span className="text-[10px] text-gray-500">مدت قطعی کل</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-800">
|
||||
{totalDowntimeFormatted}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-lg px-3 py-2.5 border border-amber-100">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
|
||||
<span className="text-[10px] text-gray-500">تعداد قطعی</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-800">
|
||||
{toPersianDigits(String(summary.totalOutageCount))} بار
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outage List */}
|
||||
{summary.outages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium text-gray-500 mr-1">زمانبندی قطعیها</p>
|
||||
<div className="space-y-1.5">
|
||||
{summary.outages.map((outage, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-lg px-3 py-2.5 border border-amber-100 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center ${
|
||||
outage.connectedAt
|
||||
? 'bg-red-100'
|
||||
: 'bg-red-200'
|
||||
}`}>
|
||||
<PowerOff className={`w-3.5 h-3.5 ${
|
||||
outage.connectedAt
|
||||
? 'text-red-500'
|
||||
: 'text-red-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-800">
|
||||
از ساعت {formatTime(outage.disconnectedAt)}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
{outage.connectedAt
|
||||
? `تا ساعت ${formatTime(outage.connectedAt)}`
|
||||
: 'هنوز وصل نشده'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className={`text-[11px] font-bold ${
|
||||
outage.durationSeconds !== null && outage.durationSeconds > 3600
|
||||
? 'text-red-600'
|
||||
: 'text-amber-700'
|
||||
}`}>
|
||||
{formatDuration(outage.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,13 +36,13 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
||||
<div
|
||||
className={`${config.bgColor} rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-150 overflow-hidden ${onClick ? 'cursor-pointer hover:shadow-sm' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-800">{config.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Gauge on left, Stats on right */}
|
||||
@@ -55,7 +55,7 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
|
||||
|
||||
{/* Stats on right - stacked */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 bg-white rounded-md px-2 py-1.5 border border-gray-100">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-500 leading-tight">حداکثر</span>
|
||||
@@ -64,7 +64,7 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 bg-white rounded-md px-2 py-1.5 border border-gray-100">
|
||||
<TrendingDown className="w-3.5 h-3.5 text-red-600 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-500 leading-tight">حداقل</span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { SummaryCard } from './SummaryCard'
|
||||
import { PowerOutageCard } from './PowerOutageCard'
|
||||
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'
|
||||
import type { PowerOutageSummaryDto } from '@/lib/api'
|
||||
|
||||
type SummaryTabProps = {
|
||||
temperature: number[]
|
||||
@@ -16,9 +18,11 @@ type SummaryTabProps = {
|
||||
forecastWeatherLoading?: boolean
|
||||
onCardClick?: (param: string) => void
|
||||
visibleParams?: string[] | null
|
||||
powerOutageSummary?: PowerOutageSummaryDto | null
|
||||
powerOutageLoading?: boolean
|
||||
}
|
||||
|
||||
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick, visibleParams }: SummaryTabProps) {
|
||||
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick, visibleParams, powerOutageSummary = null, powerOutageLoading = false }: SummaryTabProps) {
|
||||
const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
|
||||
|
||||
const alertsCount = useMemo(() => {
|
||||
@@ -75,16 +79,16 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
{/* 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="w-full p-4 bg-amber-50 border border-amber-200 rounded-lg 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 className="w-9 h-9 bg-amber-100 rounded-md flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 text-amber-600 animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">
|
||||
در حال بارگذاری هشدارهای آب و هوایی...
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
لطفاً صبر کنید
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,6 +160,14 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Power Outage Card */}
|
||||
<div className="mt-6">
|
||||
<PowerOutageCard
|
||||
summary={powerOutageSummary}
|
||||
loading={powerOutageLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alerts Dialog */}
|
||||
{forecastWeather && (
|
||||
<Dialog
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Daily report UI components only
|
||||
export { SummaryCard } from './SummaryCard'
|
||||
export { SummaryTab } from './SummaryTab'
|
||||
export { LiveSummaryCards } from './LiveSummaryCards'
|
||||
export { TimeRangeSelector } from './TimeRangeSelector'
|
||||
export { ChartsTab } from './ChartsTab'
|
||||
export { WeatherTab } from './WeatherTab'
|
||||
export { AnalysisTab } from './AnalysisTab'
|
||||
export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
|
||||
export { PowerOutageCard } from './PowerOutageCard'
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
|
||||
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="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="bg-blue-600 p-5">
|
||||
<div className="text-center text-white">
|
||||
<p className="text-lg opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
|
||||
<p className="text-2xl font-bold">{selectedDate}</p>
|
||||
<p className="text-sm opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
|
||||
<p className="text-xl font-bold">{selectedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Grid */}
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 grid grid-cols-2 gap-3">
|
||||
<TemperatureCard weatherData={weatherData} isToday={false} />
|
||||
<PrecipitationHistoricalCard weatherData={weatherData} />
|
||||
<SunlightCard weatherData={weatherData} />
|
||||
@@ -29,4 +29,3 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-xl border-r-4 ${
|
||||
className={`p-4 rounded-lg 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' :
|
||||
@@ -56,30 +56,30 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
)}
|
||||
|
||||
{/* 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="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="bg-emerald-600 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-white">
|
||||
<p className="text-lg opacity-90 mb-1">🌡️ الان</p>
|
||||
<p className="text-sm opacity-90 mb-1">🌡️ الان</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-6xl font-bold">
|
||||
<span className="text-5xl font-bold">
|
||||
{toPersianDigits(Math.round(weatherData.current.temperature))}
|
||||
</span>
|
||||
<span className="text-3xl">درجه</span>
|
||||
<span className="text-2xl">درجه</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-white">
|
||||
{(() => {
|
||||
const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
|
||||
return <IconComponent className="w-20 h-20" />
|
||||
return <IconComponent className="w-16 h-16" />
|
||||
})()}
|
||||
<p className="text-lg mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
|
||||
<p className="text-sm mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Grid */}
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 grid grid-cols-2 gap-3">
|
||||
<TemperatureCard weatherData={weatherData} isToday={true} />
|
||||
<PrecipitationForecastCard weatherData={weatherData} />
|
||||
<SunlightCard weatherData={weatherData} />
|
||||
@@ -88,9 +88,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
</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">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="bg-indigo-600 p-4">
|
||||
<h4 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
🕐 وضعیت ساعت به ساعت امروز
|
||||
</h4>
|
||||
</div>
|
||||
@@ -108,9 +108,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
return (
|
||||
<div
|
||||
key={hour.time}
|
||||
className={`flex flex-col items-center p-3 rounded-xl min-w-[85px] transition-all ${
|
||||
className={`flex flex-col items-center p-3 rounded-lg min-w-[80px] transition-all ${
|
||||
isNow
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-500 text-white shadow-lg scale-105'
|
||||
? 'bg-emerald-500 text-white shadow-sm scale-105'
|
||||
: isHot
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: isCold
|
||||
@@ -122,8 +122,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
|
||||
</p>
|
||||
|
||||
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
||||
<IconComponent className={`w-6 h-6 ${
|
||||
<div className={`my-2 p-1.5 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
||||
<IconComponent className={`w-5 h-5 ${
|
||||
isNow ? 'text-white' :
|
||||
isHot ? 'text-red-500' :
|
||||
isCold ? 'text-blue-500' :
|
||||
@@ -131,7 +131,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<p className={`text-2xl font-bold ${
|
||||
<p className={`text-xl font-bold ${
|
||||
isNow ? 'text-white' :
|
||||
isHot ? 'text-red-600' :
|
||||
isCold ? 'text-blue-600' :
|
||||
@@ -160,8 +160,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
|
||||
{/* 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 className="text-base font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4 text-emerald-500" />
|
||||
پیشبینی ۷ روز آینده
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
@@ -174,10 +174,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
const isExpanded = expandedDayIndex === index
|
||||
|
||||
return (
|
||||
<div key={day.date} className="overflow-hidden rounded-xl">
|
||||
<div key={day.date} className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<button
|
||||
onClick={() => onDayToggle(isExpanded ? null : index)}
|
||||
className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
|
||||
className={`w-full flex items-center justify-between p-4 transition-all duration-150 ${
|
||||
isExpanded
|
||||
? 'bg-emerald-500 text-white'
|
||||
: isToday
|
||||
@@ -190,10 +190,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
isExpanded ? 'bg-white/20' : 'bg-white'
|
||||
}`}>
|
||||
<IconComponent className={`w-6 h-6 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
|
||||
<IconComponent className={`w-5 h-5 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div className="text-right flex-1">
|
||||
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||
@@ -206,53 +206,53 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||
<p className={`text-xl 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'}`} />
|
||||
<ChevronDown className={`w-4 h-4 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="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<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>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
|
||||
<p className="text-xs text-gray-500">حداکثر</p>
|
||||
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
|
||||
<p className="text-xs text-gray-500">حداقل</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<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>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
|
||||
<p className="text-xs text-gray-500">احتمال</p>
|
||||
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
|
||||
<p className="text-xs text-gray-500">میلیمتر</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<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>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
|
||||
<p className="text-xs text-gray-500">ساعت</p>
|
||||
<p className="text-lg font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
|
||||
<p className="text-xs text-gray-500">UV Index</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<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>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
|
||||
<p className="text-xs text-gray-500">کیلومتر/ساعت</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,4 +265,3 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,22 +18,22 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
|
||||
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={`rounded-lg p-4 border ${
|
||||
isCold ? 'bg-blue-50 border-blue-200' :
|
||||
isHot ? 'bg-red-50 border-red-200' :
|
||||
'bg-green-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
isCold ? 'bg-blue-500' :
|
||||
isHot ? 'bg-red-500' :
|
||||
'bg-green-500'
|
||||
}`}>
|
||||
<Thermometer className="w-8 h-8 text-white" />
|
||||
<Thermometer className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
|
||||
<p className={`text-sm ${
|
||||
<p className="text-sm font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
|
||||
<p className={`text-xs ${
|
||||
isCold ? 'text-blue-600' :
|
||||
isHot ? 'text-red-600' :
|
||||
'text-green-600'
|
||||
@@ -47,12 +47,12 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
|
||||
<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>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMin))}°</p>
|
||||
</div>
|
||||
<div className="text-3xl text-gray-300">←</div>
|
||||
<div className="text-2xl 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>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMax))}°</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,25 +69,25 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
|
||||
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={`rounded-lg p-4 border ${
|
||||
isHigh ? 'bg-blue-50 border-blue-200' :
|
||||
isMedium ? 'bg-sky-50 border-sky-200' :
|
||||
'bg-amber-50 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 ${
|
||||
<div className={`w-10 h-10 rounded-lg 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" />
|
||||
<CloudRain className="w-5 h-5 text-white" /> :
|
||||
<Sun className="w-5 h-5 text-white" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-sm ${
|
||||
<p className="text-sm font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-xs ${
|
||||
isHigh ? 'text-blue-600' :
|
||||
isMedium ? 'text-sky-600' :
|
||||
'text-amber-600'
|
||||
@@ -99,8 +99,8 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
|
||||
</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>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
|
||||
<p className="text-xs text-gray-500">احتمال بارش</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -116,25 +116,25 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
|
||||
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={`rounded-lg p-4 border ${
|
||||
isHigh ? 'bg-blue-50 border-blue-200' :
|
||||
hasPrecipitation ? 'bg-sky-50 border-sky-200' :
|
||||
'bg-amber-50 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 ${
|
||||
<div className={`w-10 h-10 rounded-lg 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" />
|
||||
<CloudRain className="w-5 h-5 text-white" /> :
|
||||
<Sun className="w-5 h-5 text-white" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-sm ${
|
||||
<p className="text-sm font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-xs ${
|
||||
isHigh ? 'text-blue-600' :
|
||||
hasPrecipitation ? 'text-sky-600' :
|
||||
'text-amber-600'
|
||||
@@ -146,8 +146,8 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
|
||||
</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>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
|
||||
<p className="text-xs text-gray-500">میلیمتر بارش</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -161,14 +161,14 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
|
||||
const uvIndex = weatherData.daily[0]?.uvIndexMax || 0
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-2xl p-4">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg 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 className="w-10 h-10 rounded-lg bg-yellow-400 flex items-center justify-center">
|
||||
<Sun className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
|
||||
<p className="text-sm text-yellow-600">
|
||||
<p className="text-sm font-bold text-gray-800">نور آفتاب</p>
|
||||
<p className="text-xs text-yellow-600">
|
||||
{sunshineHours > 8 ? '☀️ آفتاب زیاد' :
|
||||
sunshineHours > 4 ? '🌤️ آفتاب متوسط' :
|
||||
'☁️ کمآفتاب'}
|
||||
@@ -177,11 +177,11 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
|
||||
</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>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
|
||||
<p className="text-xs 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-xl font-bold text-orange-500">{toPersianDigits(Math.round(uvIndex))}</p>
|
||||
<p className="text-xs text-gray-500">شاخص UV</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,14 +197,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
|
||||
const humidity = weatherData.current.humidity
|
||||
|
||||
return (
|
||||
<div className="bg-cyan-50 border-2 border-cyan-200 rounded-2xl p-4">
|
||||
<div className="bg-cyan-50 border border-cyan-200 rounded-lg 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 className="w-10 h-10 rounded-lg bg-cyan-400 flex items-center justify-center">
|
||||
<Wind className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
|
||||
<p className="text-sm text-cyan-600">
|
||||
<p className="text-sm font-bold text-gray-800">باد و رطوبت</p>
|
||||
<p className="text-xs text-cyan-600">
|
||||
{windSpeed > 40 ? '💨 باد شدید!' :
|
||||
windSpeed > 20 ? '🍃 وزش باد' :
|
||||
'😌 آرام'}
|
||||
@@ -213,15 +213,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
|
||||
</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-2xl 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-2xl font-bold text-blue-500">{toPersianDigits(humidity)}%</p>
|
||||
<p className="text-xs text-gray-500">رطوبت هوا</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function CodeInput({
|
||||
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"
|
||||
className="w-14 h-14 text-center text-2xl font-bold rounded-lg border 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-150"
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function FormInput({
|
||||
<input
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border-2 transition-all duration-200',
|
||||
'w-full px-4 py-3 rounded-lg border transition-all duration-150',
|
||||
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',
|
||||
|
||||
@@ -48,7 +48,7 @@ export function MobileInput({
|
||||
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',
|
||||
'w-full px-4 py-3 rounded-lg border 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-150 text-left',
|
||||
className
|
||||
)}
|
||||
placeholder="09123456789"
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SearchInput({
|
||||
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"
|
||||
className="w-full pr-12 pl-4 py-3 rounded-lg border 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-150"
|
||||
{...props}
|
||||
/>
|
||||
{showClearButton && value && (
|
||||
|
||||
@@ -59,9 +59,9 @@ export function BottomNav() {
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-[0_-2px_10px_rgba(0,0,0,0.08)]"
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-around h-16">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-around h-14">
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = index === activeIndex
|
||||
const Icon = item.icon
|
||||
@@ -70,24 +70,24 @@ export function BottomNav() {
|
||||
key={item.route}
|
||||
onClick={() => handleNavigate(item)}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-all duration-200',
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-all duration-150',
|
||||
'min-w-0 min-h-0 py-1',
|
||||
isActive
|
||||
? 'text-green-600'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'rounded-lg p-1.5 transition-colors duration-200',
|
||||
'rounded-md p-1.5 transition-colors duration-150',
|
||||
isActive && 'bg-green-50'
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
'w-5 h-5 transition-transform duration-200',
|
||||
'w-5 h-5 transition-transform duration-150',
|
||||
isActive && 'scale-110'
|
||||
)} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium leading-tight transition-colors duration-200',
|
||||
'text-[10px] font-medium leading-tight transition-colors duration-150',
|
||||
isActive ? 'text-green-600' : 'text-gray-400'
|
||||
)}>
|
||||
{item.label}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function TopHeader({
|
||||
const { deviceName } = useDevice()
|
||||
|
||||
return (
|
||||
<header className={cn('sticky top-0 z-40 bg-white/95 backdrop-blur-md border-b border-gray-200 shadow-sm', className)}>
|
||||
<header className={cn('sticky top-0 z-40 bg-white border-b border-gray-200', className)}>
|
||||
<div className="max-w-7xl mx-auto px-4 py-2 flex items-center justify-between gap-3">
|
||||
{/* Right: Device Name */}
|
||||
<div className="text-xs text-gray-500 font-medium truncate min-w-0 flex-1">
|
||||
@@ -32,7 +32,7 @@ export function TopHeader({
|
||||
{/* Center: Icon + Title + PageInfo */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-shrink-0">
|
||||
<div className={cn(
|
||||
'inline-flex items-center justify-center w-8 h-8 bg-gradient-to-br rounded-lg shadow-sm',
|
||||
'inline-flex items-center justify-center w-7 h-7 rounded-md',
|
||||
iconGradient
|
||||
)}>
|
||||
<Icon className="w-4 h-4 text-white" />
|
||||
|
||||
@@ -42,10 +42,10 @@ export function Pagination({
|
||||
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',
|
||||
'px-4 py-2 rounded-lg transition-all duration-150 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'
|
||||
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -58,10 +58,10 @@ export function Pagination({
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl transition-all duration-200',
|
||||
'w-10 h-10 rounded-lg transition-all duration-150',
|
||||
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'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{pageNum}
|
||||
@@ -73,10 +73,10 @@ export function Pagination({
|
||||
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',
|
||||
'px-4 py-2 rounded-lg transition-all duration-150 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'
|
||||
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
)}
|
||||
>
|
||||
بعدی
|
||||
|
||||
@@ -21,8 +21,7 @@ export function SettingsSection({
|
||||
<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
|
||||
'w-9 h-9 rounded-md flex items-center justify-center bg-gray-600',
|
||||
)}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -30,9 +30,9 @@ export function ResendButton({
|
||||
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',
|
||||
'w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium transition-all duration-150',
|
||||
canResend && !loading
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md hover:shadow-lg'
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type TabType = 'summary' | 'charts' | 'weather' | 'analysis'
|
||||
export type TabType = 'summary' | 'live' | 'charts' | 'weather' | 'analysis'
|
||||
|
||||
export const TABS: { value: TabType; label: string }[] = [
|
||||
{ value: 'summary', label: 'خلاصه' },
|
||||
{ value: 'live', label: 'لحظهای' },
|
||||
{ value: 'charts', label: 'نمودار' },
|
||||
{ value: 'weather', label: 'آب و هوا' },
|
||||
{ value: 'analysis', label: 'تحلیل' },
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
AlertConditionDto,
|
||||
CreateAlertConditionDto,
|
||||
UpdateAlertConditionDto,
|
||||
PowerOutageSummaryDto,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
|
||||
@@ -111,7 +112,15 @@ export const api = {
|
||||
http<number>(`${API_BASE}/api/alertconditions`, { method: 'POST', body: JSON.stringify(dto) }),
|
||||
updateAlertCondition: (dto: UpdateAlertConditionDto) =>
|
||||
http<void>(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }),
|
||||
deleteAlertCondition: (id: number) =>
|
||||
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
|
||||
deleteAlertCondition: (id: number) =>
|
||||
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Power Outage
|
||||
getPowerOutageSummary: (deviceId: number, startUtc?: string, endUtc?: string) => {
|
||||
const params = new URLSearchParams({ deviceId: String(deviceId) })
|
||||
if (startUtc) params.set('startUtc', startUtc)
|
||||
if (endUtc) params.set('endUtc', endUtc)
|
||||
return http<PowerOutageSummaryDto>(`${API_BASE}/api/PowerOutage/summary?${params.toString()}`)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -149,3 +149,32 @@ export type UpdateAlertConditionDto = {
|
||||
rules: CreateAlertRuleRequest[]
|
||||
}
|
||||
|
||||
// Power Outage Types
|
||||
export type PowerOutageLogDto = {
|
||||
id: number
|
||||
deviceId: number
|
||||
deviceName: string
|
||||
previousPower: number
|
||||
currentPower: number
|
||||
eventType: string // "Connected" or "Disconnected"
|
||||
eventTypePersian: string // "وصل" or "قطع"
|
||||
timestampUtc: string
|
||||
serverTimestampUtc: string
|
||||
persianDate: string
|
||||
}
|
||||
|
||||
export type PowerOutagePairDto = {
|
||||
disconnectedAt: string
|
||||
connectedAt: string | null
|
||||
durationSeconds: number | null
|
||||
}
|
||||
|
||||
export type PowerOutageSummaryDto = {
|
||||
deviceId: number
|
||||
totalOutageCount: number
|
||||
totalDowntimeSeconds: number
|
||||
startDateUtc: string | null
|
||||
endDateUtc: string | null
|
||||
outages: PowerOutagePairDto[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user