Compare commits
5 Commits
e32fb90296
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8cc37374 | |||
| da180e76ab | |||
| 9ec7187e07 | |||
| 9da69c96b1 | |||
| 676f1373c1 |
282
plans/bottom-nav-and-header-restructure.md
Normal file
282
plans/bottom-nav-and-header-restructure.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# Plan: Bottom Navigation Bar & Global Header Restructure
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Restructure the GreenHomeUI app to have an Android-style layout with:
|
||||||
|
- A **persistent Bottom Navigation Bar** (4 tabs: Profile, Alerts, Report, Weather)
|
||||||
|
- A **global Top Header** showing device title, page title with icon, and page info
|
||||||
|
- Applied to all authenticated pages (after device selection)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
App Flow:
|
||||||
|
/ (login) → /verify-code → /devices → /daily-report?deviceId=X&date=Y
|
||||||
|
├── /alert-settings?deviceId=X
|
||||||
|
├── /calendar?deviceId=X
|
||||||
|
├── /day-details?deviceId=X&year=Y&month=Z
|
||||||
|
└── /device-settings?deviceId=X
|
||||||
|
```
|
||||||
|
|
||||||
|
Each page currently has its own `PageHeader` and manages its own layout independently. There is no shared shell/layout for authenticated pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Authenticated Shell (Layout)
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Top Header │
|
||||||
|
│ [Device Name] [Page Icon+Title]│
|
||||||
|
│ [Page-specific info] │
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Page Content │
|
||||||
|
│ (children) │
|
||||||
|
│ │
|
||||||
|
├──────────────────────────────────┤
|
||||||
|
│ [Profile] [Alerts] [Report] [W] │
|
||||||
|
│ Bottom Navigation Bar │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Mapping
|
||||||
|
|
||||||
|
| Bottom Nav Tab | Route | Page Component | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Profile | `/profile?deviceId=X` | New `src/app/profile/page.tsx` | `User` |
|
||||||
|
| Alerts | `/alert-settings?deviceId=X` | Existing (refactored) | `Bell` |
|
||||||
|
| Report | `/daily-report?deviceId=X&date=Y` | Existing (refactored) | `BarChart3` |
|
||||||
|
| Weather | `/weather?deviceId=X` | New `src/app/weather/page.tsx` | `CloudSun` |
|
||||||
|
|
||||||
|
### Pages OUTSIDE the shell (no bottom nav/header):
|
||||||
|
- `/` (login page)
|
||||||
|
- `/login`
|
||||||
|
- `/verify-code`
|
||||||
|
- `/devices` (device selection - this is the gateway before entering the shell)
|
||||||
|
|
||||||
|
### Pages INSIDE the shell (with bottom nav + header):
|
||||||
|
- `/daily-report` (Report tab)
|
||||||
|
- `/alert-settings` (Alerts tab)
|
||||||
|
- `/profile` (Profile tab - NEW)
|
||||||
|
- `/weather` (Weather tab - NEW)
|
||||||
|
- `/calendar` (calendar navigation)
|
||||||
|
- `/day-details` (day detail view)
|
||||||
|
- `/device-settings` (device configuration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
### 1. [`src/components/layout/AppShell.tsx`](src/components/layout/AppShell.tsx) — NEW
|
||||||
|
The main layout wrapper for authenticated pages. Provides:
|
||||||
|
- Top header bar
|
||||||
|
- Bottom navigation bar
|
||||||
|
- Main content area (children)
|
||||||
|
|
||||||
|
### 2. [`src/components/layout/TopHeader.tsx`](src/components/layout/TopHeader.tsx) — NEW
|
||||||
|
Global top header component showing:
|
||||||
|
- **Left side**: Device name (from context/query param)
|
||||||
|
- **Center/Right**: Page title with icon
|
||||||
|
- **Optional**: Page-specific info (e.g., selected date for daily-report)
|
||||||
|
|
||||||
|
Props:
|
||||||
|
```typescript
|
||||||
|
type TopHeaderProps = {
|
||||||
|
deviceName: string
|
||||||
|
pageTitle: string
|
||||||
|
pageIcon: LucideIcon
|
||||||
|
pageInfo?: string // e.g., "۱۴۰۴/۰۲/۲۱" for date
|
||||||
|
iconGradient?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. [`src/components/layout/BottomNav.tsx`](src/components/layout/BottomNav.tsx) — NEW
|
||||||
|
Bottom navigation bar with 4 tabs:
|
||||||
|
- Uses `lucide-react` icons
|
||||||
|
- Highlights active tab based on current route
|
||||||
|
- Fixed at bottom, safe-area aware for notched devices
|
||||||
|
- Each tab navigates via `next/navigation` `useRouter`
|
||||||
|
|
||||||
|
### 4. [`src/components/layout/DeviceProvider.tsx`](src/components/layout/DeviceProvider.tsx) — NEW
|
||||||
|
React Context provider that stores the currently selected `deviceId` and `deviceName` so all pages within the shell can access them without passing through query params everywhere.
|
||||||
|
|
||||||
|
### 5. [`src/app/profile/page.tsx`](src/app/profile/page.tsx) — NEW
|
||||||
|
User profile page showing:
|
||||||
|
- User name, family, mobile
|
||||||
|
- Logout button
|
||||||
|
- Link to device settings
|
||||||
|
|
||||||
|
### 6. [`src/app/weather/page.tsx`](src/app/weather/page.tsx) — NEW
|
||||||
|
Dedicated weather page (extracted from the weather tab inside daily-report):
|
||||||
|
- Shows forecast weather data
|
||||||
|
- Reuses existing [`WeatherTab`](src/components/daily-report/WeatherTab.tsx) or creates a standalone version
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User logs in → /devices (selects device)
|
||||||
|
→ Stores deviceId + deviceName in DeviceProvider context
|
||||||
|
→ Redirects to /daily-report?deviceId=X&date=today
|
||||||
|
→ AppShell renders with TopHeader + BottomNav + page content
|
||||||
|
→ BottomNav tab changes update the route
|
||||||
|
→ DeviceProvider persists across tab switches
|
||||||
|
```
|
||||||
|
|
||||||
|
### DeviceProvider Context
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type DeviceContextType = {
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
setDevice: (id: number, name: string) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The provider wraps all authenticated pages. When a user selects a device on `/devices`, it sets the context. The `AppShell` reads from this context to display the device name in the header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Changes Summary
|
||||||
|
|
||||||
|
### New Files to Create
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [`src/components/layout/AppShell.tsx`](src/components/layout/AppShell.tsx) | Main layout shell with header + bottom nav |
|
||||||
|
| [`src/components/layout/TopHeader.tsx`](src/components/layout/TopHeader.tsx) | Global top header component |
|
||||||
|
| [`src/components/layout/BottomNav.tsx`](src/components/layout/BottomNav.tsx) | Bottom navigation bar |
|
||||||
|
| [`src/components/layout/DeviceProvider.tsx`](src/components/layout/DeviceProvider.tsx) | Device context provider |
|
||||||
|
| [`src/components/layout/index.ts`](src/components/layout/index.ts) | Barrel exports |
|
||||||
|
| [`src/app/profile/page.tsx`](src/app/profile/page.tsx) | Profile page |
|
||||||
|
| [`src/app/weather/page.tsx`](src/app/weather/page.tsx) | Weather page |
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|---|---|
|
||||||
|
| [`src/app/layout.tsx`](src/app/layout.tsx) | Wrap children with `DeviceProvider` for authenticated routes |
|
||||||
|
| [`src/app/daily-report/page.tsx`](src/app/daily-report/page.tsx) | Remove standalone `PageHeader`, use `AppShell` instead. Remove `BackLink`. |
|
||||||
|
| [`src/app/alert-settings/page.tsx`](src/app/alert-settings/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
|
||||||
|
| [`src/app/calendar/page.tsx`](src/app/calendar/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
|
||||||
|
| [`src/app/day-details/page.tsx`](src/app/day-details/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
|
||||||
|
| [`src/app/device-settings/page.tsx`](src/app/device-settings/page.tsx) | Remove standalone `PageHeader`, use `AppShell`. Remove `BackLink`. |
|
||||||
|
| [`src/app/devices/page.tsx`](src/app/devices/page.tsx) | After device selection, set `DeviceProvider` context before redirecting |
|
||||||
|
| [`src/app/globals.css`](src/app/globals.css) | Add styles for bottom nav (safe-area padding, fixed positioning) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Steps (Ordered)
|
||||||
|
|
||||||
|
### Step 1: Create Layout Components
|
||||||
|
- Create [`DeviceProvider`](src/components/layout/DeviceProvider.tsx) context
|
||||||
|
- Create [`TopHeader`](src/components/layout/TopHeader.tsx) component
|
||||||
|
- Create [`BottomNav`](src/components/layout/BottomNav.tsx) component
|
||||||
|
- Create [`AppShell`](src/components/layout/AppShell.tsx) that composes them
|
||||||
|
- Create barrel export [`index.ts`](src/components/layout/index.ts)
|
||||||
|
|
||||||
|
### Step 2: Update Root Layout
|
||||||
|
- Modify [`src/app/layout.tsx`](src/app/layout.tsx) to wrap with `DeviceProvider`
|
||||||
|
- Add bottom nav CSS to [`src/app/globals.css`](src/app/globals.css)
|
||||||
|
|
||||||
|
### Step 3: Create New Pages
|
||||||
|
- Create [`src/app/profile/page.tsx`](src/app/profile/page.tsx) — user info + logout
|
||||||
|
- Create [`src/app/weather/page.tsx`](src/app/weather/page.tsx) — dedicated weather page
|
||||||
|
|
||||||
|
### Step 4: Refactor Existing Pages to Use AppShell
|
||||||
|
- [`src/app/daily-report/page.tsx`](src/app/daily-report/page.tsx) — remove standalone PageHeader, wrap content in AppShell
|
||||||
|
- [`src/app/alert-settings/page.tsx`](src/app/alert-settings/page.tsx) — same
|
||||||
|
- [`src/app/calendar/page.tsx`](src/app/calendar/page.tsx) — same
|
||||||
|
- [`src/app/day-details/page.tsx`](src/app/day-details/page.tsx) — same
|
||||||
|
- [`src/app/device-settings/page.tsx`](src/app/device-settings/page.tsx) — same
|
||||||
|
|
||||||
|
### Step 5: Update Device Selection Flow
|
||||||
|
- Modify [`src/app/devices/page.tsx`](src/app/devices/page.tsx) to set device context on selection
|
||||||
|
- Ensure single-device auto-redirect also sets context
|
||||||
|
|
||||||
|
### Step 6: Polish & Testing
|
||||||
|
- Ensure bottom nav highlights correct tab based on route
|
||||||
|
- Ensure safe-area padding for notched phones
|
||||||
|
- Test navigation between all tabs
|
||||||
|
- Verify header shows correct device name and page info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mermaid Diagram: Component Tree
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
RootLayout[RootLayout] --> DeviceProvider[DeviceProvider]
|
||||||
|
DeviceProvider --> AuthPages[Auth Pages Group]
|
||||||
|
|
||||||
|
subgraph AuthPages[Authenticated Pages]
|
||||||
|
AppShell[AppShell] --> TopHeader[TopHeader]
|
||||||
|
AppShell --> PageContent[Page Content]
|
||||||
|
AppShell --> BottomNav[BottomNav]
|
||||||
|
|
||||||
|
PageContent --> DailyReport[/daily-report]
|
||||||
|
PageContent --> AlertSettings[/alert-settings]
|
||||||
|
PageContent --> Profile[/profile]
|
||||||
|
PageContent --> Weather[/weather]
|
||||||
|
PageContent --> Calendar[/calendar]
|
||||||
|
PageContent --> DayDetails[/day-details]
|
||||||
|
PageContent --> DeviceSettings[/device-settings]
|
||||||
|
end
|
||||||
|
|
||||||
|
NonAuth[Non-Auth Pages] -.-> Login[/login]
|
||||||
|
NonAuth -.-> VerifyCode[/verify-code]
|
||||||
|
NonAuth -.-> Devices[/devices]
|
||||||
|
|
||||||
|
Devices -.->|select device| AppShell
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mermaid Diagram: Navigation Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Login[/login] --> Verify[/verify-code]
|
||||||
|
Verify --> Devices[/devices]
|
||||||
|
Devices -->|select device + set context| DailyReport[/daily-report<br/>Tab: Report]
|
||||||
|
|
||||||
|
DailyReport -->|BottomNav: Alerts| AlertSettings[/alert-settings<br/>Tab: Alerts]
|
||||||
|
DailyReport -->|BottomNav: Profile| Profile[/profile<br/>Tab: Profile]
|
||||||
|
DailyReport -->|BottomNav: Weather| Weather[/weather<br/>Tab: Weather]
|
||||||
|
|
||||||
|
AlertSettings -->|BottomNav: Report| DailyReport
|
||||||
|
AlertSettings -->|BottomNav: Profile| Profile
|
||||||
|
AlertSettings -->|BottomNav: Weather| Weather
|
||||||
|
|
||||||
|
Profile -->|BottomNav: Report| DailyReport
|
||||||
|
Profile -->|BottomNav: Alerts| AlertSettings
|
||||||
|
Profile -->|BottomNav: Weather| Weather
|
||||||
|
|
||||||
|
Weather -->|BottomNav: Report| DailyReport
|
||||||
|
Weather -->|BottomNav: Alerts| AlertSettings
|
||||||
|
Weather -->|BottomNav: Profile| Profile
|
||||||
|
|
||||||
|
DailyReport -->|internal link| Calendar[/calendar]
|
||||||
|
Calendar -->|select day| DayDetails[/day-details]
|
||||||
|
DayDetails -->|select date| DailyReport
|
||||||
|
|
||||||
|
DailyReport -->|settings icon| DeviceSettings[/device-settings]
|
||||||
|
Profile -->|settings link| DeviceSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **DeviceProvider Context**: Rather than passing `deviceId` as a query param on every navigation, we use React Context. This simplifies all page components and makes the header always aware of the current device.
|
||||||
|
|
||||||
|
2. **AppShell as a wrapper**: Each page inside the shell imports `AppShell` and wraps its content. The shell provides the header and bottom nav. This keeps pages clean and focused on their content.
|
||||||
|
|
||||||
|
3. **BottomNav uses route matching**: The active tab is determined by matching `usePathname()` against known routes. This is simpler than managing state across pages.
|
||||||
|
|
||||||
|
4. **Safe-area handling**: The bottom nav uses `env(safe-area-inset-bottom)` CSS to avoid overlapping with system navigation bars on notched devices.
|
||||||
|
|
||||||
|
5. **Existing PageHeader component**: The existing `PageHeader` component can still be used inside page content if needed (e.g., for sub-headers within a page), but the main header is now provided by `AppShell`.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_NAME = 'greenhome-1766182760520';
|
const CACHE_NAME = 'greenhome-1780331120215';
|
||||||
const STATIC_CACHE_NAME = 'greenhome-static-1766182760520';
|
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": "1766182760520"
|
"version": "1780331120215"
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useState, useCallback, Suspense } from 'react'
|
import { useEffect, useState, useCallback, Suspense } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { api, AlertConditionDto, UpdateAlertConditionDto, CreateAlertConditionDto, CreateAlertRuleRequest } from '@/lib/api'
|
import { api, AlertConditionDto, CreateAlertConditionDto, CreateAlertRuleRequest } from '@/lib/api'
|
||||||
import { Bell, Plus, AlertTriangle } from 'lucide-react'
|
import { Bell, Plus, AlertTriangle } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { PageHeader, EmptyState } from '@/components/common'
|
import { EmptyState } from '@/components/common'
|
||||||
import { confirmDialog } from '@/components/utils'
|
import { confirmDialog } from '@/components/utils'
|
||||||
import { AlertListItem } from '@/components/alert-settings/AlertListItem'
|
import { AlertListItem } from '@/components/alert-settings/AlertListItem'
|
||||||
import { AlertFormModal } from '@/components/alert-settings/AlertFormModal'
|
import { AlertFormModal } from '@/components/alert-settings/AlertFormModal'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
function AlertSettingsContent() {
|
function AlertSettingsContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@@ -102,24 +103,20 @@ function AlertSettingsContent() {
|
|||||||
if (loading) return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
|
if (loading) return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
|
<>
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<AppShell
|
||||||
<PageHeader
|
pageTitle="تنظیمات هشدارها"
|
||||||
icon={Bell}
|
pageIcon={Bell}
|
||||||
title="تنظیمات هشدارها"
|
|
||||||
subtitle="مدیریت شرایط و هشدارهای دستگاه"
|
|
||||||
iconGradient="from-orange-500 to-red-600"
|
iconGradient="from-orange-500 to-red-600"
|
||||||
action={
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">لیست هشدارها ({alerts.length})</h2>
|
||||||
<button onClick={openCreate}
|
<button onClick={openCreate}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg">
|
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-xl transition-all duration-200 font-medium shadow-md hover:shadow-lg">
|
||||||
<Plus className="w-5 h-5" /> افزودن هشدار
|
<Plus className="w-5 h-5" /> افزودن هشدار
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden">
|
|
||||||
<div className="p-6 border-b border-gray-200">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">لیست هشدارها ({alerts.length})</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{alerts.length === 0 ? (
|
{alerts.length === 0 ? (
|
||||||
@@ -135,6 +132,7 @@ function AlertSettingsContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
|
||||||
<AlertFormModal
|
<AlertFormModal
|
||||||
isOpen={modal.open}
|
isOpen={modal.open}
|
||||||
@@ -148,7 +146,7 @@ function AlertSettingsContent() {
|
|||||||
onRemoveRule={removeRule}
|
onRemoveRule={removeRule}
|
||||||
onUpdateRule={updateRule}
|
onUpdateRule={updateRule}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
"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'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { PageHeader, BackLink, Card } from '@/components/common'
|
import { Card } from '@/components/common'
|
||||||
import { YearSelector } from '@/components/calendar'
|
import { YearSelector } from '@/components/calendar'
|
||||||
import { MonthCard } from '@/components/cards'
|
import { MonthCard } from '@/components/cards'
|
||||||
import { StatsCard } from '@/components/cards'
|
import { StatsCard } from '@/components/cards'
|
||||||
|
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[]>([])
|
||||||
@@ -75,16 +64,12 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-6xl mx-auto">
|
pageTitle="انتخاب سال و ماه"
|
||||||
{/* Header */}
|
pageIcon={CalendarIcon}
|
||||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
|
||||||
<PageHeader
|
|
||||||
icon={CalendarIcon}
|
|
||||||
title="انتخاب سال و ماه"
|
|
||||||
iconGradient="from-green-500 to-green-600"
|
iconGradient="from-green-500 to-green-600"
|
||||||
/>
|
>
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Main Card */}
|
{/* Main Card */}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
{/* Year Selector */}
|
{/* Year Selector */}
|
||||||
@@ -130,6 +115,14 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Loading message="در حال بارگذاری تقویم..." />}>
|
||||||
|
<CalendarContent />
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
"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, Bell, CalendarIcon, ChevronRight } from 'lucide-react'
|
import { BarChart3, CalendarIcon, ChevronRight } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { SummaryTab } from '@/components/daily-report'
|
import { SummaryTab } from '@/components/daily-report'
|
||||||
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
|
import { fetchForecastWeather, isToday as checkIsToday, WeatherData } from '@/features/weather'
|
||||||
import { Tabs, PageHeader, Button } from '@/components/common'
|
import { Tabs, Button } from '@/components/common'
|
||||||
import { DateNavigation } from '@/components/navigation'
|
import { DateNavigation } from '@/components/navigation'
|
||||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
|
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
// Lazy load heavy components
|
// Lazy load heavy components
|
||||||
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
|
const ChartsTab = lazy(() => import('@/components/daily-report/ChartsTab').then(m => ({ default: m.ChartsTab })))
|
||||||
@@ -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)
|
||||||
@@ -201,7 +256,12 @@ function DailyReportContent() {
|
|||||||
|
|
||||||
if (!selectedDate) {
|
if (!selectedDate) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<AppShell
|
||||||
|
pageTitle="گزارش روزانه"
|
||||||
|
pageIcon={BarChart3}
|
||||||
|
iconGradient="from-indigo-500 to-purple-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||||
@@ -214,37 +274,25 @@ function DailyReportContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
pageTitle="گزارش روزانه"
|
||||||
{/* Header */}
|
pageIcon={BarChart3}
|
||||||
<PageHeader
|
pageInfo={selectedDate}
|
||||||
icon={BarChart3}
|
|
||||||
title="گزارش روزانه"
|
|
||||||
iconGradient="from-indigo-500 to-purple-600"
|
iconGradient="from-indigo-500 to-purple-600"
|
||||||
action={
|
|
||||||
<Link
|
|
||||||
href={`/alert-settings?deviceId=${deviceId}`}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<div className="space-y-6">
|
||||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Date Navigation Buttons */}
|
{/* Date Navigation Buttons */}
|
||||||
{selectedDate && (
|
|
||||||
<DateNavigation
|
<DateNavigation
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
onPrevious={goToPreviousDay}
|
onPrevious={goToPreviousDay}
|
||||||
onNext={goToNextDay}
|
onNext={goToNextDay}
|
||||||
onCalendar={goToCalendar}
|
onCalendar={goToCalendar}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -254,7 +302,7 @@ 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} />,
|
||||||
charts: (
|
charts: (
|
||||||
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||||
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} visibleParams={visibleParams} />
|
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} visibleParams={visibleParams} />
|
||||||
@@ -273,7 +321,7 @@ function DailyReportContent() {
|
|||||||
}}
|
}}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { api } from '@/lib/api'
|
|||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
|
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
|
||||||
import { Calendar as CalendarIcon, Database } from 'lucide-react'
|
import { Calendar as CalendarIcon, Database } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { PageHeader, BackLink, Card } from '@/components/common'
|
import { Card } from '@/components/common'
|
||||||
import { WeekdayHeaders } from '@/components/calendar'
|
import { WeekdayHeaders } from '@/components/calendar'
|
||||||
import { CalendarDayCell, StatsCard } from '@/components/cards'
|
import { CalendarDayCell, StatsCard } from '@/components/cards'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||||
|
|
||||||
@@ -71,16 +72,12 @@ function DayDetailsContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-6xl mx-auto">
|
pageTitle={`${monthNames[month - 1]} ${year}`}
|
||||||
{/* Header */}
|
pageIcon={CalendarIcon}
|
||||||
<BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
|
|
||||||
<PageHeader
|
|
||||||
icon={CalendarIcon}
|
|
||||||
title={`${monthNames[month - 1]} ${year}`}
|
|
||||||
iconGradient="from-green-500 to-green-600"
|
iconGradient="from-green-500 to-green-600"
|
||||||
/>
|
>
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Calendar Grid */}
|
{/* Calendar Grid */}
|
||||||
<Card className="overflow-hidden" padding="none">
|
<Card className="overflow-hidden" padding="none">
|
||||||
{/* Weekday Headers */}
|
{/* Weekday Headers */}
|
||||||
@@ -138,7 +135,7 @@ function DayDetailsContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||||
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
|
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { PageHeader, BackLink, ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common'
|
import { ErrorMessage, SuccessMessage, EmptyState, Card } from '@/components/common'
|
||||||
import { SettingsSection, SettingsInputGroup } from '@/components/settings'
|
import { SettingsSection, SettingsInputGroup } from '@/components/settings'
|
||||||
|
import { AppShell } from '@/components/layout'
|
||||||
|
|
||||||
function useQueryParam(name: string) {
|
function useQueryParam(name: string) {
|
||||||
if (typeof window === 'undefined') return null as string | null
|
if (typeof window === 'undefined') return null as string | null
|
||||||
@@ -95,36 +95,25 @@ export default function DeviceSettingsPage() {
|
|||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return (
|
return (
|
||||||
|
<AppShell
|
||||||
|
pageTitle="تنظیمات دستگاه"
|
||||||
|
pageIcon={Settings}
|
||||||
|
iconGradient="from-blue-500 to-blue-600"
|
||||||
|
>
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message="شناسه دستگاه مشخص نشده است"
|
message="شناسه دستگاه مشخص نشده است"
|
||||||
fullPage
|
|
||||||
action={
|
|
||||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-4 md:p-6">
|
<AppShell
|
||||||
<div className="max-w-5xl mx-auto">
|
pageTitle={`تنظیمات ${deviceName}`}
|
||||||
{/* Header */}
|
pageIcon={Settings}
|
||||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
|
||||||
<PageHeader
|
|
||||||
icon={Settings}
|
|
||||||
title={`تنظیمات ${deviceName}`}
|
|
||||||
iconGradient="from-blue-500 to-blue-600"
|
iconGradient="from-blue-500 to-blue-600"
|
||||||
action={
|
|
||||||
<Link
|
|
||||||
href={`/alert-settings?deviceId=${deviceId}`}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<div className="max-w-5xl mx-auto">
|
||||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{error && <ErrorMessage message={error} className="mb-6" />}
|
{error && <ErrorMessage message={error} className="mb-6" />}
|
||||||
{success && <SuccessMessage message={success} className="mb-6" />}
|
{success && <SuccessMessage message={success} className="mb-6" />}
|
||||||
@@ -252,7 +241,7 @@ export default function DeviceSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { api, DeviceDto, PagedResult } from '@/lib/api'
|
|||||||
import { Settings, LogOut } from 'lucide-react'
|
import { Settings, LogOut } from 'lucide-react'
|
||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
|
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
|
||||||
import { PageHeader, ErrorMessage, EmptyState, BackLink } from '@/components/common'
|
import { ErrorMessage, EmptyState } from '@/components/common'
|
||||||
import { SearchInput } from '@/components/forms'
|
import { SearchInput } from '@/components/forms'
|
||||||
import { DeviceCard } from '@/components/cards'
|
import { DeviceCard } from '@/components/cards'
|
||||||
import { Pagination } from '@/components/navigation'
|
import { Pagination } from '@/components/navigation'
|
||||||
|
import { useDevice } from '@/components/layout'
|
||||||
|
|
||||||
export default function DevicesPage() {
|
export default function DevicesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { setDevice } = useDevice()
|
||||||
const [pagedResult, setPagedResult] = useState<PagedResult<DeviceDto> | null>(null)
|
const [pagedResult, setPagedResult] = useState<PagedResult<DeviceDto> | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -22,6 +24,12 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
|
|
||||||
|
const handleDeviceSelect = useCallback((device: DeviceDto) => {
|
||||||
|
setDevice(device.id, device.deviceName)
|
||||||
|
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||||
|
router.push(`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`)
|
||||||
|
}, [setDevice, router])
|
||||||
|
|
||||||
const fetchDevices = useCallback(async (page: number, search?: string) => {
|
const fetchDevices = useCallback(async (page: number, search?: string) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
@@ -39,9 +47,8 @@ export default function DevicesPage() {
|
|||||||
if (result.items.length === 0 && page === 1) {
|
if (result.items.length === 0 && page === 1) {
|
||||||
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
|
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
|
||||||
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
|
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
|
||||||
// Single device - redirect to today's daily report
|
// Single device - set context and redirect to today's daily report
|
||||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
handleDeviceSelect(result.items[0])
|
||||||
router.push(`/daily-report?deviceId=${result.items[0].id}&date=${encodeURIComponent(today)}`)
|
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
setPagedResult(result)
|
setPagedResult(result)
|
||||||
@@ -52,7 +59,7 @@ export default function DevicesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [user, router])
|
}, [user, router, handleDeviceSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check authentication
|
// Check authentication
|
||||||
@@ -120,11 +127,24 @@ export default function DevicesPage() {
|
|||||||
<div className="min-h-screen p-4 md:p-8">
|
<div className="min-h-screen p-4 md:p-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<PageHeader
|
<div className="mb-6">
|
||||||
icon={Settings}
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
title="انتخاب دستگاه"
|
<div className="flex items-center gap-3">
|
||||||
subtitle={user ? `${user.name} ${user.family} (${user.mobile})` : undefined}
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
||||||
action={
|
<Settings className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||||
|
انتخاب دستگاه
|
||||||
|
</h1>
|
||||||
|
{user && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{user.name} {user.family} ({user.mobile})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm"
|
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm"
|
||||||
@@ -132,9 +152,9 @@ export default function DevicesPage() {
|
|||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
خروج
|
خروج
|
||||||
</button>
|
</button>
|
||||||
}
|
</div>
|
||||||
iconGradient="from-green-500 to-green-600"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -174,16 +194,13 @@ export default function DevicesPage() {
|
|||||||
{pagedResult && pagedResult.items.length > 0 ? (
|
{pagedResult && pagedResult.items.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
{pagedResult.items.map((device) => {
|
{pagedResult.items.map((device) => (
|
||||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
|
||||||
return (
|
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
key={device.id}
|
key={device.id}
|
||||||
device={device}
|
device={device}
|
||||||
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
|
onClick={() => handleDeviceSelect(device)}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
@@ -203,11 +220,6 @@ export default function DevicesPage() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Back to Home */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -157,3 +153,23 @@ button, a, [role="button"] {
|
|||||||
background-size: 2000px 100%;
|
background-size: 2000px 100%;
|
||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottom Navigation Safe Area */
|
||||||
|
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||||
|
.pb-safe {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.h-safe-bottom {
|
||||||
|
height: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent content from being hidden behind bottom nav */
|
||||||
|
.has-bottom-nav {
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.has-bottom-nav {
|
||||||
|
padding-bottom: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||||
import UpdateNotification from '@/components/UpdateNotification'
|
import UpdateNotification from '@/components/UpdateNotification'
|
||||||
|
import { DeviceProvider } from '@/components/layout'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'GreenHome',
|
title: 'GreenHome',
|
||||||
@@ -42,7 +43,9 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||||||
<body className='persian-number'>
|
<body className='persian-number'>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
<UpdateNotification />
|
<UpdateNotification />
|
||||||
|
<DeviceProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</DeviceProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function LoginPage() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Redirect to verify page with mobile number
|
// Redirect to verify page with mobile number
|
||||||
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
|
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
|
||||||
|
return // Don't set loading to false — component will unmount
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'خطا در ارسال کد')
|
setError(result.message || 'خطا در ارسال کد')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Smartphone, ArrowLeft } from 'lucide-react'
|
|||||||
import Loading from '@/components/Loading'
|
import Loading from '@/components/Loading'
|
||||||
import { MobileInput } from '@/components/forms'
|
import { MobileInput } from '@/components/forms'
|
||||||
import { ErrorMessage } from '@/components/common'
|
import { ErrorMessage } from '@/components/common'
|
||||||
|
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -20,9 +21,35 @@ export default function Home() {
|
|||||||
|
|
||||||
if (token && userStr) {
|
if (token && userStr) {
|
||||||
try {
|
try {
|
||||||
JSON.parse(userStr)
|
const user = JSON.parse(userStr)
|
||||||
// Redirect to devices check
|
|
||||||
|
// Try to use the saved device from localStorage first
|
||||||
|
const savedDeviceId = localStorage.getItem('selectedDeviceId')
|
||||||
|
if (savedDeviceId) {
|
||||||
|
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||||
|
router.push(`/daily-report?deviceId=${savedDeviceId}&date=${encodeURIComponent(today)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No saved device — fetch user devices and auto-select the first one
|
||||||
|
api.getUserDevices(user.id)
|
||||||
|
.then((devices) => {
|
||||||
|
if (devices.length > 0) {
|
||||||
|
const firstDevice = devices[0]
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('selectedDeviceId', String(firstDevice.id))
|
||||||
|
localStorage.setItem('selectedDeviceName', firstDevice.deviceName)
|
||||||
|
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||||
|
router.push(`/daily-report?deviceId=${firstDevice.id}&date=${encodeURIComponent(today)}`)
|
||||||
|
} else {
|
||||||
|
// No devices — go to devices page to show the error
|
||||||
router.push('/devices')
|
router.push('/devices')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// On error, fall back to devices page
|
||||||
|
router.push('/devices')
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid user data, go to login
|
// Invalid user data, go to login
|
||||||
}
|
}
|
||||||
@@ -62,6 +89,7 @@ export default function Home() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Redirect to verify page with mobile number
|
// Redirect to verify page with mobile number
|
||||||
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
|
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
|
||||||
|
return // Don't set loading to false — component will unmount
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'خطا در ارسال کد')
|
setError(result.message || 'خطا در ارسال کد')
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/app/profile/page.tsx
Normal file
138
src/app/profile/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { User, LogOut, Settings, ChevronLeft, Smartphone, Shield, Monitor } from 'lucide-react'
|
||||||
|
import { AppShell, useDevice } from '@/components/layout'
|
||||||
|
import { Card } from '@/components/common'
|
||||||
|
|
||||||
|
type UserData = {
|
||||||
|
id: number
|
||||||
|
mobile: string
|
||||||
|
name: string
|
||||||
|
family: string
|
||||||
|
role: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { deviceId } = useDevice()
|
||||||
|
const [user, setUser] = useState<UserData | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userStr = localStorage.getItem('user')
|
||||||
|
if (userStr) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(userStr))
|
||||||
|
} catch {
|
||||||
|
// Invalid user data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('authToken')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeviceSettings = () => {
|
||||||
|
router.push(`/device-settings?deviceId=${deviceId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectDevice = () => {
|
||||||
|
router.push('/devices')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
pageTitle="پروفایل"
|
||||||
|
pageIcon={User}
|
||||||
|
iconGradient="from-green-500 to-emerald-600"
|
||||||
|
>
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
{/* User Info Card */}
|
||||||
|
<Card padding="lg">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center shadow-md">
|
||||||
|
<User className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">
|
||||||
|
{user ? `${user.name} ${user.family}` : 'کاربر'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 flex items-center gap-1.5">
|
||||||
|
<Smartphone className="w-4 h-4" />
|
||||||
|
{user?.mobile || '---'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-sm text-gray-600">نقش کاربری</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 flex items-center gap-1.5">
|
||||||
|
<Shield className="w-4 h-4 text-green-600" />
|
||||||
|
{user?.role === 1 ? 'مدیر' : 'کاربر'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Card padding="lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Select Device — always visible so user can change device */}
|
||||||
|
<button
|
||||||
|
onClick={handleSelectDevice}
|
||||||
|
className="w-full flex items-center justify-between p-4 rounded-xl hover:bg-gray-50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<Monitor className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium text-gray-900">انتخاب دستگاه</p>
|
||||||
|
<p className="text-xs text-gray-500">تغییر دستگاه فعال</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-gray-400 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDeviceSettings}
|
||||||
|
className="w-full flex items-center justify-between p-4 rounded-xl hover:bg-gray-50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<Settings className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium text-gray-900">تنظیمات دستگاه</p>
|
||||||
|
<p className="text-xs text-gray-500">مدیریت محدودههای مجاز سنسورها</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-gray-400 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center justify-between p-4 rounded-xl hover:bg-red-50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<LogOut className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium text-red-600">خروج از حساب</p>
|
||||||
|
<p className="text-xs text-gray-500">خروج از حساب کاربری</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-gray-400 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -93,14 +93,17 @@ function VerifyCodeContent() {
|
|||||||
} else if (devicesResult.totalCount === 1) {
|
} else if (devicesResult.totalCount === 1) {
|
||||||
// Single device - redirect to calendar
|
// Single device - redirect to calendar
|
||||||
router.push(`/calendar?deviceId=${devicesResult.items[0].id}`)
|
router.push(`/calendar?deviceId=${devicesResult.items[0].id}`)
|
||||||
|
return // Don't set loading to false — component will unmount
|
||||||
} else {
|
} else {
|
||||||
// Multiple devices - redirect to device list
|
// Multiple devices - redirect to device list
|
||||||
router.push('/devices')
|
router.push('/devices')
|
||||||
|
return // Don't set loading to false — component will unmount
|
||||||
}
|
}
|
||||||
} catch (deviceError) {
|
} catch (deviceError) {
|
||||||
console.error('Error fetching devices:', deviceError)
|
console.error('Error fetching devices:', deviceError)
|
||||||
// Still redirect to devices page to handle error there
|
// Still redirect to devices page to handle error there
|
||||||
router.push('/devices')
|
router.push('/devices')
|
||||||
|
return // Don't set loading to false — component will unmount
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'کد وارد شده نادرست است')
|
setError(result.message || 'کد وارد شده نادرست است')
|
||||||
|
|||||||
104
src/app/weather/page.tsx
Normal file
104
src/app/weather/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { CloudSun, RefreshCw, MapPin } from 'lucide-react'
|
||||||
|
import { AppShell, useDevice } from '@/components/layout'
|
||||||
|
import { WeatherData, fetchForecastWeather, fetchLocationName } from '@/features/weather'
|
||||||
|
import { QOM_LAT, QOM_LON } from '@/features/weather/helpers'
|
||||||
|
import { TodayWeather } from '@/components/daily-report/weather/TodayWeather'
|
||||||
|
import Loading from '@/components/Loading'
|
||||||
|
import { ErrorMessage } from '@/components/common'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
|
||||||
|
export default function WeatherPage() {
|
||||||
|
const { deviceName } = useDevice()
|
||||||
|
const [weatherData, setWeatherData] = useState<WeatherData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [expandedDayIndex, setExpandedDayIndex] = useState<number | null>(null)
|
||||||
|
const [locationName, setLocationName] = useState<string>('در حال دریافت...')
|
||||||
|
|
||||||
|
const loadWeather = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [weather, location] = await Promise.all([
|
||||||
|
fetchForecastWeather(),
|
||||||
|
fetchLocationName(QOM_LAT, QOM_LON)
|
||||||
|
])
|
||||||
|
|
||||||
|
setWeatherData(weather)
|
||||||
|
setLocationName(location)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading weather:', error)
|
||||||
|
setError('خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید.')
|
||||||
|
setLocationName('کهک قم، ایران')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWeather()
|
||||||
|
}, [loadWeather])
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setWeatherData(null)
|
||||||
|
loadWeather()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
pageTitle="آب و هوا"
|
||||||
|
pageIcon={CloudSun}
|
||||||
|
pageInfo={deviceName || undefined}
|
||||||
|
iconGradient="from-sky-500 to-cyan-600"
|
||||||
|
>
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Location Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-gray-600">
|
||||||
|
<MapPin className="w-5 h-5 text-sky-500" />
|
||||||
|
<span className="font-medium">{locationName}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
variant="outline"
|
||||||
|
icon={RefreshCw}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
بروزرسانی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Loading message="در حال دریافت اطلاعات آب و هوا..." fullScreen={false} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<ErrorMessage
|
||||||
|
message={error}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
variant="primary"
|
||||||
|
icon={RefreshCw}
|
||||||
|
>
|
||||||
|
تلاش مجدد
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{weatherData && !loading && (
|
||||||
|
<TodayWeather
|
||||||
|
weatherData={weatherData}
|
||||||
|
expandedDayIndex={expandedDayIndex}
|
||||||
|
onDayToggle={setExpandedDayIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,10 +151,19 @@ 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"
|
||||||
onChange={(e) => onUpdate(index, { value1: Number(e.target.value) })}
|
value={displayValue1}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sanitized = sanitizeDecimal(e.target.value)
|
||||||
|
setRawValue1(sanitized)
|
||||||
|
}}
|
||||||
|
onFocus={() => { isFocused1.current = true }}
|
||||||
|
onBlur={() => {
|
||||||
|
isFocused1.current = false
|
||||||
|
commitValue1(rawValue1)
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
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 ? 'از' : 'مقدار'}
|
||||||
required
|
required
|
||||||
@@ -112,10 +177,19 @@ 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"
|
||||||
onChange={(e) => onUpdate(index, { value2: Number(e.target.value) })}
|
value={displayValue2}
|
||||||
|
onChange={(e) => {
|
||||||
|
const sanitized = sanitizeDecimal(e.target.value)
|
||||||
|
setRawValue2(sanitized)
|
||||||
|
}}
|
||||||
|
onFocus={() => { isFocused2.current = true }}
|
||||||
|
onBlur={() => {
|
||||||
|
isFocused2.current = false
|
||||||
|
commitValue2(rawValue2)
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 pl-10 bg-white border border-orange-200 rounded-lg text-sm font-medium focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
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="تا"
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,22 +4,55 @@ import { DeviceDto } from '@/lib/api'
|
|||||||
|
|
||||||
type DeviceCardProps = {
|
type DeviceCardProps = {
|
||||||
device: DeviceDto
|
device: DeviceDto
|
||||||
href: string
|
href?: string
|
||||||
|
onClick?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceCard({ device, href, className }: DeviceCardProps) {
|
export function DeviceCard({ device, href, onClick, className }: DeviceCardProps) {
|
||||||
|
if (onClick) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
href={href}
|
onClick={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 ${className || ''}`}
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick(); }}
|
||||||
|
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}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">
|
||||||
|
{device.location || 'بدون موقعیت'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<span>{device.userName} {device.userFamily}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Calendar className="w-5 h-5 text-gray-400 group-hover:text-green-600 transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href || '#'}
|
||||||
|
className={`group bg-white rounded-lg border border-gray-200 hover:border-green-300 transition-all duration-150 overflow-hidden p-5 relative ${className || ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-green-600 flex items-center justify-center group-hover:scale-105 transition-transform duration-150">
|
||||||
|
<Settings className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-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">
|
||||||
@@ -33,7 +66,6 @@ export function DeviceCard({ device, href, 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,12 +37,12 @@ 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>
|
||||||
@@ -99,7 +103,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Summary Cards Grid */}
|
{/* Summary Cards Grid */}
|
||||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{(!visibleParams || visibleParams.length === 0 || visibleParams.includes('temperature')) && (
|
{(!visibleParams || visibleParams.length === 0 || visibleParams.includes('temperature')) && (
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
param="temperature"
|
param="temperature"
|
||||||
@@ -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 && (
|
||||||
|
|||||||
46
src/components/layout/AppShell.tsx
Normal file
46
src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { LucideIcon } from 'lucide-react'
|
||||||
|
import { TopHeader } from './TopHeader'
|
||||||
|
import { BottomNav } from './BottomNav'
|
||||||
|
|
||||||
|
type AppShellProps = {
|
||||||
|
children: ReactNode
|
||||||
|
pageTitle: string
|
||||||
|
pageIcon: LucideIcon
|
||||||
|
pageInfo?: string
|
||||||
|
iconGradient?: string
|
||||||
|
hideBottomNav?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({
|
||||||
|
children,
|
||||||
|
pageTitle,
|
||||||
|
pageIcon,
|
||||||
|
pageInfo,
|
||||||
|
iconGradient,
|
||||||
|
hideBottomNav = false
|
||||||
|
}: AppShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white">
|
||||||
|
<TopHeader
|
||||||
|
pageTitle={pageTitle}
|
||||||
|
pageIcon={pageIcon}
|
||||||
|
pageInfo={pageInfo}
|
||||||
|
iconGradient={iconGradient}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 px-4 py-4 md:px-6 md:py-6 max-w-7xl mx-auto w-full">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{!hideBottomNav && <BottomNav />}
|
||||||
|
|
||||||
|
{/* Spacer for bottom nav */}
|
||||||
|
{!hideBottomNav && (
|
||||||
|
<div className="h-20" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/layout/BottomNav.tsx
Normal file
101
src/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { User, Bell, BarChart3, CloudSun } from 'lucide-react'
|
||||||
|
import { useDevice } from './DeviceProvider'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
label: string
|
||||||
|
icon: typeof User
|
||||||
|
route: string
|
||||||
|
matchPatterns: string[] // Routes that should highlight this tab
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
label: 'پروفایل',
|
||||||
|
icon: User,
|
||||||
|
route: '/profile',
|
||||||
|
matchPatterns: ['/profile']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'هشدارها',
|
||||||
|
icon: Bell,
|
||||||
|
route: '/alert-settings',
|
||||||
|
matchPatterns: ['/alert-settings']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'گزارش',
|
||||||
|
icon: BarChart3,
|
||||||
|
route: '/daily-report',
|
||||||
|
matchPatterns: ['/daily-report', '/calendar', '/day-details', '/device-settings']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'آب و هوا',
|
||||||
|
icon: CloudSun,
|
||||||
|
route: '/weather',
|
||||||
|
matchPatterns: ['/weather']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const { deviceId } = useDevice()
|
||||||
|
|
||||||
|
const getActiveIndex = () => {
|
||||||
|
const idx = navItems.findIndex(item =>
|
||||||
|
item.matchPatterns.some(pattern => pathname.startsWith(pattern))
|
||||||
|
)
|
||||||
|
return idx >= 0 ? idx : 2 // Default to Report (index 2) if no match
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = getActiveIndex()
|
||||||
|
|
||||||
|
const handleNavigate = (item: NavItem) => {
|
||||||
|
const params = deviceId ? `?deviceId=${deviceId}` : ''
|
||||||
|
router.push(`${item.route}${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200"
|
||||||
|
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-around h-14">
|
||||||
|
{navItems.map((item, index) => {
|
||||||
|
const isActive = index === activeIndex
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.route}
|
||||||
|
onClick={() => handleNavigate(item)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-all duration-150',
|
||||||
|
'min-w-0 min-h-0 py-1',
|
||||||
|
isActive
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'rounded-md p-1.5 transition-colors duration-150',
|
||||||
|
isActive && 'bg-green-50'
|
||||||
|
)}>
|
||||||
|
<Icon className={cn(
|
||||||
|
'w-5 h-5 transition-transform duration-150',
|
||||||
|
isActive && 'scale-110'
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[10px] font-medium leading-tight transition-colors duration-150',
|
||||||
|
isActive ? 'text-green-600' : 'text-gray-400'
|
||||||
|
)}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
src/components/layout/DeviceProvider.tsx
Normal file
51
src/components/layout/DeviceProvider.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY_DEVICE_ID = 'selectedDeviceId'
|
||||||
|
const STORAGE_KEY_DEVICE_NAME = 'selectedDeviceName'
|
||||||
|
|
||||||
|
type DeviceContextType = {
|
||||||
|
deviceId: number
|
||||||
|
deviceName: string
|
||||||
|
setDevice: (id: number, name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceContext = createContext<DeviceContextType>({
|
||||||
|
deviceId: 0,
|
||||||
|
deviceName: '',
|
||||||
|
setDevice: () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function DeviceProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [deviceId, setDeviceId] = useState<number>(0)
|
||||||
|
const [deviceName, setDeviceName] = useState<string>('')
|
||||||
|
|
||||||
|
// Initialize from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedId = localStorage.getItem(STORAGE_KEY_DEVICE_ID)
|
||||||
|
const savedName = localStorage.getItem(STORAGE_KEY_DEVICE_NAME)
|
||||||
|
if (savedId) {
|
||||||
|
setDeviceId(Number(savedId))
|
||||||
|
setDeviceName(savedName || '')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setDevice = useCallback((id: number, name: string) => {
|
||||||
|
setDeviceId(id)
|
||||||
|
setDeviceName(name)
|
||||||
|
// Persist to localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY_DEVICE_ID, String(id))
|
||||||
|
localStorage.setItem(STORAGE_KEY_DEVICE_NAME, name)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeviceContext.Provider value={{ deviceId, deviceName, setDevice }}>
|
||||||
|
{children}
|
||||||
|
</DeviceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevice() {
|
||||||
|
return useContext(DeviceContext)
|
||||||
|
}
|
||||||
57
src/components/layout/TopHeader.tsx
Normal file
57
src/components/layout/TopHeader.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { LucideIcon } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useDevice } from './DeviceProvider'
|
||||||
|
|
||||||
|
type TopHeaderProps = {
|
||||||
|
pageTitle: string
|
||||||
|
pageIcon: LucideIcon
|
||||||
|
pageInfo?: string
|
||||||
|
iconGradient?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopHeader({
|
||||||
|
pageTitle,
|
||||||
|
pageIcon: Icon,
|
||||||
|
pageInfo,
|
||||||
|
iconGradient = 'from-indigo-500 to-purple-600',
|
||||||
|
className
|
||||||
|
}: TopHeaderProps) {
|
||||||
|
const { deviceName } = useDevice()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={cn('sticky top-0 z-40 bg-white border-b border-gray-200', className)}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-2 flex items-center justify-between gap-3">
|
||||||
|
{/* Right: Device Name */}
|
||||||
|
<div className="text-xs text-gray-500 font-medium truncate min-w-0 flex-1">
|
||||||
|
{deviceName || 'دستگاه'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Icon + Title + PageInfo */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-shrink-0">
|
||||||
|
<div className={cn(
|
||||||
|
'inline-flex items-center justify-center w-7 h-7 rounded-md',
|
||||||
|
iconGradient
|
||||||
|
)}>
|
||||||
|
<Icon className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-sm md:text-base font-bold text-gray-900 whitespace-nowrap">
|
||||||
|
{pageTitle}
|
||||||
|
</h1>
|
||||||
|
{pageInfo && (
|
||||||
|
<span className="text-[11px] text-gray-500 hidden sm:inline">
|
||||||
|
{pageInfo}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left: spacer for balance (empty) */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/components/layout/index.ts
Normal file
4
src/components/layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { AppShell } from './AppShell'
|
||||||
|
export { BottomNav } from './BottomNav'
|
||||||
|
export { TopHeader } from './TopHeader'
|
||||||
|
export { DeviceProvider, useDevice } from './DeviceProvider'
|
||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ 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'
|
||||||
|
|
||||||
|
console.log('api-base: ', API_BASE);
|
||||||
|
|
||||||
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
|
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -33,7 +36,14 @@ async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
|||||||
error.status = res.status
|
error.status = res.status
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>
|
|
||||||
|
// Check if response has content
|
||||||
|
const text = await res.text()
|
||||||
|
if (!text) {
|
||||||
|
return undefined as T // or return null as T, depending on your preference
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(text) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -65,7 +75,12 @@ export const api = {
|
|||||||
updateDeviceSettings: (dto: DeviceSettingsDto) => http<void>(`${API_BASE}/api/devicesettings`, { method: 'PUT', body: JSON.stringify(dto) }),
|
updateDeviceSettings: (dto: DeviceSettingsDto) => http<void>(`${API_BASE}/api/devicesettings`, { method: 'PUT', body: JSON.stringify(dto) }),
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
sendCode: (request: SendCodeRequest) => http<SendCodeResponse>(`${API_BASE}/api/auth/send-code`, { method: 'POST', body: JSON.stringify(request) }),
|
sendCode: (request: SendCodeRequest) => {
|
||||||
|
const url = `${API_BASE}/api/auth/send-code`;
|
||||||
|
|
||||||
|
console.log('posting to ' + url);
|
||||||
|
return http<SendCodeResponse>(url, { method: 'POST', body: JSON.stringify(request) })
|
||||||
|
},
|
||||||
verifyCode: (request: VerifyCodeRequest) => http<VerifyCodeResponse>(`${API_BASE}/api/auth/verify-code`, { method: 'POST', body: JSON.stringify(request) }),
|
verifyCode: (request: VerifyCodeRequest) => http<VerifyCodeResponse>(`${API_BASE}/api/auth/verify-code`, { method: 'POST', body: JSON.stringify(request) }),
|
||||||
canResend: (mobile: string) => http<{ canResend: boolean }>(`${API_BASE}/api/auth/can-resend?mobile=${encodeURIComponent(mobile)}`),
|
canResend: (mobile: string) => http<{ canResend: boolean }>(`${API_BASE}/api/auth/can-resend?mobile=${encodeURIComponent(mobile)}`),
|
||||||
|
|
||||||
@@ -98,6 +113,14 @@ export const api = {
|
|||||||
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