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 STATIC_CACHE_NAME = 'greenhome-static-1766182760520';
|
||||
const CACHE_NAME = 'greenhome-1780331120215';
|
||||
const STATIC_CACHE_NAME = 'greenhome-static-1780331120215';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_FILES_TO_CACHE = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "1766182760520"
|
||||
"version": "1780331120215"
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useCallback, Suspense } from 'react'
|
||||
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 Loading from '@/components/Loading'
|
||||
import { PageHeader, EmptyState } from '@/components/common'
|
||||
import { EmptyState } from '@/components/common'
|
||||
import { confirmDialog } from '@/components/utils'
|
||||
import { AlertListItem } from '@/components/alert-settings/AlertListItem'
|
||||
import { AlertFormModal } from '@/components/alert-settings/AlertFormModal'
|
||||
import { AppShell } from '@/components/layout'
|
||||
|
||||
function AlertSettingsContent() {
|
||||
const searchParams = useSearchParams()
|
||||
@@ -102,39 +103,36 @@ function AlertSettingsContent() {
|
||||
if (loading) return <Loading message="در حال بارگذاری تنظیمات هشدار..." />
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<PageHeader
|
||||
icon={Bell}
|
||||
title="تنظیمات هشدارها"
|
||||
subtitle="مدیریت شرایط و هشدارهای دستگاه"
|
||||
iconGradient="from-orange-500 to-red-600"
|
||||
action={
|
||||
<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">
|
||||
<Plus className="w-5 h-5" /> افزودن هشدار
|
||||
</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>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<EmptyState icon={AlertTriangle} message="هیچ هشداری ثبت نشده است"
|
||||
action={<button onClick={openCreate} className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"><Plus className="w-4 h-4" /> افزودن اولین هشدار</button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{alerts.map(alert => (
|
||||
<AlertListItem key={alert.id} alert={alert} onEdit={openEdit} onDelete={handleDelete} onToggleActive={toggleActive} />
|
||||
))}
|
||||
<>
|
||||
<AppShell
|
||||
pageTitle="تنظیمات هشدارها"
|
||||
pageIcon={Bell}
|
||||
iconGradient="from-orange-500 to-red-600"
|
||||
>
|
||||
<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}
|
||||
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" /> افزودن هشدار
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<EmptyState icon={AlertTriangle} message="هیچ هشداری ثبت نشده است"
|
||||
action={<button onClick={openCreate} className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors"><Plus className="w-4 h-4" /> افزودن اولین هشدار</button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{alerts.map(alert => (
|
||||
<AlertListItem key={alert.id} alert={alert} onEdit={openEdit} onDelete={handleDelete} onToggleActive={toggleActive} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<AlertFormModal
|
||||
isOpen={modal.open}
|
||||
@@ -148,7 +146,7 @@ function AlertSettingsContent() {
|
||||
onRemoveRule={removeRule}
|
||||
onUpdateRule={updateRule}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear } from '@/lib/date/persian-date'
|
||||
import { Calendar as CalendarIcon, Database, TrendingUp } from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
import { PageHeader, BackLink, Card } from '@/components/common'
|
||||
import { Card } from '@/components/common'
|
||||
import { YearSelector } from '@/components/calendar'
|
||||
import { MonthCard } from '@/components/cards'
|
||||
import { StatsCard } from '@/components/cards'
|
||||
import { AppShell } from '@/components/layout'
|
||||
|
||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
const [value, setValue] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
setValue(params.get(name))
|
||||
}
|
||||
}, [name])
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
function CalendarContent() {
|
||||
const router = useRouter()
|
||||
const deviceIdParam = useQueryParam('deviceId')
|
||||
const searchParams = useSearchParams()
|
||||
const deviceIdParam = searchParams.get('deviceId')
|
||||
const [deviceId, setDeviceId] = useState<number>(1)
|
||||
const [year, setYear] = useState<number>(getCurrentPersianYear())
|
||||
const [activeMonths, setActiveMonths] = useState<number[]>([])
|
||||
@@ -75,16 +64,12 @@ export default function CalendarPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<AppShell
|
||||
pageTitle="انتخاب سال و ماه"
|
||||
pageIcon={CalendarIcon}
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
||||
<PageHeader
|
||||
icon={CalendarIcon}
|
||||
title="انتخاب سال و ماه"
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
/>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card padding="lg">
|
||||
{/* Year Selector */}
|
||||
@@ -130,6 +115,14 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری تقویم..." />}>
|
||||
<CalendarContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
import { useEffect, useMemo, useState, useCallback, Suspense, lazy } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { api, TelemetryDto } from '@/lib/api'
|
||||
import { api, TelemetryDto, PowerOutageSummaryDto } from '@/lib/api'
|
||||
import { persianToGregorian, getCurrentPersianDay, getCurrentPersianYear, getCurrentPersianMonth, getPreviousPersianDay, getNextPersianDay } from '@/lib/date/persian-date'
|
||||
import { formatPersianDate, ensureDateFormat } from '@/lib/format/persian-date'
|
||||
import { TABS, TabType } from '@/features/daily-report'
|
||||
import { detectDataGaps } from '@/features/daily-report/utils'
|
||||
import { BarChart3, Bell, CalendarIcon, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { BarChart3, CalendarIcon, ChevronRight } from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
import { SummaryTab } from '@/components/daily-report'
|
||||
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 { usePullToRefresh } from '@/hooks/usePullToRefresh'
|
||||
import { AppShell } from '@/components/layout'
|
||||
|
||||
// Lazy load heavy components
|
||||
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 [forecastWeatherLoading, setForecastWeatherLoading] = useState(false)
|
||||
const [visibleParams, setVisibleParams] = useState<string[] | null>(null)
|
||||
const [powerOutageSummary, setPowerOutageSummary] = useState<PowerOutageSummaryDto | null>(null)
|
||||
const [powerOutageLoading, setPowerOutageLoading] = useState(false)
|
||||
|
||||
// Map summary param to chart key
|
||||
const paramToChartKey = useCallback((param: string): string => {
|
||||
@@ -131,6 +133,38 @@ function DailyReportContent() {
|
||||
}
|
||||
}, [deviceId, selectedDate])
|
||||
|
||||
// Load power outage summary
|
||||
useEffect(() => {
|
||||
if (!selectedDate) {
|
||||
setPowerOutageSummary(null)
|
||||
return
|
||||
}
|
||||
|
||||
const loadPowerOutage = async () => {
|
||||
setPowerOutageLoading(true)
|
||||
try {
|
||||
const [year, month, day] = selectedDate.split('/').map(Number)
|
||||
const startDate = persianToGregorian(year, month, day)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
const endDate = new Date(startDate)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
|
||||
const startUtc = startDate.toISOString()
|
||||
const endUtc = endDate.toISOString()
|
||||
|
||||
const summary = await api.getPowerOutageSummary(deviceId, startUtc, endUtc)
|
||||
setPowerOutageSummary(summary)
|
||||
} catch (error) {
|
||||
console.error('Error loading power outage summary:', error)
|
||||
setPowerOutageSummary(null)
|
||||
} finally {
|
||||
setPowerOutageLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadPowerOutage()
|
||||
}, [deviceId, selectedDate])
|
||||
|
||||
// Pull-to-refresh for mobile
|
||||
usePullToRefresh(loadData)
|
||||
|
||||
@@ -189,6 +223,27 @@ function DailyReportContent() {
|
||||
const gas = useMemo(() => sortedTelemetry.map(t => Number(t.gasPPM ?? 0)), [sortedTelemetry])
|
||||
const lux = useMemo(() => sortedTelemetry.map(t => Number(t.lux ?? 0)), [sortedTelemetry])
|
||||
|
||||
// Latest values for live summary
|
||||
const latestValues = useMemo(() => ({
|
||||
temperature: temp.length > 0 ? temp.at(-1) ?? 0 : 0,
|
||||
humidity: hum.length > 0 ? hum.at(-1) ?? 0 : 0,
|
||||
soil: soil.length > 0 ? soil.at(-1) ?? 0 : 0,
|
||||
gas: gas.length > 0 ? gas.at(-1) ?? 0 : 0,
|
||||
lux: lux.length > 0 ? lux.at(-1) ?? 0 : 0,
|
||||
}), [temp, hum, soil, gas, lux])
|
||||
|
||||
// Last updated time from the most recent telemetry entry
|
||||
const lastUpdated = useMemo(() => {
|
||||
if (sortedTelemetry.length === 0) return undefined
|
||||
const lastTimestamp = sortedTelemetry.at(-1)?.serverTimestampUtc || sortedTelemetry.at(-1)?.timestampUtc
|
||||
if (!lastTimestamp) return undefined
|
||||
const date = new Date(lastTimestamp)
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
}, [sortedTelemetry])
|
||||
|
||||
// Detect data gaps in the full day data
|
||||
const dataGaps = useMemo(() => {
|
||||
const timestamps = sortedTelemetry.map(t => t.serverTimestampUtc || t.timestampUtc)
|
||||
@@ -201,50 +256,43 @@ function DailyReportContent() {
|
||||
|
||||
if (!selectedDate) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||
<Button
|
||||
onClick={goToCalendar}
|
||||
variant="outline"
|
||||
icon={ChevronRight}
|
||||
>
|
||||
بازگشت به تقویم
|
||||
</Button>
|
||||
<AppShell
|
||||
pageTitle="گزارش روزانه"
|
||||
pageIcon={BarChart3}
|
||||
iconGradient="from-indigo-500 to-purple-600"
|
||||
>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<div className="text-lg text-red-600 mb-4">تاریخ انتخاب نشده است</div>
|
||||
<Button
|
||||
onClick={goToCalendar}
|
||||
variant="outline"
|
||||
icon={ChevronRight}
|
||||
>
|
||||
بازگشت به تقویم
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
title="گزارش روزانه"
|
||||
iconGradient="from-indigo-500 to-purple-600"
|
||||
action={
|
||||
<Link
|
||||
href={`/alert-settings?deviceId=${deviceId}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppShell
|
||||
pageTitle="گزارش روزانه"
|
||||
pageIcon={BarChart3}
|
||||
pageInfo={selectedDate}
|
||||
iconGradient="from-indigo-500 to-purple-600"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Date Navigation Buttons */}
|
||||
{selectedDate && (
|
||||
<DateNavigation
|
||||
selectedDate={selectedDate}
|
||||
onPrevious={goToPreviousDay}
|
||||
onNext={goToNextDay}
|
||||
onCalendar={goToCalendar}
|
||||
/>
|
||||
)}
|
||||
<DateNavigation
|
||||
selectedDate={selectedDate}
|
||||
onPrevious={goToPreviousDay}
|
||||
onNext={goToNextDay}
|
||||
onCalendar={goToCalendar}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
@@ -254,7 +302,7 @@ function DailyReportContent() {
|
||||
className="md:mx-0 mx-[-1rem] md:rounded-xl rounded-none"
|
||||
>
|
||||
{{
|
||||
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} onCardClick={handleCardClick} visibleParams={visibleParams} />,
|
||||
summary: <SummaryTab temperature={temp} humidity={hum} soil={soil} gas={gas} lux={lux} forecastWeather={forecastWeather} forecastWeatherLoading={forecastWeatherLoading} onCardClick={handleCardClick} visibleParams={visibleParams} powerOutageSummary={powerOutageSummary} powerOutageLoading={powerOutageLoading} />,
|
||||
charts: (
|
||||
<Suspense fallback={<Loading message="در حال بارگذاری نمودارها..." />}>
|
||||
<ChartsTab sortedTelemetry={sortedTelemetry} dataGaps={dataGaps} visibleParams={visibleParams} />
|
||||
@@ -273,7 +321,7 @@ function DailyReportContent() {
|
||||
}}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import { api } from '@/lib/api'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getPersianMonthStartWeekday, getPersianMonthDays } from '@/lib/date/persian-date'
|
||||
import { Calendar as CalendarIcon, Database } from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
import { PageHeader, BackLink, Card } from '@/components/common'
|
||||
import { Card } from '@/components/common'
|
||||
import { WeekdayHeaders } from '@/components/calendar'
|
||||
import { CalendarDayCell, StatsCard } from '@/components/cards'
|
||||
import { AppShell } from '@/components/layout'
|
||||
|
||||
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند']
|
||||
|
||||
@@ -71,16 +72,12 @@ function DayDetailsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<AppShell
|
||||
pageTitle={`${monthNames[month - 1]} ${year}`}
|
||||
pageIcon={CalendarIcon}
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<BackLink href={`/calendar?deviceId=${deviceId}`} label="بازگشت به تقویم" />
|
||||
<PageHeader
|
||||
icon={CalendarIcon}
|
||||
title={`${monthNames[month - 1]} ${year}`}
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
/>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<Card className="overflow-hidden" padding="none">
|
||||
{/* Weekday Headers */}
|
||||
@@ -138,7 +135,7 @@ function DayDetailsContent() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { api, DeviceSettingsDto } from '@/lib/api'
|
||||
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle, Bell } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Settings, Thermometer, Wind, Sun, Droplets, Save, Loader2, AlertCircle } from 'lucide-react'
|
||||
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 { AppShell } from '@/components/layout'
|
||||
|
||||
function useQueryParam(name: string) {
|
||||
if (typeof window === 'undefined') return null as string | null
|
||||
@@ -95,36 +95,25 @@ export default function DeviceSettingsPage() {
|
||||
|
||||
if (!deviceId) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message="شناسه دستگاه مشخص نشده است"
|
||||
fullPage
|
||||
action={
|
||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
||||
}
|
||||
/>
|
||||
<AppShell
|
||||
pageTitle="تنظیمات دستگاه"
|
||||
pageIcon={Settings}
|
||||
iconGradient="from-blue-500 to-blue-600"
|
||||
>
|
||||
<ErrorMessage
|
||||
message="شناسه دستگاه مشخص نشده است"
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6">
|
||||
<AppShell
|
||||
pageTitle={`تنظیمات ${deviceName}`}
|
||||
pageIcon={Settings}
|
||||
iconGradient="from-blue-500 to-blue-600"
|
||||
>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<BackLink href="/devices" label="بازگشت به انتخاب دستگاه" />
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title={`تنظیمات ${deviceName}`}
|
||||
iconGradient="from-blue-500 to-blue-600"
|
||||
action={
|
||||
<Link
|
||||
href={`/alert-settings?deviceId=${deviceId}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800 rounded-xl transition-all duration-200 font-medium shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">تنظیمات هشدار</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
{error && <ErrorMessage message={error} className="mb-6" />}
|
||||
{success && <SuccessMessage message={success} className="mb-6" />}
|
||||
@@ -252,7 +241,7 @@ export default function DeviceSettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ import { api, DeviceDto, PagedResult } from '@/lib/api'
|
||||
import { Settings, LogOut } from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
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 { DeviceCard } from '@/components/cards'
|
||||
import { Pagination } from '@/components/navigation'
|
||||
import { useDevice } from '@/components/layout'
|
||||
|
||||
export default function DevicesPage() {
|
||||
const router = useRouter()
|
||||
const { setDevice } = useDevice()
|
||||
const [pagedResult, setPagedResult] = useState<PagedResult<DeviceDto> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -22,6 +24,12 @@ export default function DevicesPage() {
|
||||
|
||||
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) => {
|
||||
if (!user) return
|
||||
|
||||
@@ -39,9 +47,8 @@ export default function DevicesPage() {
|
||||
if (result.items.length === 0 && page === 1) {
|
||||
setError('شما هیچ دستگاهی ندارید. لطفاً با پشتیبانی تماس بگیرید.')
|
||||
} else if (result.items.length === 1 && result.totalCount === 1 && !search) {
|
||||
// Single device - redirect to today's daily report
|
||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||
router.push(`/daily-report?deviceId=${result.items[0].id}&date=${encodeURIComponent(today)}`)
|
||||
// Single device - set context and redirect to today's daily report
|
||||
handleDeviceSelect(result.items[0])
|
||||
return
|
||||
} else {
|
||||
setPagedResult(result)
|
||||
@@ -52,7 +59,7 @@ export default function DevicesPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [user, router])
|
||||
}, [user, router, handleDeviceSelect])
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication
|
||||
@@ -120,21 +127,34 @@ export default function DevicesPage() {
|
||||
<div className="min-h-screen p-4 md:p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title="انتخاب دستگاه"
|
||||
subtitle={user ? `${user.name} ${user.family} (${user.mobile})` : undefined}
|
||||
action={
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
خروج
|
||||
</button>
|
||||
}
|
||||
iconGradient="from-green-500 to-green-600"
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-xl shadow-md">
|
||||
<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
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl transition-all duration-200 text-sm"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
@@ -174,16 +194,13 @@ export default function DevicesPage() {
|
||||
{pagedResult && pagedResult.items.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{pagedResult.items.map((device) => {
|
||||
const today = `${getCurrentPersianYear()}/${String(getCurrentPersianMonth()).padStart(2, '0')}/${String(getCurrentPersianDay()).padStart(2, '0')}`
|
||||
return (
|
||||
<DeviceCard
|
||||
key={device.id}
|
||||
device={device}
|
||||
href={`/daily-report?deviceId=${device.id}&date=${encodeURIComponent(today)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{pagedResult.items.map((device) => (
|
||||
<DeviceCard
|
||||
key={device.id}
|
||||
device={device}
|
||||
onClick={() => handleDeviceSelect(device)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
@@ -203,11 +220,6 @@ export default function DevicesPage() {
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Back to Home */}
|
||||
<div className="mt-6 text-center">
|
||||
<BackLink href="/" label="بازگشت به صفحه اصلی" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
/* --background: #ffffff; */
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* --color-background: var(--background); */
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
--font-mono: 'Vazirmatn', monospace;
|
||||
@@ -14,7 +12,6 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* --background: #0a0a0a; */
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
@@ -31,12 +28,11 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
/* background: var(--background); */
|
||||
background: linear-gradient(180deg, #eef7ff, #f6fbff);
|
||||
background: #f8fafc;
|
||||
color: var(--foreground);
|
||||
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
overscroll-behavior-y: contain; /* Prevent pull-to-refresh on Android */
|
||||
overscroll-behavior-y: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@@ -44,7 +40,7 @@ body {
|
||||
button, a, [role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
touch-action: manipulation; /* Disable double-tap zoom */
|
||||
touch-action: manipulation;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@@ -56,7 +52,7 @@ button, a, [role="button"] {
|
||||
.transition-transform,
|
||||
.transition-opacity {
|
||||
will-change: transform, opacity;
|
||||
transform: translateZ(0); /* Force GPU acceleration */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.persian-number {
|
||||
@@ -157,3 +153,23 @@ button, a, [role="button"] {
|
||||
background-size: 2000px 100%;
|
||||
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 ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||
import UpdateNotification from '@/components/UpdateNotification'
|
||||
import { DeviceProvider } from '@/components/layout'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'GreenHome',
|
||||
@@ -42,7 +43,9 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||
<body className='persian-number'>
|
||||
<ServiceWorkerRegistration />
|
||||
<UpdateNotification />
|
||||
{children}
|
||||
<DeviceProvider>
|
||||
{children}
|
||||
</DeviceProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function LoginPage() {
|
||||
if (result.success) {
|
||||
// Redirect to verify page with mobile number
|
||||
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
|
||||
return // Don't set loading to false — component will unmount
|
||||
} else {
|
||||
setError(result.message || 'خطا در ارسال کد')
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Smartphone, ArrowLeft } from 'lucide-react'
|
||||
import Loading from '@/components/Loading'
|
||||
import { MobileInput } from '@/components/forms'
|
||||
import { ErrorMessage } from '@/components/common'
|
||||
import { getCurrentPersianYear, getCurrentPersianMonth, getCurrentPersianDay } from '@/lib/date/persian-date'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
@@ -20,9 +21,35 @@ export default function Home() {
|
||||
|
||||
if (token && userStr) {
|
||||
try {
|
||||
JSON.parse(userStr)
|
||||
// Redirect to devices check
|
||||
router.push('/devices')
|
||||
const user = JSON.parse(userStr)
|
||||
|
||||
// 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')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// On error, fall back to devices page
|
||||
router.push('/devices')
|
||||
})
|
||||
} catch {
|
||||
// Invalid user data, go to login
|
||||
}
|
||||
@@ -62,6 +89,7 @@ export default function Home() {
|
||||
if (result.success) {
|
||||
// Redirect to verify page with mobile number
|
||||
router.push(`/verify-code?mobile=${encodeURIComponent(normalizedMobile)}`)
|
||||
return // Don't set loading to false — component will unmount
|
||||
} else {
|
||||
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) {
|
||||
// Single device - redirect to calendar
|
||||
router.push(`/calendar?deviceId=${devicesResult.items[0].id}`)
|
||||
return // Don't set loading to false — component will unmount
|
||||
} else {
|
||||
// Multiple devices - redirect to device list
|
||||
router.push('/devices')
|
||||
return // Don't set loading to false — component will unmount
|
||||
}
|
||||
} catch (deviceError) {
|
||||
console.error('Error fetching devices:', deviceError)
|
||||
// Still redirect to devices page to handle error there
|
||||
router.push('/devices')
|
||||
return // Don't set loading to false — component will unmount
|
||||
}
|
||||
} else {
|
||||
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 }) {
|
||||
return (
|
||||
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
|
||||
<div id={id} className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-300 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200 px-5 py-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
|
||||
@@ -77,19 +77,19 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
|
||||
const hasStats = subtitle?.includes('حداکثر') && subtitle?.includes('حداقل')
|
||||
|
||||
return (
|
||||
<div className={`group relative bg-white rounded-xl border-2 ${colors.border} shadow-md hover:shadow-lg transition-all duration-300 overflow-hidden`}>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
<div className={`bg-white rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-150 overflow-hidden`}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs font-medium text-gray-500">
|
||||
{title.replace(/[💡🫁🌱💧🌡️📊]/g, '').trim()}
|
||||
</div>
|
||||
{IconComponent && (
|
||||
<div className={`w-8 h-8 bg-gradient-to-br ${colors.iconBg} rounded-lg flex items-center justify-center shadow-sm`}>
|
||||
<IconComponent className="w-4 h-4 text-white" />
|
||||
<div className={`w-7 h-7 rounded-md ${colors.iconBg} flex items-center justify-center`}>
|
||||
<IconComponent className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-2">{value}</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mb-1">{value}</div>
|
||||
{subtitle && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2 flex-wrap">
|
||||
{hasStats ? (
|
||||
@@ -109,7 +109,6 @@ export function Card({ title, value, subtitle, icon, color }: CardProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r ${colors.gradient} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function Skeleton({ className, width, height, rounded = true }: SkeletonP
|
||||
// Card skeleton for loading states
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-md p-6 space-y-4">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
<Skeleton height="1.5rem" width="60%" />
|
||||
<Skeleton height="1rem" />
|
||||
<Skeleton height="1rem" width="80%" />
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function UpdateNotification() {
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 z-50 animate-in slide-in-from-bottom-5">
|
||||
<div className="bg-white rounded-xl shadow-lg border-2 border-indigo-500 p-4 flex items-center gap-3">
|
||||
<div className="bg-white rounded-lg shadow-md border border-indigo-500 p-4 flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function AlertFormModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-1.5 text-xs bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white rounded-lg transition-all duration-200 font-medium shadow-sm hover:shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-1.5 text-xs bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'در حال ذخیره...' : editingAlert ? 'ذخیره تغییرات' : 'افزودن هشدار'}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function AlertListItem({ alert, onEdit, onDelete, onToggleActive }: Alert
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => onToggleActive(alert)}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
||||
alert.isEnabled
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Bell } from 'lucide-react'
|
||||
import { SensorType, ComparisonType, NotificationType, TimeType } from './constants'
|
||||
import { generatePreviewText } from './utils'
|
||||
|
||||
@@ -12,14 +11,10 @@ type AlertPreviewProps = {
|
||||
|
||||
export function AlertPreview({ rules, notificationType, timeType }: AlertPreviewProps) {
|
||||
return (
|
||||
<div className="sticky top-[45px] z-10 bg-gradient-to-r from-blue-500 to-indigo-600 shadow-lg -mx-6 -mt-6">
|
||||
<div className="sticky top-[45px] z-10 bg-blue-600 -mx-6 -mt-6">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* <div className="flex-shrink-0 w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center backdrop-blur-sm">
|
||||
<Bell className="w-5 h-5 text-white" />
|
||||
</div> */}
|
||||
<div className="flex-1">
|
||||
{/* <div className="text-xs font-semibold text-white/90 mb-1.5">📢 پیشنمایش زنده</div> */}
|
||||
<div className="text-sm md:text-base text-white leading-relaxed font-medium">
|
||||
{generatePreviewText(rules, notificationType, timeType)}
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,9 @@ export function NotificationTypeSelector({ value, onChange }: NotificationTypeSe
|
||||
key={notif.value}
|
||||
type="button"
|
||||
onClick={() => onChange(notif.value)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all text-xs ${
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all duration-150 text-xs ${
|
||||
isSelected
|
||||
? 'border-orange-500 bg-orange-50 text-orange-700 shadow-sm'
|
||||
? 'border-orange-500 bg-orange-50 text-orange-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { SENSOR_TYPES, COMPARISON_TYPES, SensorType, ComparisonType } from './constants'
|
||||
import { getSensorIcon, getSensorLabel, getSensorUnit, getComparisonLabel } from './utils'
|
||||
@@ -13,16 +14,71 @@ type RuleFormCardProps = {
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
/** Validate and sanitize a decimal number string with max 1 decimal place */
|
||||
function sanitizeDecimal(input: string): string {
|
||||
// Remove any non-digit, non-dot, non-minus characters
|
||||
let cleaned = input.replace(/[^0-9.\-]/g, '')
|
||||
// Allow only one minus sign at the start
|
||||
const minusCount = (cleaned.match(/-/g) || []).length
|
||||
if (minusCount > 1) {
|
||||
cleaned = cleaned.replace(/-/g, '')
|
||||
if (input.startsWith('-')) cleaned = '-' + cleaned
|
||||
} else if (minusCount === 1 && !cleaned.startsWith('-')) {
|
||||
cleaned = cleaned.replace(/-/g, '')
|
||||
}
|
||||
// Allow only one decimal point
|
||||
const dotIndex = cleaned.indexOf('.')
|
||||
if (dotIndex !== -1) {
|
||||
const beforeDot = cleaned.slice(0, dotIndex + 1)
|
||||
const afterDot = cleaned.slice(dotIndex + 1).replace(/\./g, '')
|
||||
// Max 1 decimal place
|
||||
cleaned = beforeDot + afterDot.slice(0, 1)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: RuleFormCardProps) {
|
||||
const needsMax = COMPARISON_TYPES.find(c => c.value === rule.comparisonType)?.needsMax
|
||||
const SensorIcon = getSensorIcon(rule.sensorType)
|
||||
|
||||
// Helper to safely convert a number to string, handling null/undefined
|
||||
const toStr = (v: number | null | undefined): string => v != null ? String(v) : ''
|
||||
|
||||
// Local string state for each input to allow decimal point typing
|
||||
// without losing the dot on every keystroke (Number("5.") → 5 strips the dot)
|
||||
const [rawValue1, setRawValue1] = useState<string>(toStr(rule.value1))
|
||||
const [rawValue2, setRawValue2] = useState<string>(toStr(rule.value2))
|
||||
|
||||
// Track if each input is currently focused (being edited)
|
||||
const isFocused1 = useRef(false)
|
||||
const isFocused2 = useRef(false)
|
||||
|
||||
// Use the prop value when input is NOT focused (external updates)
|
||||
const displayValue1 = isFocused1.current ? rawValue1 : toStr(rule.value1)
|
||||
const displayValue2 = isFocused2.current ? rawValue2 : toStr(rule.value2)
|
||||
|
||||
const commitValue1 = useCallback((raw: string) => {
|
||||
const sanitized = sanitizeDecimal(raw)
|
||||
const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized)
|
||||
if (!isNaN(num)) {
|
||||
onUpdate(index, { value1: num })
|
||||
}
|
||||
}, [index, onUpdate])
|
||||
|
||||
const commitValue2 = useCallback((raw: string) => {
|
||||
const sanitized = sanitizeDecimal(raw)
|
||||
const num = sanitized === '' || sanitized === '-' || sanitized === '.' ? 0 : Number(sanitized)
|
||||
if (!isNaN(num)) {
|
||||
onUpdate(index, { value2: num })
|
||||
}
|
||||
}, [index, onUpdate])
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-orange-50 to-red-50 border border-orange-200 rounded-xl p-3 relative shadow-sm">
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
{/* Header - Compact */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-orange-700 bg-white/80 px-2 py-0.5 rounded-full shadow-sm">
|
||||
<span className="text-xs font-bold text-orange-700 bg-white px-2 py-0.5 rounded-md">
|
||||
{totalRules === 1 ? '⚡ شرط' : `⚡ شرط ${index + 1}`}
|
||||
</span>
|
||||
{/* Compact summary of current selection */}
|
||||
@@ -39,7 +95,7 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
className="p-1.5 text-red-500 hover:bg-red-100 rounded-lg transition-colors"
|
||||
className="p-1.5 text-red-500 hover:bg-red-100 rounded-md transition-colors"
|
||||
title="حذف این شرط"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@@ -95,10 +151,19 @@ export function RuleFormCard({ rule, index, totalRules, onUpdate, onRemove }: Ru
|
||||
<div className="flex gap-1.5 items-center flex-1">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.value1}
|
||||
onChange={(e) => onUpdate(index, { value1: Number(e.target.value) })}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
dir="ltr"
|
||||
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"
|
||||
placeholder={needsMax ? 'از' : 'مقدار'}
|
||||
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>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.value2 ?? ''}
|
||||
onChange={(e) => onUpdate(index, { value2: Number(e.target.value) })}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
dir="ltr"
|
||||
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"
|
||||
placeholder="تا"
|
||||
required
|
||||
|
||||
@@ -23,9 +23,9 @@ export function TimeTypeSelector({ value, onChange }: TimeTypeSelectorProps) {
|
||||
type="button"
|
||||
onClick={() => onChange(time.value)}
|
||||
title={time.description}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all text-xs ${
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border transition-all duration-150 text-xs ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 shadow-sm'
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -16,22 +16,22 @@ export function WeatherAlertBanner({
|
||||
<div className={className || 'mb-6'}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full p-4 bg-gradient-to-r from-amber-50 to-orange-50 border-2 border-amber-200 hover:border-amber-400 rounded-xl transition-all duration-200 hover:shadow-md text-right flex items-center justify-between group"
|
||||
className="w-full p-4 bg-amber-50 border border-amber-200 hover:border-amber-300 rounded-lg transition-all duration-150 text-right flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
<div className="w-9 h-9 bg-amber-500 rounded-md flex items-center justify-center group-hover:scale-105 transition-transform">
|
||||
<AlertTriangle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">
|
||||
<p className="font-semibold text-gray-800 text-sm">
|
||||
شما {toPersianDigits(alertsCount)} هشدار آب و هوایی برای روزهای آینده دارید
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
برای مشاهده جزئیات و توصیههای مدیریت گلخانه کلیک کنید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronLeft className="w-5 h-5 text-amber-600 group-hover:translate-x-[-4px] transition-transform" />
|
||||
<ChevronLeft className="w-4 h-4 text-amber-600 group-hover:translate-x-[-4px] transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export function WeekdayHeaders({
|
||||
}: WeekdayHeadersProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'grid grid-cols-7 text-center bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200',
|
||||
'grid grid-cols-7 text-center bg-gray-50 border-b border-gray-200',
|
||||
className
|
||||
)}>
|
||||
{weekdays.map((day) => (
|
||||
|
||||
@@ -21,7 +21,7 @@ export function YearSelector({
|
||||
انتخاب سال:
|
||||
</label>
|
||||
<select
|
||||
className="px-4 py-2 border-2 border-gray-200 rounded-xl focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all bg-white font-medium"
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150 bg-white font-medium"
|
||||
value={selectedYear}
|
||||
onChange={(e) => onYearChange(Number(e.target.value))}
|
||||
>
|
||||
|
||||
@@ -21,12 +21,12 @@ export function CalendarDayCell({
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group min-h-[90px] md:min-h-[100px] bg-white border-2 border-green-200 hover:border-green-400 hover:bg-gradient-to-br hover:from-green-50 hover:to-emerald-50 transition-all cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center shadow-sm hover:shadow-md',
|
||||
'group min-h-[90px] md:min-h-[100px] bg-white border border-green-200 hover:border-green-400 transition-all duration-150 cursor-pointer rounded-lg p-2 md:p-3 flex flex-col items-center justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-base md:text-lg font-semibold text-gray-900 mb-1.5">{day}</div>
|
||||
<div className="flex items-center gap-1 bg-gradient-to-r from-green-500 to-green-600 text-white text-xs rounded-full px-2.5 py-1 font-medium">
|
||||
<div className="flex items-center gap-1 bg-green-600 text-white text-xs rounded-md px-2.5 py-1 font-medium">
|
||||
<Database className="w-3 h-3" />
|
||||
{recordCount}
|
||||
</div>
|
||||
|
||||
@@ -4,22 +4,55 @@ import { DeviceDto } from '@/lib/api'
|
||||
|
||||
type DeviceCardProps = {
|
||||
device: DeviceDto
|
||||
href: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DeviceCard({ device, href, className }: DeviceCardProps) {
|
||||
export function DeviceCard({ device, href, onClick, className }: DeviceCardProps) {
|
||||
if (onClick) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
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-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}
|
||||
</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-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 p-6 relative ${className || ''}`}
|
||||
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-14 h-14 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md group-hover:scale-110 transition-transform duration-300">
|
||||
<Settings className="w-7 h-7 text-white" />
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-green-600 flex items-center justify-center group-hover:scale-105 transition-transform duration-150">
|
||||
<Settings className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1 group-hover:text-green-600 transition-colors">
|
||||
{device.deviceName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
@@ -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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,32 +21,29 @@ export function MonthCard({
|
||||
onClick={onClick}
|
||||
disabled={!isActive}
|
||||
className={cn(
|
||||
'group relative rounded-xl border-2 p-5 text-center transition-all duration-300',
|
||||
'group relative rounded-lg border p-5 text-center transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-white border-green-200 hover:border-green-400 hover:shadow-lg hover:-translate-y-1'
|
||||
? 'bg-white border-green-200 hover:border-green-400'
|
||||
: 'bg-gray-50 border-gray-200 opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn('text-lg font-semibold mb-2', isActive ? 'text-gray-900' : 'text-gray-400')}>
|
||||
<div className={cn('text-base font-semibold mb-2', isActive ? 'text-gray-900' : 'text-gray-400')}>
|
||||
{name}
|
||||
</div>
|
||||
{isActive && stats ? (
|
||||
<div className="space-y-1">
|
||||
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-full px-3 py-1.5 font-medium">
|
||||
<div className="inline-flex items-center gap-1 bg-green-600 text-white text-xs rounded-md px-3 py-1.5 font-medium">
|
||||
<Database className="w-3 h-3" />
|
||||
{stats.days} روز
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-2">
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
{stats.records} رکورد
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">بدون داده</div>
|
||||
)}
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-green-500 to-green-600 rounded-b-xl transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
default: 'bg-white border-2 border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 text-gray-700 hover:text-indigo-600',
|
||||
primary: 'bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg',
|
||||
secondary: 'bg-white border-2 border-orange-300 hover:border-orange-500 hover:bg-orange-50 text-orange-700 hover:text-orange-800',
|
||||
default: 'bg-white border border-gray-300 hover:border-gray-400 hover:bg-gray-50 text-gray-700',
|
||||
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm',
|
||||
secondary: 'bg-white border border-orange-300 hover:border-orange-400 hover:bg-orange-50 text-orange-700',
|
||||
outline: 'border border-gray-300 hover:bg-gray-50 text-gray-700',
|
||||
ghost: 'hover:bg-gray-100 text-gray-700',
|
||||
}
|
||||
@@ -59,12 +59,11 @@ export function Button({
|
||||
|
||||
const buttonClasses = cn(
|
||||
'inline-flex items-center justify-center gap-1 sm:gap-2',
|
||||
'rounded-xl transition-all duration-200 font-medium',
|
||||
'rounded-lg transition-all duration-150 font-medium',
|
||||
'whitespace-nowrap',
|
||||
'shadow-sm hover:shadow-md active:shadow-sm',
|
||||
'active:scale-[0.98]', // Native-like press effect
|
||||
'touch-manipulation', // Better touch response
|
||||
'select-none', // Prevent text selection
|
||||
'active:scale-[0.98]',
|
||||
'touch-manipulation',
|
||||
'select-none',
|
||||
sizeStyle.padding,
|
||||
sizeStyle.text,
|
||||
variantStyle,
|
||||
|
||||
@@ -23,9 +23,9 @@ export function Card({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'bg-white rounded-2xl shadow-md border border-gray-100',
|
||||
'bg-white rounded-lg border border-gray-200',
|
||||
paddingClasses[padding],
|
||||
hover && 'hover:shadow-xl transition-all duration-300',
|
||||
hover && 'hover:border-gray-300 hover:shadow-sm transition-all duration-200',
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
|
||||
@@ -48,13 +48,13 @@ export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10">
|
||||
<div className="relative bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-gray-100 rounded-md transition-colors"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
|
||||
@@ -19,18 +19,18 @@ export function EmptyState({
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'bg-white rounded-2xl shadow-md border border-gray-100 p-8 md:p-12 text-center',
|
||||
'bg-white rounded-lg border border-gray-200 p-8 md:p-12 text-center',
|
||||
className
|
||||
)}>
|
||||
{Icon && (
|
||||
<div className="text-gray-300 mb-4">
|
||||
<Icon className="w-16 h-16 mx-auto" />
|
||||
<Icon className="w-14 h-14 mx-auto" />
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
)}
|
||||
<p className="text-gray-600 mb-6">{message}</p>
|
||||
<p className="text-sm text-gray-500 mb-6">{message}</p>
|
||||
{action && (
|
||||
<div className="flex justify-center">
|
||||
{action}
|
||||
|
||||
@@ -20,11 +20,11 @@ export function ErrorMessage({
|
||||
return (
|
||||
<div className={cn('min-h-screen flex items-center justify-center p-4', className)}>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100 text-center">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
|
||||
<div className="text-red-600 mb-4">
|
||||
<AlertCircle className="w-16 h-16 mx-auto" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">خطا</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">خطا</h2>
|
||||
<p className="text-gray-600 mb-6">{message}</p>
|
||||
{action}
|
||||
</div>
|
||||
@@ -35,7 +35,7 @@ export function ErrorMessage({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-4 bg-red-50 border border-red-200 rounded-xl',
|
||||
'p-4 bg-red-50 border border-red-200 rounded-lg',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center gap-2 text-red-700 mb-3">
|
||||
@@ -44,7 +44,7 @@ export function ErrorMessage({
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 p-1 hover:bg-red-100 rounded transition-colors"
|
||||
className="flex-shrink-0 p-1 hover:bg-red-100 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function IconButton({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg transition-colors',
|
||||
'rounded-md transition-colors',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
|
||||
@@ -49,7 +49,7 @@ export function Modal({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-2xl shadow-2xl w-full max-h-[90vh] overflow-y-auto',
|
||||
'bg-white rounded-lg shadow-lg w-full max-h-[90vh] overflow-y-auto',
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
@@ -57,18 +57,18 @@ export function Modal({
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-6 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(title ? 'p-6' : 'p-6', className)}>
|
||||
<div className={cn(title ? 'p-0' : 'p-0', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,17 +24,17 @@ export function PageHeader({
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br rounded-xl shadow-md',
|
||||
'inline-flex items-center justify-center w-10 h-10 rounded-lg',
|
||||
iconGradient
|
||||
)}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-gray-900">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -7,12 +7,12 @@ type SegmentTabProps<T extends string> = {
|
||||
|
||||
export function SegmentTab<T extends string>({ tabs, activeTab, className, setActiveTab }: SegmentTabProps<T>) {
|
||||
return (
|
||||
<div className={`bg-gray-100 rounded-xl p-1 flex ${className}`}>
|
||||
<div className={`bg-gray-100 rounded-lg p-1 flex ${className}`}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-lg transition-all duration-200 ${
|
||||
className={`flex-1 px-2 py-2.5 text-xs font-medium rounded-md transition-all duration-150 ${
|
||||
activeTab === tab.value
|
||||
? 'bg-white text-indigo-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SuccessMessage({
|
||||
}: SuccessMessageProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 flex items-center gap-2',
|
||||
'p-4 bg-green-50 border border-green-200 rounded-lg text-green-700 flex items-center gap-2',
|
||||
className
|
||||
)}>
|
||||
<CheckCircle2 className="w-5 h-5 flex-shrink-0" />
|
||||
@@ -22,7 +22,7 @@ export function SuccessMessage({
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 p-1 hover:bg-green-100 rounded transition-colors"
|
||||
className="flex-shrink-0 p-1 hover:bg-green-100 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Tabs<T extends string>({
|
||||
className
|
||||
}: TabsProps<T>) {
|
||||
return (
|
||||
<div className={`bg-white rounded-2xl shadow-md border border-gray-200 overflow-hidden ${className || ''}`}>
|
||||
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className || ''}`}>
|
||||
{/* Segmented Control for Mobile */}
|
||||
<div className="p-3 md:p-6 md:pb-0">
|
||||
<SegmentTab
|
||||
@@ -38,10 +38,10 @@ export function Tabs<T extends string>({
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
||||
className={`flex-1 px-6 py-4 text-sm font-medium transition-all duration-150 whitespace-nowrap ${
|
||||
activeTab === tab.value
|
||||
? 'text-indigo-600 border-b-2 border-indigo-600 bg-indigo-50/50'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
? 'text-indigo-600 border-b-2 border-indigo-600'
|
||||
: 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
142
src/components/daily-report/LiveSummaryCards.tsx
Normal file
142
src/components/daily-report/LiveSummaryCards.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { Thermometer, Droplets, Sprout, Wind, Sun, Activity } from 'lucide-react'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import { paramConfig } from '@/features/daily-report/utils'
|
||||
|
||||
type LiveSummaryCardsProps = {
|
||||
temperature: number
|
||||
humidity: number
|
||||
soil: number
|
||||
gas: number
|
||||
lux: number
|
||||
lastUpdated?: string
|
||||
onCardClick?: (param: string) => void
|
||||
}
|
||||
|
||||
const cardMeta = {
|
||||
temperature: {
|
||||
icon: Thermometer,
|
||||
color: 'text-rose-600',
|
||||
bgColor: 'bg-rose-50',
|
||||
},
|
||||
humidity: {
|
||||
icon: Droplets,
|
||||
color: 'text-sky-600',
|
||||
bgColor: 'bg-sky-50',
|
||||
},
|
||||
soil: {
|
||||
icon: Sprout,
|
||||
color: 'text-emerald-600',
|
||||
bgColor: 'bg-emerald-50',
|
||||
},
|
||||
gas: {
|
||||
icon: Wind,
|
||||
color: 'text-amber-600',
|
||||
bgColor: 'bg-amber-50',
|
||||
},
|
||||
lux: {
|
||||
icon: Sun,
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-50',
|
||||
},
|
||||
} as const
|
||||
|
||||
type ParamKey = keyof typeof cardMeta
|
||||
|
||||
export function LiveSummaryCards({
|
||||
temperature,
|
||||
humidity,
|
||||
soil,
|
||||
gas,
|
||||
lux,
|
||||
lastUpdated,
|
||||
onCardClick,
|
||||
}: LiveSummaryCardsProps) {
|
||||
const values: Record<ParamKey, number> = {
|
||||
temperature,
|
||||
humidity,
|
||||
soil,
|
||||
gas,
|
||||
lux,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-md bg-indigo-100 flex items-center justify-center">
|
||||
<Activity className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">خلاصه لحظهای</h2>
|
||||
</div>
|
||||
{lastUpdated && (
|
||||
<span className="text-[11px] text-gray-400 bg-gray-50 px-2.5 py-1 rounded-md border border-gray-200">
|
||||
آخرین بهروزرسانی: {toPersianDigits(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
|
||||
{(Object.keys(cardMeta) as ParamKey[]).map((key) => {
|
||||
const meta = cardMeta[key]
|
||||
const config = paramConfig[key]
|
||||
const Icon = meta.icon
|
||||
const value = values[key]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onCardClick?.(key)}
|
||||
className={`
|
||||
bg-white border border-gray-200
|
||||
rounded-lg p-3.5
|
||||
flex flex-col items-center gap-2
|
||||
transition-all duration-150
|
||||
hover:border-gray-300 hover:shadow-sm
|
||||
active:scale-[0.98]
|
||||
group
|
||||
text-right
|
||||
w-full
|
||||
`}
|
||||
style={{ minHeight: 'auto', minWidth: 0 }}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className={`
|
||||
w-9 h-9 rounded-lg
|
||||
${meta.bgColor}
|
||||
flex items-center justify-center
|
||||
group-hover:scale-105
|
||||
transition-transform duration-150
|
||||
`}>
|
||||
<Icon className={`w-4.5 h-4.5 ${meta.color}`} />
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-gray-900"
|
||||
style={{ color: config?.chartColor }}>
|
||||
{toPersianDigits(
|
||||
key === 'temperature'
|
||||
? value.toFixed(1)
|
||||
: Math.round(value).toString()
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">
|
||||
{config?.unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-[10px] font-medium text-gray-500">
|
||||
{config?.title}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
src/components/daily-report/PowerOutageCard.tsx
Normal file
171
src/components/daily-report/PowerOutageCard.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Zap, Clock, PowerOff, Power, AlertTriangle } from 'lucide-react'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
import type { PowerOutageSummaryDto } from '@/lib/api'
|
||||
|
||||
type PowerOutageCardProps = {
|
||||
summary: PowerOutageSummaryDto | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds === null) return 'ادامه دارد'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${toPersianDigits(String(hours))} ساعت و ${toPersianDigits(String(minutes))} دقیقه`
|
||||
}
|
||||
return `${toPersianDigits(String(minutes))} دقیقه`
|
||||
}
|
||||
|
||||
function formatTime(utcString: string): string {
|
||||
const date = new Date(utcString)
|
||||
// Convert to Iran time (UTC+3:30)
|
||||
const iranOffset = 3.5 * 60 * 60 * 1000
|
||||
const iranTime = new Date(date.getTime() + iranOffset)
|
||||
const hours = String(iranTime.getUTCHours()).padStart(2, '0')
|
||||
const minutes = String(iranTime.getUTCMinutes()).padStart(2, '0')
|
||||
return `${toPersianDigits(hours)}:${toPersianDigits(minutes)}`
|
||||
}
|
||||
|
||||
export function PowerOutageCard({ summary, loading }: PowerOutageCardProps) {
|
||||
const totalDowntimeFormatted = useMemo(() => {
|
||||
if (!summary) return '—'
|
||||
return formatDuration(summary.totalDowntimeSeconds)
|
||||
}, [summary])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-md bg-amber-200/50 animate-pulse" />
|
||||
<div className="h-4 w-24 bg-amber-200/50 rounded animate-pulse" />
|
||||
</div>
|
||||
{/* Stats skeleton */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 h-14 bg-white/50 rounded-lg animate-pulse" />
|
||||
<div className="flex-1 h-14 bg-white/50 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary || summary.totalOutageCount === 0) {
|
||||
return (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-md bg-emerald-100 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-emerald-600" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">وضعیت برق</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 bg-white rounded-lg px-3.5 py-3 border border-emerald-100">
|
||||
<div className="w-7 h-7 rounded-md bg-emerald-100 flex items-center justify-center">
|
||||
<Power className="w-3.5 h-3.5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-emerald-700">برق وصل است</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">هیچ قطعی برقی در این روز ثبت نشده</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-md bg-amber-100 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">قطعی برق</h3>
|
||||
{summary.totalOutageCount > 0 && (
|
||||
<span className="mr-auto bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
{toPersianDigits(String(summary.totalOutageCount))} قطعی
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex-1 bg-white rounded-lg px-3 py-2.5 border border-amber-100">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Clock className="w-3.5 h-3.5 text-amber-600" />
|
||||
<span className="text-[10px] text-gray-500">مدت قطعی کل</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-800">
|
||||
{totalDowntimeFormatted}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-lg px-3 py-2.5 border border-amber-100">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-600" />
|
||||
<span className="text-[10px] text-gray-500">تعداد قطعی</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-800">
|
||||
{toPersianDigits(String(summary.totalOutageCount))} بار
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outage List */}
|
||||
{summary.outages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium text-gray-500 mr-1">زمانبندی قطعیها</p>
|
||||
<div className="space-y-1.5">
|
||||
{summary.outages.map((outage, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-lg px-3 py-2.5 border border-amber-100 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center ${
|
||||
outage.connectedAt
|
||||
? 'bg-red-100'
|
||||
: 'bg-red-200'
|
||||
}`}>
|
||||
<PowerOff className={`w-3.5 h-3.5 ${
|
||||
outage.connectedAt
|
||||
? 'text-red-500'
|
||||
: 'text-red-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-800">
|
||||
از ساعت {formatTime(outage.disconnectedAt)}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
{outage.connectedAt
|
||||
? `تا ساعت ${formatTime(outage.connectedAt)}`
|
||||
: 'هنوز وصل نشده'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className={`text-[11px] font-bold ${
|
||||
outage.durationSeconds !== null && outage.durationSeconds > 3600
|
||||
? 'text-red-600'
|
||||
: 'text-amber-700'
|
||||
}`}>
|
||||
{formatDuration(outage.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,12 +37,12 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${config.bgColor} rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${onClick ? 'cursor-pointer' : ''}`}
|
||||
className={`${config.bgColor} rounded-lg border border-gray-200 hover:border-gray-300 transition-all duration-150 overflow-hidden ${onClick ? 'cursor-pointer hover:shadow-sm' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<h3 className="text-sm font-bold text-gray-800">{config.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-800">{config.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Gauge on left, Stats on right */}
|
||||
@@ -55,7 +55,7 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
|
||||
|
||||
{/* Stats on right - stacked */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 bg-white rounded-md px-2 py-1.5 border border-gray-100">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-500 leading-tight">حداکثر</span>
|
||||
@@ -64,7 +64,7 @@ export function SummaryCard({ param, currentValue, minValue, maxValue, data, onC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 bg-white/60 rounded-md px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 bg-white rounded-md px-2 py-1.5 border border-gray-100">
|
||||
<TrendingDown className="w-3.5 h-3.5 text-red-600 flex-shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-gray-500 leading-tight">حداقل</span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { SummaryCard } from './SummaryCard'
|
||||
import { PowerOutageCard } from './PowerOutageCard'
|
||||
import { WeatherData } from '@/features/weather'
|
||||
import { GreenhouseForecastAlerts, getForecastAlertsCount } from './GreenhouseForecastAlerts'
|
||||
import { Dialog } from '@/components/common/Dialog'
|
||||
import { WeatherAlertBanner } from '@/components/alerts'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { PowerOutageSummaryDto } from '@/lib/api'
|
||||
|
||||
type SummaryTabProps = {
|
||||
temperature: number[]
|
||||
@@ -16,9 +18,11 @@ type SummaryTabProps = {
|
||||
forecastWeatherLoading?: boolean
|
||||
onCardClick?: (param: string) => void
|
||||
visibleParams?: string[] | null
|
||||
powerOutageSummary?: PowerOutageSummaryDto | null
|
||||
powerOutageLoading?: boolean
|
||||
}
|
||||
|
||||
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick, visibleParams }: SummaryTabProps) {
|
||||
export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeather, forecastWeatherLoading = false, onCardClick, visibleParams, powerOutageSummary = null, powerOutageLoading = false }: SummaryTabProps) {
|
||||
const [isAlertsDialogOpen, setIsAlertsDialogOpen] = useState(false)
|
||||
|
||||
const alertsCount = useMemo(() => {
|
||||
@@ -75,16 +79,16 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
{/* Greenhouse Forecast Alerts Section */}
|
||||
{forecastWeatherLoading ? (
|
||||
<div className="mb-6">
|
||||
<div className="w-full p-4 bg-gradient-to-r from-amber-50 to-orange-50 border-2 border-amber-200 rounded-xl text-right flex items-center justify-between">
|
||||
<div className="w-full p-4 bg-amber-50 border border-amber-200 rounded-lg text-right flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-white animate-spin" />
|
||||
<div className="w-9 h-9 bg-amber-100 rounded-md flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 text-amber-600 animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">
|
||||
در حال بارگذاری هشدارهای آب و هوایی...
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
لطفاً صبر کنید
|
||||
</p>
|
||||
</div>
|
||||
@@ -99,7 +103,7 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
) : null}
|
||||
|
||||
{/* Summary Cards Grid */}
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
{(!visibleParams || visibleParams.length === 0 || visibleParams.includes('temperature')) && (
|
||||
<SummaryCard
|
||||
param="temperature"
|
||||
@@ -156,6 +160,14 @@ export function SummaryTab({ temperature, humidity, soil, gas, lux, forecastWeat
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Power Outage Card */}
|
||||
<div className="mt-6">
|
||||
<PowerOutageCard
|
||||
summary={powerOutageSummary}
|
||||
loading={powerOutageLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alerts Dialog */}
|
||||
{forecastWeather && (
|
||||
<Dialog
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Daily report UI components only
|
||||
export { SummaryCard } from './SummaryCard'
|
||||
export { SummaryTab } from './SummaryTab'
|
||||
export { LiveSummaryCards } from './LiveSummaryCards'
|
||||
export { TimeRangeSelector } from './TimeRangeSelector'
|
||||
export { ChartsTab } from './ChartsTab'
|
||||
export { WeatherTab } from './WeatherTab'
|
||||
export { AnalysisTab } from './AnalysisTab'
|
||||
export { GreenhouseForecastAlerts } from './GreenhouseForecastAlerts'
|
||||
export { PowerOutageCard } from './PowerOutageCard'
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Past Date Header */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-indigo-500 p-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="bg-blue-600 p-5">
|
||||
<div className="text-center text-white">
|
||||
<p className="text-lg opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
|
||||
<p className="text-2xl font-bold">{selectedDate}</p>
|
||||
<p className="text-sm opacity-90 mb-1">📅 وضعیت آب و هوای روز</p>
|
||||
<p className="text-xl font-bold">{selectedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Grid */}
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 grid grid-cols-2 gap-3">
|
||||
<TemperatureCard weatherData={weatherData} isToday={false} />
|
||||
<PrecipitationHistoricalCard weatherData={weatherData} />
|
||||
<SunlightCard weatherData={weatherData} />
|
||||
@@ -29,4 +29,3 @@ export function HistoricalWeather({ weatherData, selectedDate }: HistoricalWeath
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-xl border-r-4 ${
|
||||
className={`p-4 rounded-lg border-r-4 ${
|
||||
alert.type === 'danger' ? 'bg-red-50 border-red-500' :
|
||||
alert.type === 'warning' ? 'bg-amber-50 border-amber-500' :
|
||||
alert.type === 'info' ? 'bg-blue-50 border-blue-500' :
|
||||
@@ -56,30 +56,30 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
)}
|
||||
|
||||
{/* Current Weather Header */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="bg-emerald-600 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-white">
|
||||
<p className="text-lg opacity-90 mb-1">🌡️ الان</p>
|
||||
<p className="text-sm opacity-90 mb-1">🌡️ الان</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-6xl font-bold">
|
||||
<span className="text-5xl font-bold">
|
||||
{toPersianDigits(Math.round(weatherData.current.temperature))}
|
||||
</span>
|
||||
<span className="text-3xl">درجه</span>
|
||||
<span className="text-2xl">درجه</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-white">
|
||||
{(() => {
|
||||
const IconComponent = getWeatherInfo(weatherData.current.weatherCode).icon
|
||||
return <IconComponent className="w-20 h-20" />
|
||||
return <IconComponent className="w-16 h-16" />
|
||||
})()}
|
||||
<p className="text-lg mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
|
||||
<p className="text-sm mt-1">{getWeatherInfo(weatherData.current.weatherCode).description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Grid */}
|
||||
<div className="p-4 grid grid-cols-2 gap-4">
|
||||
<div className="p-4 grid grid-cols-2 gap-3">
|
||||
<TemperatureCard weatherData={weatherData} isToday={true} />
|
||||
<PrecipitationForecastCard weatherData={weatherData} />
|
||||
<SunlightCard weatherData={weatherData} />
|
||||
@@ -88,9 +88,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
</div>
|
||||
|
||||
{/* Hourly Forecast */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-gray-100 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-500 p-4">
|
||||
<h4 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="bg-indigo-600 p-4">
|
||||
<h4 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
🕐 وضعیت ساعت به ساعت امروز
|
||||
</h4>
|
||||
</div>
|
||||
@@ -108,9 +108,9 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
return (
|
||||
<div
|
||||
key={hour.time}
|
||||
className={`flex flex-col items-center p-3 rounded-xl min-w-[85px] transition-all ${
|
||||
className={`flex flex-col items-center p-3 rounded-lg min-w-[80px] transition-all ${
|
||||
isNow
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-500 text-white shadow-lg scale-105'
|
||||
? 'bg-emerald-500 text-white shadow-sm scale-105'
|
||||
: isHot
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: isCold
|
||||
@@ -122,8 +122,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
{isNow ? '⏰ الان' : `${toPersianDigits(hourNum)}:۰۰`}
|
||||
</p>
|
||||
|
||||
<div className={`my-2 p-2 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
||||
<IconComponent className={`w-6 h-6 ${
|
||||
<div className={`my-2 p-1.5 rounded-full ${isNow ? 'bg-white/20' : 'bg-white'}`}>
|
||||
<IconComponent className={`w-5 h-5 ${
|
||||
isNow ? 'text-white' :
|
||||
isHot ? 'text-red-500' :
|
||||
isCold ? 'text-blue-500' :
|
||||
@@ -131,7 +131,7 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<p className={`text-2xl font-bold ${
|
||||
<p className={`text-xl font-bold ${
|
||||
isNow ? 'text-white' :
|
||||
isHot ? 'text-red-600' :
|
||||
isCold ? 'text-blue-600' :
|
||||
@@ -160,8 +160,8 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
|
||||
{/* 7-Day Forecast */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-emerald-500" />
|
||||
<h3 className="text-base font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<CalendarIcon className="w-4 h-4 text-emerald-500" />
|
||||
پیشبینی ۷ روز آینده
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
@@ -174,10 +174,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
const isExpanded = expandedDayIndex === index
|
||||
|
||||
return (
|
||||
<div key={day.date} className="overflow-hidden rounded-xl">
|
||||
<div key={day.date} className="overflow-hidden rounded-lg border border-gray-200">
|
||||
<button
|
||||
onClick={() => onDayToggle(isExpanded ? null : index)}
|
||||
className={`w-full flex items-center justify-between p-4 transition-all duration-200 ${
|
||||
className={`w-full flex items-center justify-between p-4 transition-all duration-150 ${
|
||||
isExpanded
|
||||
? 'bg-emerald-500 text-white'
|
||||
: isToday
|
||||
@@ -190,10 +190,10 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
isExpanded ? 'bg-white/20' : 'bg-white'
|
||||
}`}>
|
||||
<IconComponent className={`w-6 h-6 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
|
||||
<IconComponent className={`w-5 h-5 ${isExpanded ? 'text-white' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div className="text-right flex-1">
|
||||
<p className={`font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||
@@ -206,53 +206,53 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||
<p className={`text-xl font-bold ${isExpanded ? 'text-white' : 'text-gray-800'}`}>
|
||||
{toPersianDigits(Math.round(day.tempMax))}°
|
||||
</p>
|
||||
<p className={`text-xs ${isExpanded ? 'text-white/60' : 'text-gray-400'}`}>
|
||||
{toPersianDigits(Math.round(day.tempMin))}°
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-5 h-5 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180 text-white' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-white p-4 border-t border-emerald-100">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-600 font-medium">دما</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
|
||||
<p className="text-sm text-gray-500">حداکثر</p>
|
||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
|
||||
<p className="text-sm text-gray-500">حداقل</p>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.tempMax))}°</p>
|
||||
<p className="text-xs text-gray-500">حداکثر</p>
|
||||
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(Math.round(day.tempMin))}°</p>
|
||||
<p className="text-xs text-gray-500">حداقل</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-600 font-medium">بارش</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
|
||||
<p className="text-sm text-gray-500">احتمال</p>
|
||||
<p className="text-xl font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
|
||||
<p className="text-sm text-gray-500">میلیمتر</p>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(day.precipitationProbability)}%</p>
|
||||
<p className="text-xs text-gray-500">احتمال</p>
|
||||
<p className="text-lg font-bold text-blue-600 mt-2">{toPersianDigits(day.precipitation.toFixed(1))}</p>
|
||||
<p className="text-xs text-gray-500">میلیمتر</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-600 font-medium">ساعات آفتابی</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
|
||||
<p className="text-sm text-gray-500">ساعت</p>
|
||||
<p className="text-xl font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
|
||||
<p className="text-sm text-gray-500">UV Index</p>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.sunshineDuration / 3600))}</p>
|
||||
<p className="text-xs text-gray-500">ساعت</p>
|
||||
<p className="text-lg font-bold text-orange-600 mt-2">{toPersianDigits(Math.round(day.uvIndexMax))}</p>
|
||||
<p className="text-xs text-gray-500">UV Index</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-600 font-medium">باد</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
|
||||
<p className="text-sm text-gray-500">کیلومتر/ساعت</p>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(day.windSpeedMax))}</p>
|
||||
<p className="text-xs text-gray-500">کیلومتر/ساعت</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,4 +265,3 @@ export function TodayWeather({ weatherData, expandedDayIndex, onDayToggle }: Tod
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,22 +18,22 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
|
||||
const isHot = tempMax > 35
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl p-4 ${
|
||||
isCold ? 'bg-blue-100 border-2 border-blue-300' :
|
||||
isHot ? 'bg-red-100 border-2 border-red-300' :
|
||||
'bg-green-100 border-2 border-green-300'
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
isCold ? 'bg-blue-50 border-blue-200' :
|
||||
isHot ? 'bg-red-50 border-red-200' :
|
||||
'bg-green-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
isCold ? 'bg-blue-500' :
|
||||
isHot ? 'bg-red-500' :
|
||||
'bg-green-500'
|
||||
}`}>
|
||||
<Thermometer className="w-8 h-8 text-white" />
|
||||
<Thermometer className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
|
||||
<p className={`text-sm ${
|
||||
<p className="text-sm font-bold text-gray-800">{isToday ? 'دمای امروز' : 'دمای روز'}</p>
|
||||
<p className={`text-xs ${
|
||||
isCold ? 'text-blue-600' :
|
||||
isHot ? 'text-red-600' :
|
||||
'text-green-600'
|
||||
@@ -47,12 +47,12 @@ export function TemperatureCard({ weatherData, isToday }: WeatherCardsProps) {
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">🌙 شب</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMin))}°</p>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMin))}°</p>
|
||||
</div>
|
||||
<div className="text-3xl text-gray-300">←</div>
|
||||
<div className="text-2xl text-gray-300">←</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">☀️ روز</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMax))}°</p>
|
||||
<p className="text-xl font-bold text-gray-800">{toPersianDigits(Math.round(tempMax))}°</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,25 +69,25 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
|
||||
const isMedium = probability > 30
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl p-4 ${
|
||||
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
|
||||
isMedium ? 'bg-sky-50 border-2 border-sky-200' :
|
||||
'bg-amber-50 border-2 border-amber-200'
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
isHigh ? 'bg-blue-50 border-blue-200' :
|
||||
isMedium ? 'bg-sky-50 border-sky-200' :
|
||||
'bg-amber-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
isHigh ? 'bg-blue-500' :
|
||||
isMedium ? 'bg-sky-400' :
|
||||
'bg-amber-400'
|
||||
}`}>
|
||||
{isMedium ?
|
||||
<CloudRain className="w-8 h-8 text-white" /> :
|
||||
<Sun className="w-8 h-8 text-white" />
|
||||
<CloudRain className="w-5 h-5 text-white" /> :
|
||||
<Sun className="w-5 h-5 text-white" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-sm ${
|
||||
<p className="text-sm font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-xs ${
|
||||
isHigh ? 'text-blue-600' :
|
||||
isMedium ? 'text-sky-600' :
|
||||
'text-amber-600'
|
||||
@@ -99,8 +99,8 @@ export function PrecipitationForecastCard({ weatherData }: { weatherData: Weathe
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
|
||||
<p className="text-sm text-gray-500">احتمال بارش</p>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(probability)}%</p>
|
||||
<p className="text-xs text-gray-500">احتمال بارش</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -116,25 +116,25 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
|
||||
const hasPrecipitation = precipitation > 0
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl p-4 ${
|
||||
isHigh ? 'bg-blue-100 border-2 border-blue-300' :
|
||||
hasPrecipitation ? 'bg-sky-50 border-2 border-sky-200' :
|
||||
'bg-amber-50 border-2 border-amber-200'
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
isHigh ? 'bg-blue-50 border-blue-200' :
|
||||
hasPrecipitation ? 'bg-sky-50 border-sky-200' :
|
||||
'bg-amber-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
isHigh ? 'bg-blue-500' :
|
||||
hasPrecipitation ? 'bg-sky-400' :
|
||||
'bg-amber-400'
|
||||
}`}>
|
||||
{hasPrecipitation ?
|
||||
<CloudRain className="w-8 h-8 text-white" /> :
|
||||
<Sun className="w-8 h-8 text-white" />
|
||||
<CloudRain className="w-5 h-5 text-white" /> :
|
||||
<Sun className="w-5 h-5 text-white" />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-sm ${
|
||||
<p className="text-sm font-bold text-gray-800">بارش</p>
|
||||
<p className={`text-xs ${
|
||||
isHigh ? 'text-blue-600' :
|
||||
hasPrecipitation ? 'text-sky-600' :
|
||||
'text-amber-600'
|
||||
@@ -146,8 +146,8 @@ export function PrecipitationHistoricalCard({ weatherData }: { weatherData: Weat
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
|
||||
<p className="text-sm text-gray-500">میلیمتر بارش</p>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(precipitation.toFixed(1))}</p>
|
||||
<p className="text-xs text-gray-500">میلیمتر بارش</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -161,14 +161,14 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
|
||||
const uvIndex = weatherData.daily[0]?.uvIndexMax || 0
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-2xl p-4">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-14 h-14 rounded-xl bg-yellow-400 flex items-center justify-center">
|
||||
<Sun className="w-8 h-8 text-white" />
|
||||
<div className="w-10 h-10 rounded-lg bg-yellow-400 flex items-center justify-center">
|
||||
<Sun className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">نور آفتاب</p>
|
||||
<p className="text-sm text-yellow-600">
|
||||
<p className="text-sm font-bold text-gray-800">نور آفتاب</p>
|
||||
<p className="text-xs text-yellow-600">
|
||||
{sunshineHours > 8 ? '☀️ آفتاب زیاد' :
|
||||
sunshineHours > 4 ? '🌤️ آفتاب متوسط' :
|
||||
'☁️ کمآفتاب'}
|
||||
@@ -177,11 +177,11 @@ export function SunlightCard({ weatherData }: { weatherData: WeatherData }) {
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
|
||||
<p className="text-sm text-gray-500">ساعت آفتاب</p>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(sunshineHours))}</p>
|
||||
<p className="text-xs text-gray-500">ساعت آفتاب</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-2xl font-bold text-orange-500">{toPersianDigits(Math.round(uvIndex))}</p>
|
||||
<p className="text-xl font-bold text-orange-500">{toPersianDigits(Math.round(uvIndex))}</p>
|
||||
<p className="text-xs text-gray-500">شاخص UV</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,14 +197,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
|
||||
const humidity = weatherData.current.humidity
|
||||
|
||||
return (
|
||||
<div className="bg-cyan-50 border-2 border-cyan-200 rounded-2xl p-4">
|
||||
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-14 h-14 rounded-xl bg-cyan-400 flex items-center justify-center">
|
||||
<Wind className="w-8 h-8 text-white" />
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-400 flex items-center justify-center">
|
||||
<Wind className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-800">باد و رطوبت</p>
|
||||
<p className="text-sm text-cyan-600">
|
||||
<p className="text-sm font-bold text-gray-800">باد و رطوبت</p>
|
||||
<p className="text-xs text-cyan-600">
|
||||
{windSpeed > 40 ? '💨 باد شدید!' :
|
||||
windSpeed > 20 ? '🍃 وزش باد' :
|
||||
'😌 آرام'}
|
||||
@@ -213,15 +213,14 @@ export function WindHumidityCard({ weatherData }: { weatherData: WeatherData })
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-gray-800">{toPersianDigits(Math.round(windSpeed))}</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{toPersianDigits(Math.round(windSpeed))}</p>
|
||||
<p className="text-xs text-gray-500">کیلومتر/ساعت باد</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-3xl font-bold text-blue-500">{toPersianDigits(humidity)}%</p>
|
||||
<p className="text-2xl font-bold text-blue-500">{toPersianDigits(humidity)}%</p>
|
||||
<p className="text-xs text-gray-500">رطوبت هوا</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export function CodeInput({
|
||||
onChange={(e) => handleCodeChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onPaste={index === 0 ? handlePaste : undefined}
|
||||
className="w-14 h-14 text-center text-2xl font-bold rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
|
||||
className="w-14 h-14 text-center text-2xl font-bold rounded-lg border border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150"
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function FormInput({
|
||||
<input
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border-2 transition-all duration-200',
|
||||
'w-full px-4 py-3 rounded-lg border transition-all duration-150',
|
||||
error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20 bg-red-50'
|
||||
: 'border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20',
|
||||
|
||||
@@ -48,7 +48,7 @@ export function MobileInput({
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200 text-left',
|
||||
'w-full px-4 py-3 rounded-lg border border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150 text-left',
|
||||
className
|
||||
)}
|
||||
placeholder="09123456789"
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SearchInput({
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pr-12 pl-4 py-3 rounded-xl border-2 border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-200"
|
||||
className="w-full pr-12 pl-4 py-3 rounded-lg border border-gray-200 bg-gray-50 focus:border-green-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-green-500/20 transition-all duration-150"
|
||||
{...props}
|
||||
/>
|
||||
{showClearButton && value && (
|
||||
|
||||
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))}
|
||||
disabled={currentPage === 1}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
|
||||
'px-4 py-2 rounded-lg transition-all duration-150 flex items-center gap-2',
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -58,10 +58,10 @@ export function Pagination({
|
||||
key={pageNum}
|
||||
onClick={() => onPageChange(pageNum)}
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-xl transition-all duration-200',
|
||||
'w-10 h-10 rounded-lg transition-all duration-150',
|
||||
currentPage === pageNum
|
||||
? 'bg-gradient-to-r from-green-500 to-green-600 text-white shadow-md'
|
||||
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{pageNum}
|
||||
@@ -73,10 +73,10 @@ export function Pagination({
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-xl transition-all duration-200 flex items-center gap-2',
|
||||
'px-4 py-2 rounded-lg transition-all duration-150 flex items-center gap-2',
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white border-2 border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
: 'bg-white border border-gray-200 hover:border-green-500 hover:text-green-600 text-gray-700'
|
||||
)}
|
||||
>
|
||||
بعدی
|
||||
|
||||
@@ -21,8 +21,7 @@ export function SettingsSection({
|
||||
<div className={cn('space-y-5', className)}>
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-200">
|
||||
<div className={cn(
|
||||
'w-10 h-10 bg-gradient-to-br rounded-lg flex items-center justify-center',
|
||||
iconGradient
|
||||
'w-9 h-9 rounded-md flex items-center justify-center bg-gray-600',
|
||||
)}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
@@ -30,9 +30,9 @@ export function ResendButton({
|
||||
onClick={onResend}
|
||||
disabled={!canResend || loading}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium transition-all duration-200',
|
||||
'w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium transition-all duration-150',
|
||||
canResend && !loading
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md hover:shadow-lg'
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -13,10 +13,13 @@ import type {
|
||||
AlertConditionDto,
|
||||
CreateAlertConditionDto,
|
||||
UpdateAlertConditionDto,
|
||||
PowerOutageSummaryDto,
|
||||
} from './types'
|
||||
|
||||
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> {
|
||||
const res = await fetch(url, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } })
|
||||
if (!res.ok) {
|
||||
@@ -33,7 +36,14 @@ async function http<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
error.status = res.status
|
||||
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 = {
|
||||
@@ -65,7 +75,12 @@ export const api = {
|
||||
updateDeviceSettings: (dto: DeviceSettingsDto) => http<void>(`${API_BASE}/api/devicesettings`, { method: 'PUT', body: JSON.stringify(dto) }),
|
||||
|
||||
// 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) }),
|
||||
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) =>
|
||||
http<void>(`${API_BASE}/api/alertconditions`, { method: 'PUT', body: JSON.stringify(dto) }),
|
||||
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[]
|
||||
}
|
||||
|
||||
// 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