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 CACHE_NAME = 'greenhome-1780331120215';
|
||||||
const STATIC_CACHE_NAME = 'greenhome-static-1778524870692';
|
const STATIC_CACHE_NAME = 'greenhome-static-1780331120215';
|
||||||
|
|
||||||
// Static assets to cache on install
|
// Static assets to cache on install
|
||||||
const STATIC_FILES_TO_CACHE = [
|
const STATIC_FILES_TO_CACHE = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "1778524870692"
|
"version": "1780331120215"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { Suspense, useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
||||||
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
||||||
@@ -13,22 +13,10 @@ import { AppShell } from '@/components/layout'
|
|||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
function CalendarContent() {
|
||||||
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() {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const deviceIdParam = useQueryParam('deviceId')
|
const searchParams = useSearchParams()
|
||||||
|
const deviceIdParam = searchParams.get('deviceId')
|
||||||
const [deviceId, setDeviceId] = useState<number>(1)
|
const [deviceId, setDeviceId] = useState<number>(1)
|
||||||
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
||||||
const [activeMonths, setActiveMonths] = useState<number[]>([])
|
const [activeMonths, setActiveMonths] = useState<number[]>([])
|
||||||
@@ -129,4 +117,12 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Loading message="در حال بارگذاری تقویم..." />}>
|
||||||
|
<CalendarContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
|
import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { api, TelemetryDto } from '@/lib/api'
|
import { api, TelemetryDto, PowerOutageSummaryDto } from '@/lib/api'
|
||||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
|
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
|
||||||
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
|
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
|
||||||
import { TABS, TabType } from '@/features/daily-report'
|
import { TABS, TabType } from '@/features/daily-report'
|
||||||
import { detectDataGaps } from '@/features/daily-report/utils'
|
import { detectDataGaps } from '@/features/daily-report/utils'
|
||||||
import { BarChart3, CalendarIcon, ChevronRight } from 'lucide-react'
|
import { BarChart3, CalendarIcon, ChevronRight } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
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 { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
|
||||||
import { Tabs, Button } from '@/components/common'
|
import { Tabs, Button } from '@/components/common'
|
||||||
import { DateNavigation } from '@/components/navigation'
|
import { DateNavigation } from '@/components/navigation'
|
||||||
@@ -29,6 +29,8 @@ function DailyReportContent() {
|
|||||||
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
|
const [forecastWeather, setForecastWeather] = useState<WeatherData | null>(null)
|
||||||
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
const [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
||||||
const [visibleParams, setVisibleParams] = useState<string[] | null>(null)
|
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
|
// Map summary param to chart key
|
||||||
const paramToChartKey = useCallback((param: string): string => {
|
const paramToChartKey = useCallback((param: string): string => {
|
||||||
@@ -131,6 +133,38 @@ function DailyReportContent() {
|
|||||||
}
|
}
|
||||||
}, [deviceId, selectedDate])
|
}, [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
|
// Pull-to-refresh for mobile
|
||||||
usePullToRefresh(loadData)
|
usePullToRefresh(loadData)
|
||||||
|
|
||||||
@@ -189,6 +223,27 @@ function DailyReportContent() {
|
|||||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
||||||
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
||||||
|
|
||||||
|
// 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
|
// Detect data gaps in the full day data
|
||||||
const dataGaps = useMemo(() => {
|
const dataGaps = useMemo(() => {
|
||||||
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||||
@@ -247,7 +302,16 @@ function DailyReportContent() {
|
|||||||
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
|
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: (
|
charts: (
|
||||||
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||||
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} visibleParams={visibleParams} />
|
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} visibleParams={visibleParams} />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* --background: #ffffff; */
|
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* --color-background: var(--background); */
|
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
--font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
--font-mono: 'Vazirmatn', monospace;
|
--font-mono: 'Vazirmatn', monospace;
|
||||||
@@ -14,7 +12,6 @@
|
|||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
/* --background: #0a0a0a; */
|
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,12 +28,11 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
/* background: var(--background); */
|
background: #f8fafc;
|
||||||
background: linear-gradient(180deg, #eef7ff, #f6fbff);
|
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
overscroll-behavior-y: contain; /* Prevent pull-to-refresh on Android */
|
overscroll-behavior-y: contain;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +40,7 @@ body {
|
|||||||
button, a, [role="button"] {
|
button, a, [role="button"] {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
touch-action: manipulation; /* Disable double-tap zoom */
|
touch-action: manipulation;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@@ -56,7 +52,7 @@ button, a, [role="button"] {
|
|||||||
.transition-transform,
|
.transition-transform,
|
||||||
.transition-opacity {
|
.transition-opacity {
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
transform: translateZ(0); /* Force GPU acceleration */
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.persian-number {
|
.persian-number {
|
||||||
@@ -176,4 +172,4 @@ button, a, [role="button"] {
|
|||||||
.has-bottom-nav {
|
.has-bottom-nav {
|
||||||
padding-bottom: 4.5rem;
|
padding-bottom: 4.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ type Props = {
|
|||||||
|
|
||||||
export function Panel({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) {
|
export function Panel({ title, children, id }: { title: string; children: React.ReactNode; id?: string }) {
|
||||||
return (
|
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">
|
<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>
|
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,19 +77,19 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
|
|||||||
const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل')
|
const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل')
|
||||||
|
|
||||||
return (
|
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={`bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-150 overflow-hidden`}>
|
||||||
<div className="p-5">
|
<div className="p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
<div className="text-xs font-medium text-gray-500">
|
||||||
{title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()}
|
{title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()}
|
||||||
</div>
|
</div>
|
||||||
{IconComponent && (
|
{IconComponent && (
|
||||||
<div className={`w-8 h-8 bg-gradient-to-br ${colors.iconBg} rounded-lg flex items-center justify-center shadow-sm`}>
|
<div className={`w-7 h-7 rounded-md ${colors.iconBg} flex items-center justify-center`}>
|
||||||
<IconComponent className="w-4 h-4 text-white" />
|
<IconComponent className="w-3.5 h-3.5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 && (
|
{subtitle && (
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap">
|
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap">
|
||||||
{hasStats ? (
|
{hasStats ? (
|
||||||
@@ -109,7 +109,6 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function Skeleton({ className, width, height, rounded = true }: SkeletonP
|
|||||||
// Card skeleton for loading states
|
// Card skeleton for loading states
|
||||||
export function CardSkeleton() {
|
export function CardSkeleton() {
|
||||||
return (
|
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="1.5rem" width="60%" />
|
||||||
<Skeleton height="1rem" />
|
<Skeleton height="1rem" />
|
||||||
<Skeleton height="1rem" width="80%" />
|
<Skeleton height="1rem" width="80%" />
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default function UpdateNotification() {
|
|||||||
|
|
||||||
return (
|
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="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">
|
<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" />
|
<RefreshCw className="w-5 h-5 text-indigo-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function AlertFormModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function AlertListItem({ alert, onEdit, onDelete, onToggleActive }: Alert
|
|||||||
</Badge>
|
</Badge>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleActive(alert)}
|
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
|
alert.isEnabled
|
||||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Bell } from 'lucide-react'
|
|
||||||
import { SensorType, ComparisonType, NotificationType, TimeType } from './constants'
|
import { SensorType, ComparisonType, NotificationType, TimeType } from './constants'
|
||||||
import { generatePreviewText } from './utils'
|
import { generatePreviewText } from './utils'
|
||||||
|
|
||||||
@@ -12,14 +11,10 @@ type AlertPreviewProps = {
|
|||||||
|
|
||||||
export function AlertPreview({ rules, notificationType, timeType }: AlertPreviewProps) {
|
export function AlertPreview({ rules, notificationType, timeType }: AlertPreviewProps) {
|
||||||
return (
|
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="px-6 py-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* <div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
|
||||||
<Bell className="w-5 h-5 text-white" />
|
|
||||||
</div> */}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{/* <div className="text-xs font-semibold text-white/90 mb-1.5">📢 پیشنمایش زنده</div> */}
|
|
||||||
<div className="text-sm md:text-base text-white leading-relaxed font-medium">
|
<div className="text-sm md:text-base text-white leading-relaxed font-medium">
|
||||||
{generatePreviewText(rules, notificationType, timeType)}
|
{generatePreviewText(rules, notificationType, timeType)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ export function NotificationTypeSelector({ value, onChange }: NotificationTypeSe
|
|||||||
key={notif.value}
|
key={notif.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(notif.value)}
|
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
|
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'
|
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
import { SENSOR_TYPES, COMPARISON_TYPES, SensorType, ComparisonType } from './constants'
|
import { SENSOR_TYPES, COMPARISON_TYPES, SensorType, ComparisonType } from './constants'
|
||||||
import { getSensorIcon, getSensorLabel, getSensorUnit, getComparisonLabel } from './utils'
|
import { getSensorIcon, getSensorLabel, getSensorUnit, getComparisonLabel } from './utils'
|
||||||
@@ -13,16 +14,71 @@ type RuleFormCardProps = {
|
|||||||
onRemove: (index: number) => void
|
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) {
|
export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: RuleFormCardProps) {
|
||||||
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
||||||
const SensorIcon = getSensorIcon(rule.sensorType)
|
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 (
|
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 */}
|
{/* Header - Compact */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-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}`}
|
{totalRules === 1 ? '⚡ شرط' : `⚡ شرط ${index + 1}`}
|
||||||
</span>
|
</span>
|
||||||
{/* Compact summary of current selection */}
|
{/* Compact summary of current selection */}
|
||||||
@@ -39,7 +95,7 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemove(index)}
|
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="حذف این شرط"
|
title="حذف این شرط"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<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="flex gap-1.5 items-center flex-1">
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
step="0.01"
|
inputMode="decimal"
|
||||||
value={rule.value1}
|
dir="ltr"
|
||||||
|
value={displayValue1}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value
|
const sanitized = sanitizeDecimal(e.target.value)
|
||||||
// Only update if value is not empty; otherwise keep previous value
|
setRawValue1(sanitized)
|
||||||
// to avoid Number('') → 0 breaking the input in some browsers
|
}}
|
||||||
if (val !== '') {
|
onFocus={() => { isFocused1.current = true }}
|
||||||
onUpdate(index, { value1: Number(val) })
|
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"
|
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 ? 'از' : 'مقدار'}
|
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>
|
<span className="text-gray-400 text-sm flex-shrink-0">-</span>
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
step="0.01"
|
inputMode="decimal"
|
||||||
value={rule.value2 ?? ''}
|
dir="ltr"
|
||||||
|
value={displayValue2}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value
|
const sanitized = sanitizeDecimal(e.target.value)
|
||||||
// Only update if value is not empty; otherwise keep previous value
|
setRawValue2(sanitized)
|
||||||
// to avoid Number('') → 0 breaking the input in some browsers
|
}}
|
||||||
if (val !== '') {
|
onFocus={() => { isFocused2.current = true }}
|
||||||
onUpdate(index, { value2: Number(val) })
|
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"
|
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="تا"
|
placeholder="تا"
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export function TimeTypeSelector({ value, onChange }: TimeTypeSelectorProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(time.value)}
|
onClick={() => onChange(time.value)}
|
||||||
title={time.description}
|
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
|
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'
|
: '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'}>
|
<div className={className || 'mb-6'}>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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="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">
|
<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-6 h-6 text-white" />
|
<AlertTriangle className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-800">
|
<p className="font-semibold text-gray-800 text-sm">
|
||||||
شما {toPersianDigits(alertsCount)} هشدار آب و هوایی برای روزهای آینده دارید
|
شما {toPersianDigits(alertsCount)} هشدار آب و هوایی برای روزهای آینده دارید
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
برای مشاهده جزئیات و توصیههای مدیریت گلخانه کلیک کنید
|
برای مشاهده جزئیات و توصیههای مدیریت گلخانه کلیک کنید
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function WeekdayHeaders({
|
|||||||
}: WeekdayHeadersProps) {
|
}: WeekdayHeadersProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<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
|
className
|
||||||
)}>
|
)}>
|
||||||
{weekdays.map((day) => (
|
{weekdays.map((day) => (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function YearSelector({
|
|||||||
انتخاب سال:
|
انتخاب سال:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<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}
|
value={selectedYear}
|
||||||
onChange={(e) => onYearChange(Number(e.target.value))}
|
onChange={(e) => onYearChange(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export function CalendarDayCell({
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
|
<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" />
|
<Database className="w-3 h-3" />
|
||||||
{recordCount}
|
{recordCount}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ export function DeviceCard({ device, href, onClick, className }: DeviceCardProps
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); }}
|
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 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">
|
<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-7 h-7 text-white" />
|
<Settings className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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}
|
{device.deviceName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 mb-2">
|
<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" />
|
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -46,14 +45,14 @@ export function DeviceCard({ device, href, onClick, className }: DeviceCardProps
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href || '#'}
|
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 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">
|
<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-7 h-7 text-white" />
|
<Settings className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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}
|
{device.deviceName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 mb-2">
|
<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" />
|
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,32 +21,29 @@ export function MonthCard({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={!isActive}
|
disabled={!isActive}
|
||||||
className={cn(
|
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
|
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',
|
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed',
|
||||||
className
|
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}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
{isActive && stats ? (
|
{isActive && stats ? (
|
||||||
<div className="space-y-1">
|
<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" />
|
<Database className="w-3 h-3" />
|
||||||
{stats.days} روز
|
{stats.days} روز
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600 mt-2">
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
{stats.records} رکورد
|
{stats.records} رکورد
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-gray-400">بدون داده</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>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
default: 'bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600',
|
default: 'bg-white border border-gray-300 hover:border-gray-400 hover:bg-gray-50 text-gray-700',
|
||||||
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',
|
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm',
|
||||||
secondary: 'bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800',
|
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',
|
outline: 'border border-gray-300 hover:bg-gray-50 text-gray-700',
|
||||||
ghost: 'hover:bg-gray-100 text-gray-700',
|
ghost: 'hover:bg-gray-100 text-gray-700',
|
||||||
}
|
}
|
||||||
@@ -59,12 +59,11 @@ export function Button({
|
|||||||
|
|
||||||
const buttonClasses = cn(
|
const buttonClasses = cn(
|
||||||
'inline-flex items-center justify-center gap-1 sm:gap-2',
|
'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',
|
'whitespace-nowrap',
|
||||||
'shadow-sm hover:shadow-md active:shadow-sm',
|
'active:scale-[0.98]',
|
||||||
'active:scale-[0.98]', // Native-like press effect
|
'touch-manipulation',
|
||||||
'touch-manipulation', // Better touch response
|
'select-none',
|
||||||
'select-none', // Prevent text selection
|
|
||||||
sizeStyle.padding,
|
sizeStyle.padding,
|
||||||
sizeStyle.text,
|
sizeStyle.text,
|
||||||
variantStyle,
|
variantStyle,
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export function Card({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'bg-white rounded-2xl shadow-md border border-gray-100',
|
'bg-white rounded-lg border border-gray-200',
|
||||||
paddingClasses[padding],
|
paddingClasses[padding],
|
||||||
hover && 'hover:shadow-xl transition-all duration-300',
|
hover && 'hover:border-gray-300 hover:shadow-sm transition-all duration-200',
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -48,13 +48,13 @@ export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* 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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<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
|
<button
|
||||||
onClick={onClose}
|
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="بستن"
|
aria-label="بستن"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-gray-500" />
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
|||||||
@@ -19,18 +19,18 @@ export function EmptyState({
|
|||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<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
|
className
|
||||||
)}>
|
)}>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div className="text-gray-300 mb-4">
|
<div className="text-gray-300 mb-4">
|
||||||
<Icon className="w-16 h-16 mx-auto" />
|
<Icon className="w-14 h-14 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{title && (
|
{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 && (
|
{action && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{action}
|
{action}
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export function ErrorMessage({
|
|||||||
return (
|
return (
|
||||||
<div className={cn('min-h-screen flex items-center justify-center p-4', className)}>
|
<div className={cn('min-h-screen flex items-center justify-center p-4', className)}>
|
||||||
<div className="w-full max-w-md">
|
<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">
|
<div className="text-red-600 mb-4">
|
||||||
<AlertCircle className="w-16 h-16 mx-auto" />
|
<AlertCircle className="w-16 h-16 mx-auto" />
|
||||||
</div>
|
</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>
|
<p className="text-gray-600 mb-6">{message}</p>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,7 @@ export function ErrorMessage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<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
|
className
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center gap-2 text-red-700 mb-3">
|
<div className="flex items-center gap-2 text-red-700 mb-3">
|
||||||
@@ -44,7 +44,7 @@ export function ErrorMessage({
|
|||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function IconButton({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg transition-colors',
|
'rounded-md transition-colors',
|
||||||
variantStyles[variant],
|
variantStyles[variant],
|
||||||
sizeStyles[size],
|
sizeStyles[size],
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function Modal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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],
|
sizeStyles[size],
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -57,18 +57,18 @@ export function Modal({
|
|||||||
>
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<div className="flex items-center justify-between px-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
<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 && (
|
{showCloseButton && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cn(title ? 'p-6' : 'p-6', className)}>
|
<div className={cn(title ? 'p-0' : 'p-0', className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</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 justify-between flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<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
|
iconGradient
|
||||||
)}>
|
)}>
|
||||||
<Icon className="w-6 h-6 text-white" />
|
<Icon className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<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}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ type SegmentTabProps<T extends string> = {
|
|||||||
|
|
||||||
export function SegmentTab<T extends string>({ tabs, activeTab, className, setActiveTab }: SegmentTabProps<T>) {
|
export function SegmentTab<T extends string>({ tabs, activeTab, className, setActiveTab }: SegmentTabProps<T>) {
|
||||||
return (
|
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 => (
|
{tabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
onClick={() => setActiveTab(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
|
activeTab === tab.value
|
||||||
? 'bg-white text-indigo-600 shadow-sm'
|
? 'bg-white text-indigo-600 shadow-sm'
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function SuccessMessage({
|
|||||||
}: SuccessMessageProps) {
|
}: SuccessMessageProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<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
|
className
|
||||||
)}>
|
)}>
|
||||||
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
||||||
@@ -22,7 +22,7 @@ export function SuccessMessage({
|
|||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function Tabs<T extends string>({
|
|||||||
className
|
className
|
||||||
}: TabsProps<T>) {
|
}: TabsProps<T>) {
|
||||||
return (
|
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 */}
|
{/* Segmented Control for Mobile */}
|
||||||
<div className="p-3 md:p-6 md:pb-0">
|
<div className="p-3 md:p-6 md:pb-0">
|
||||||
<SegmentTab
|
<SegmentTab
|
||||||
@@ -38,10 +38,10 @@ export function Tabs<T extends string>({
|
|||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
onClick={() => setActiveTab(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
|
activeTab === tab.value
|
||||||
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
|
? 'text-indigo-600 border-b-2 border-indigo-600'
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
: 'text-gray-500 hover:text-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
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}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 pt-3 pb-1">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Gauge on left, Stats on right */}
|
{/* 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 */}
|
{/* Stats on right - stacked */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<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" />
|
<TrendingUp className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] text-gray-500 leading-tight">حداکثر</span>
|
<span className="text-[10px] text-gray-500 leading-tight">حداکثر</span>
|
||||||
@@ -64,7 +64,7 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<TrendingDown className="w-3.5 h-3.5 text-red-600 flex-shrink-0" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[10px] text-gray-500 leading-tight">حداقل</span>
|
<span className="text-[10px] text-gray-500 leading-tight">حداقل</span>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { SummaryCard } from './SummaryCard'
|
import { SummaryCard } from './SummaryCard'
|
||||||
|
import { PowerOutageCard } from './PowerOutageCard'
|
||||||
import { WeatherData } from '@/features/weather'
|
import { WeatherData } from '@/features/weather'
|
||||||
import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts'
|
import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts'
|
||||||
import { Dialog } from '@/components/common/Dialog'
|
import { Dialog } from '@/components/common/Dialog'
|
||||||
import { WeatherAlertBanner } from '@/components/alerts'
|
import { WeatherAlertBanner } from '@/components/alerts'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import type { PowerOutageSummaryDto } from '@/lib/api'
|
||||||
|
|
||||||
type SummaryTabProps = {
|
type SummaryTabProps = {
|
||||||
temperature: number[]
|
temperature: number[]
|
||||||
@@ -16,9 +18,11 @@ type SummaryTabProps = {
|
|||||||
forecastWeatherLoading?: boolean
|
forecastWeatherLoading?: boolean
|
||||||
onCardClick?: (param: string) => void
|
onCardClick?: (param: string) => void
|
||||||
visibleParams?: string[] | null
|
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 [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
|
||||||
|
|
||||||
const alertsCount = useMemo(() => {
|
const alertsCount = useMemo(() => {
|
||||||
@@ -75,16 +79,16 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
|||||||
{/* Greenhouse Forecast Alerts Section */}
|
{/* Greenhouse Forecast Alerts Section */}
|
||||||
{forecastWeatherLoading ? (
|
{forecastWeatherLoading ? (
|
||||||
<div className="mb-6">
|
<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="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
|
<div className="w-9 h-9 bg-amber-100 rounded-md flex items-center justify-center">
|
||||||
<Loader2 className="w-6 h-6 text-white animate-spin" />
|
<Loader2 className="w-5 h-5 text-amber-600 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-800">
|
<p className="font-semibold text-gray-800">
|
||||||
در حال بارگذاری هشدارهای آب و هوایی...
|
در حال بارگذاری هشدارهای آب و هوایی...
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
لطفاً صبر کنید
|
لطفاً صبر کنید
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +160,14 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Power Outage Card */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<PowerOutageCard
|
||||||
|
summary={powerOutageSummary}
|
||||||
|
loading={powerOutageLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Alerts Dialog */}
|
{/* Alerts Dialog */}
|
||||||
{forecastWeather && (
|
{forecastWeather && (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Daily report UI components only
|
// Daily report UI components only
|
||||||
export { SummaryCard } from './SummaryCard'
|
export { SummaryCard } from './SummaryCard'
|
||||||
export { SummaryTab } from './SummaryTab'
|
export { SummaryTab } from './SummaryTab'
|
||||||
|
export { LiveSummaryCards } from './LiveSummaryCards'
|
||||||
export { TimeRangeSelector } from './TimeRangeSelector'
|
export { TimeRangeSelector } from './TimeRangeSelector'
|
||||||
export { ChartsTab } from './ChartsTab'
|
export { ChartsTab } from './ChartsTab'
|
||||||
export { WeatherTab } from './WeatherTab'
|
export { WeatherTab } from './WeatherTab'
|
||||||
export { AnalysisTab } from './AnalysisTab'
|
export { AnalysisTab } from './AnalysisTab'
|
||||||
export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
|
export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
|
||||||
|
export { PowerOutageCard } from './PowerOutageCard'
|
||||||
|
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Past Date Header */}
|
{/* Past Date Header */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
<div className="bg-gradient-to-r from-blue-500 to-indigo-500 p-6">
|
<div className="bg-blue-600 p-5">
|
||||||
<div className="text-center text-white">
|
<div className="text-center text-white">
|
||||||
<p className="text-lg opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
|
<p className="text-sm opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
|
||||||
<p className="text-2xl font-bold">{selectedDate}</p>
|
<p className="text-xl font-bold">{selectedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Grid */}
|
{/* 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} />
|
<TemperatureCard weatherData={weatherData} isToday={false} />
|
||||||
<PrecipitationHistoricalCard weatherData={weatherData} />
|
<PrecipitationHistoricalCard weatherData={weatherData} />
|
||||||
<SunlightCard weatherData={weatherData} />
|
<SunlightCard weatherData={weatherData} />
|
||||||
@@ -29,4 +29,3 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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 === 'danger' ? 'bg-red-50 border-red-500' :
|
||||||
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' :
|
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' :
|
||||||
alert.type === 'info' ? 'bg-blue-50 border-blue-500' :
|
alert.type === 'info' ? 'bg-blue-50 border-blue-500' :
|
||||||
@@ -56,30 +56,30 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Current Weather Header */}
|
{/* Current Weather Header */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-6">
|
<div className="bg-emerald-600 p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-white">
|
<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">
|
<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))}
|
{toPersianDigits(Math.round(weatherData.current.temperature))}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-3xl">درجه</span>
|
<span className="text-2xl">درجه</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center text-white">
|
<div className="text-center text-white">
|
||||||
{(() => {
|
{(() => {
|
||||||
const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Grid */}
|
{/* 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} />
|
<TemperatureCard weatherData={weatherData} isToday={true} />
|
||||||
<PrecipitationForecastCard weatherData={weatherData} />
|
<PrecipitationForecastCard weatherData={weatherData} />
|
||||||
<SunlightCard weatherData={weatherData} />
|
<SunlightCard weatherData={weatherData} />
|
||||||
@@ -88,9 +88,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hourly Forecast */}
|
{/* Hourly Forecast */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 p-4">
|
<div className="bg-indigo-600 p-4">
|
||||||
<h4 className="text-lg font-bold text-white flex items-center gap-2">
|
<h4 className="text-sm font-bold text-white flex items-center gap-2">
|
||||||
🕐 وضعیت ساعت به ساعت امروز
|
🕐 وضعیت ساعت به ساعت امروز
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,9 +108,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={hour.time}
|
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
|
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
|
: isHot
|
||||||
? 'bg-red-50 border border-red-200'
|
? 'bg-red-50 border border-red-200'
|
||||||
: isCold
|
: isCold
|
||||||
@@ -122,8 +122,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
|
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
<div className={`my-2 p-1.5 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
||||||
<IconComponent className={`w-6 h-6 ${
|
<IconComponent className={`w-5 h-5 ${
|
||||||
isNow ? 'text-white' :
|
isNow ? 'text-white' :
|
||||||
isHot ? 'text-red-500' :
|
isHot ? 'text-red-500' :
|
||||||
isCold ? 'text-blue-500' :
|
isCold ? 'text-blue-500' :
|
||||||
@@ -131,7 +131,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={`text-2xl font-bold ${
|
<p className={`text-xl font-bold ${
|
||||||
isNow ? 'text-white' :
|
isNow ? 'text-white' :
|
||||||
isHot ? 'text-red-600' :
|
isHot ? 'text-red-600' :
|
||||||
isCold ? 'text-blue-600' :
|
isCold ? 'text-blue-600' :
|
||||||
@@ -160,8 +160,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
|
|
||||||
{/* 7-Day Forecast */}
|
{/* 7-Day Forecast */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
<h3 className="text-base font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||||
<CalendarIcon className="w-5 h-5 text-emerald-500" />
|
<CalendarIcon className="w-4 h-4 text-emerald-500" />
|
||||||
پیشبینی ۷ روز آینده
|
پیشبینی ۷ روز آینده
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -174,10 +174,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
const isExpanded = expandedDayIndex === index
|
const isExpanded = expandedDayIndex === index
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.date} className="overflow-hidden rounded-xl">
|
<div key={day.date} className="overflow-hidden rounded-lg border border-gray-200">
|
||||||
<button
|
<button
|
||||||
onClick={() => onDayToggle(isExpanded ? null : index)}
|
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
|
isExpanded
|
||||||
? 'bg-emerald-500 text-white'
|
? 'bg-emerald-500 text-white'
|
||||||
: isToday
|
: isToday
|
||||||
@@ -190,10 +190,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 flex-1">
|
<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'
|
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>
|
||||||
<div className="text-right flex-1">
|
<div className="text-right flex-1">
|
||||||
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||||
@@ -206,53 +206,53 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-center">
|
<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))}°
|
{toPersianDigits(Math.round(day.tempMax))}°
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
|
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
|
||||||
{toPersianDigits(Math.round(day.tempMin))}°
|
{toPersianDigits(Math.round(day.tempMin))}°
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="bg-white p-4 border-t border-emerald-100">
|
<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="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs text-gray-600 font-medium">دما</span>
|
<span className="text-xs text-gray-600 font-medium">دما</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
|
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
|
||||||
<p className="text-sm text-gray-500">حداکثر</p>
|
<p className="text-xs text-gray-500">حداکثر</p>
|
||||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
|
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
|
||||||
<p className="text-sm text-gray-500">حداقل</p>
|
<p className="text-xs text-gray-500">حداقل</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs text-gray-600 font-medium">بارش</span>
|
<span className="text-xs text-gray-600 font-medium">بارش</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
|
<p className="text-xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
|
||||||
<p className="text-sm text-gray-500">احتمال</p>
|
<p className="text-xs text-gray-500">احتمال</p>
|
||||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
|
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
|
||||||
<p className="text-sm text-gray-500">میلیمتر</p>
|
<p className="text-xs text-gray-500">میلیمتر</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
|
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
|
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
|
||||||
<p className="text-sm text-gray-500">ساعت</p>
|
<p className="text-xs text-gray-500">ساعت</p>
|
||||||
<p className="text-xl font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
|
<p className="text-lg 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-xs text-gray-500">UV Index</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs text-gray-600 font-medium">باد</span>
|
<span className="text-xs text-gray-600 font-medium">باد</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
|
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
|
||||||
<p className="text-sm text-gray-500">کیلومتر/ساعت</p>
|
<p className="text-xs text-gray-500">کیلومتر/ساعت</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,4 +265,3 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,22 +18,22 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
|
|||||||
const isHot = tempMax > 35
|
const isHot = tempMax > 35
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl p-4 ${
|
<div className={`rounded-lg p-4 border ${
|
||||||
isCold ? 'bg-blue-100 border-2 border-blue-300' :
|
isCold ? 'bg-blue-50 border-blue-200' :
|
||||||
isHot ? 'bg-red-100 border-2 border-red-300' :
|
isHot ? 'bg-red-50 border-red-200' :
|
||||||
'bg-green-100 border-2 border-green-300'
|
'bg-green-50 border-green-200'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<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' :
|
isCold ? 'bg-blue-500' :
|
||||||
isHot ? 'bg-red-500' :
|
isHot ? 'bg-red-500' :
|
||||||
'bg-green-500'
|
'bg-green-500'
|
||||||
}`}>
|
}`}>
|
||||||
<Thermometer className="w-8 h-8 text-white" />
|
<Thermometer className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
|
<p className="text-sm font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
|
||||||
<p className={`text-sm ${
|
<p className={`text-xs ${
|
||||||
isCold ? 'text-blue-600' :
|
isCold ? 'text-blue-600' :
|
||||||
isHot ? 'text-red-600' :
|
isHot ? 'text-red-600' :
|
||||||
'text-green-600'
|
'text-green-600'
|
||||||
@@ -47,12 +47,12 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-xs text-gray-500">🌙 شب</p>
|
<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>
|
||||||
<div className="text-3xl text-gray-300">←</div>
|
<div className="text-2xl text-gray-300">←</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-xs text-gray-500">☀️ روز</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,25 +69,25 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
|
|||||||
const isMedium = probability > 30
|
const isMedium = probability > 30
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl p-4 ${
|
<div className={`rounded-lg p-4 border ${
|
||||||
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
|
isHigh ? 'bg-blue-50 border-blue-200' :
|
||||||
isMedium ? 'bg-sky-50 border-2 border-sky-200' :
|
isMedium ? 'bg-sky-50 border-sky-200' :
|
||||||
'bg-amber-50 border-2 border-amber-200'
|
'bg-amber-50 border-amber-200'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<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' :
|
isHigh ? 'bg-blue-500' :
|
||||||
isMedium ? 'bg-sky-400' :
|
isMedium ? 'bg-sky-400' :
|
||||||
'bg-amber-400'
|
'bg-amber-400'
|
||||||
}`}>
|
}`}>
|
||||||
{isMedium ?
|
{isMedium ?
|
||||||
<CloudRain className="w-8 h-8 text-white" /> :
|
<CloudRain className="w-5 h-5 text-white" /> :
|
||||||
<Sun className="w-8 h-8 text-white" />
|
<Sun className="w-5 h-5 text-white" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
<p className="text-sm font-bold text-gray-800">بارش</p>
|
||||||
<p className={`text-sm ${
|
<p className={`text-xs ${
|
||||||
isHigh ? 'text-blue-600' :
|
isHigh ? 'text-blue-600' :
|
||||||
isMedium ? 'text-sky-600' :
|
isMedium ? 'text-sky-600' :
|
||||||
'text-amber-600'
|
'text-amber-600'
|
||||||
@@ -99,8 +99,8 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
|
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
|
||||||
<p className="text-sm text-gray-500">احتمال بارش</p>
|
<p className="text-xs text-gray-500">احتمال بارش</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,25 +116,25 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
|
|||||||
const hasPrecipitation = precipitation > 0
|
const hasPrecipitation = precipitation > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl p-4 ${
|
<div className={`rounded-lg p-4 border ${
|
||||||
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
|
isHigh ? 'bg-blue-50 border-blue-200' :
|
||||||
hasPrecipitation ? 'bg-sky-50 border-2 border-sky-200' :
|
hasPrecipitation ? 'bg-sky-50 border-sky-200' :
|
||||||
'bg-amber-50 border-2 border-amber-200'
|
'bg-amber-50 border-amber-200'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<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' :
|
isHigh ? 'bg-blue-500' :
|
||||||
hasPrecipitation ? 'bg-sky-400' :
|
hasPrecipitation ? 'bg-sky-400' :
|
||||||
'bg-amber-400'
|
'bg-amber-400'
|
||||||
}`}>
|
}`}>
|
||||||
{hasPrecipitation ?
|
{hasPrecipitation ?
|
||||||
<CloudRain className="w-8 h-8 text-white" /> :
|
<CloudRain className="w-5 h-5 text-white" /> :
|
||||||
<Sun className="w-8 h-8 text-white" />
|
<Sun className="w-5 h-5 text-white" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
<p className="text-sm font-bold text-gray-800">بارش</p>
|
||||||
<p className={`text-sm ${
|
<p className={`text-xs ${
|
||||||
isHigh ? 'text-blue-600' :
|
isHigh ? 'text-blue-600' :
|
||||||
hasPrecipitation ? 'text-sky-600' :
|
hasPrecipitation ? 'text-sky-600' :
|
||||||
'text-amber-600'
|
'text-amber-600'
|
||||||
@@ -146,8 +146,8 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
|
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
|
||||||
<p className="text-sm text-gray-500">میلیمتر بارش</p>
|
<p className="text-xs text-gray-500">میلیمتر بارش</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -161,14 +161,14 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
|
|||||||
const uvIndex = weatherData.daily[0]?.uvIndexMax || 0
|
const uvIndex = weatherData.daily[0]?.uvIndexMax || 0
|
||||||
|
|
||||||
return (
|
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="flex items-center gap-3 mb-3">
|
||||||
<div className="w-14 h-14 rounded-xl bg-yellow-400 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-yellow-400 flex items-center justify-center">
|
||||||
<Sun className="w-8 h-8 text-white" />
|
<Sun className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
|
<p className="text-sm font-bold text-gray-800">نور آفتاب</p>
|
||||||
<p className="text-sm text-yellow-600">
|
<p className="text-xs text-yellow-600">
|
||||||
{sunshineHours > 8 ? '☀️ آفتاب زیاد' :
|
{sunshineHours > 8 ? '☀️ آفتاب زیاد' :
|
||||||
sunshineHours > 4 ? '🌤️ آفتاب متوسط' :
|
sunshineHours > 4 ? '🌤️ آفتاب متوسط' :
|
||||||
'☁️ کمآفتاب'}
|
'☁️ کمآفتاب'}
|
||||||
@@ -177,11 +177,11 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
|
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
|
||||||
<p className="text-sm text-gray-500">ساعت آفتاب</p>
|
<p className="text-xs text-gray-500">ساعت آفتاب</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className="text-xs text-gray-500">شاخص UV</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,14 +197,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
|
|||||||
const humidity = weatherData.current.humidity
|
const humidity = weatherData.current.humidity
|
||||||
|
|
||||||
return (
|
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="flex items-center gap-3 mb-3">
|
||||||
<div className="w-14 h-14 rounded-xl bg-cyan-400 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-cyan-400 flex items-center justify-center">
|
||||||
<Wind className="w-8 h-8 text-white" />
|
<Wind className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
|
<p className="text-sm font-bold text-gray-800">باد و رطوبت</p>
|
||||||
<p className="text-sm text-cyan-600">
|
<p className="text-xs text-cyan-600">
|
||||||
{windSpeed > 40 ? '💨 باد شدید!' :
|
{windSpeed > 40 ? '💨 باد شدید!' :
|
||||||
windSpeed > 20 ? '🍃 وزش باد' :
|
windSpeed > 20 ? '🍃 وزش باد' :
|
||||||
'😌 آرام'}
|
'😌 آرام'}
|
||||||
@@ -213,15 +213,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<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>
|
<p className="text-xs text-gray-500">کیلومتر/ساعت باد</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<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>
|
<p className="text-xs text-gray-500">رطوبت هوا</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function CodeInput({
|
|||||||
onChange={(e) => handleCodeChange(index, e.target.value)}
|
onChange={(e) => handleCodeChange(index, e.target.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||||
onPaste={index === 0 ? handlePaste : undefined}
|
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}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function FormInput({
|
|||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className={cn(
|
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
|
error
|
||||||
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20 bg-red-50'
|
? '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',
|
: '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"
|
type="tel"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
placeholder="09123456789"
|
placeholder="09123456789"
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function SearchInput({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onValueChange(e.target.value)}
|
onChange={(e) => onValueChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{showClearButton && value && (
|
{showClearButton && value && (
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ export function BottomNav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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)' }}>
|
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) => {
|
{navItems.map((item, index) => {
|
||||||
const isActive = index === activeIndex
|
const isActive = index === activeIndex
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
@@ -70,24 +70,24 @@ export function BottomNav() {
|
|||||||
key={item.route}
|
key={item.route}
|
||||||
onClick={() => handleNavigate(item)}
|
onClick={() => handleNavigate(item)}
|
||||||
className={cn(
|
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',
|
'min-w-0 min-h-0 py-1',
|
||||||
isActive
|
isActive
|
||||||
? 'text-green-600'
|
? 'text-green-600'
|
||||||
: 'text-gray-400 hover:text-gray-600'
|
: 'text-gray-400 hover:text-gray-500'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<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'
|
isActive && 'bg-green-50'
|
||||||
)}>
|
)}>
|
||||||
<Icon className={cn(
|
<Icon className={cn(
|
||||||
'w-5 h-5 transition-transform duration-200',
|
'w-5 h-5 transition-transform duration-150',
|
||||||
isActive && 'scale-110'
|
isActive && 'scale-110'
|
||||||
)} />
|
)} />
|
||||||
</div>
|
</div>
|
||||||
<span className={cn(
|
<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'
|
isActive ? 'text-green-600' : 'text-gray-400'
|
||||||
)}>
|
)}>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function TopHeader({
|
|||||||
const { deviceName } = useDevice()
|
const { deviceName } = useDevice()
|
||||||
|
|
||||||
return (
|
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">
|
<div className="max-w-7xl mx-auto px-4 py-2 flex items-center justify-between gap-3">
|
||||||
{/* Right: Device Name */}
|
{/* Right: Device Name */}
|
||||||
<div className="text-xs text-gray-500 font-medium truncate min-w-0 flex-1">
|
<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 */}
|
{/* Center: Icon + Title + PageInfo */}
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-shrink-0">
|
<div className="flex items-center gap-2 min-w-0 flex-shrink-0">
|
||||||
<div className={cn(
|
<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
|
iconGradient
|
||||||
)}>
|
)}>
|
||||||
<Icon className="w-4 h-4 text-white" />
|
<Icon className="w-4 h-4 text-white" />
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ export function Pagination({
|
|||||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className={cn(
|
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
|
currentPage === 1
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
? '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" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@@ -58,10 +58,10 @@ export function Pagination({
|
|||||||
key={pageNum}
|
key={pageNum}
|
||||||
onClick={() => onPageChange(pageNum)}
|
onClick={() => onPageChange(pageNum)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-10 h-10 rounded-xl transition-all duration-200',
|
'w-10 h-10 rounded-lg transition-all duration-150',
|
||||||
currentPage === pageNum
|
currentPage === pageNum
|
||||||
? 'bg-gradient-to-r from-green-500 to-green-600 text-white shadow-md'
|
? 'bg-green-600 text-white'
|
||||||
: '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'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
@@ -73,10 +73,10 @@ export function Pagination({
|
|||||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className={cn(
|
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
|
currentPage === totalPages
|
||||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
? '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={cn('space-y-5', className)}>
|
||||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-10 h-10 bg-gradient-to-br rounded-lg flex items-center justify-center',
|
'w-9 h-9 rounded-md flex items-center justify-center bg-gray-600',
|
||||||
iconGradient
|
|
||||||
)}>
|
)}>
|
||||||
<Icon className="w-5 h-5 text-white" />
|
<Icon className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export function ResendButton({
|
|||||||
onClick={onResend}
|
onClick={onResend}
|
||||||
disabled={!canResend || loading}
|
disabled={!canResend || loading}
|
||||||
className={cn(
|
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
|
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',
|
: 'bg-gray-100 text-gray-400 cursor-not-allowed',
|
||||||
className
|
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 }[] = [
|
export const TABS: { value: TabType; label: string }[] = [
|
||||||
{ value: 'summary', label: 'خلاصه' },
|
{ value: 'summary', label: 'خلاصه' },
|
||||||
|
{ value: 'live', label: 'لحظهای' },
|
||||||
{ value: 'charts', label: 'نمودار' },
|
{ value: 'charts', label: 'نمودار' },
|
||||||
{ value: 'weather', label: 'آب و هوا' },
|
{ value: 'weather', label: 'آب و هوا' },
|
||||||
{ value: 'analysis', label: 'تحلیل' },
|
{ value: 'analysis', label: 'تحلیل' },
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
AlertConditionDto,
|
AlertConditionDto,
|
||||||
CreateAlertConditionDto,
|
CreateAlertConditionDto,
|
||||||
UpdateAlertConditionDto,
|
UpdateAlertConditionDto,
|
||||||
|
PowerOutageSummaryDto,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://ghback.nabaksoft.ir'
|
||||||
@@ -111,7 +112,15 @@ export const api = {
|
|||||||
http<number>(`${API_BASE}/api/alertconditions`, { method: 'POST', body: JSON.stringify(dto) }),
|
http<number>(`${API_BASE}/api/alertconditions`, { method: 'POST', body: JSON.stringify(dto) }),
|
||||||
updateAlertCondition: (dto: UpdateAlertConditionDto) =>
|
updateAlertCondition: (dto: UpdateAlertConditionDto) =>
|
||||||
http<void>(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }),
|
http<void>(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }),
|
||||||
deleteAlertCondition: (id: number) =>
|
deleteAlertCondition: (id: number) =>
|
||||||
http<void>(`${API_BASE}/api/alertconditions/${id}`, { method: 'DELETE' })
|
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[]
|
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