Compare commits
	
		
			1 Commits
		
	
	
		
			master
			...
			features/s
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| f1836c4247 | 
							
								
								
									
										11
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								.env.example
									
									
									
									
									
								
							@ -1,2 +1,11 @@
 | 
				
			|||||||
NEXT_PUBLIC_FASTAPI_URL="http://localhost:8000"
 | 
					NEXT_PUBLIC_API_BASE_URL=http://localhost:3005
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DATABASE_URL="postgresql://postgres:root@localhost:5432/rooftop?schema=public"
 | 
				
			||||||
 | 
					JWT_SECRET="secret_key"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#SUNGROW
 | 
				
			||||||
 | 
					SUNGROW_SECRET_KEY=
 | 
				
			||||||
 | 
					SUNGROW_APP_KEY=
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#CHINT
 | 
				
			||||||
 | 
					NEXT_PUBLIC_CHINT_TOKEN=
 | 
				
			||||||
 | 
				
			|||||||
@ -1,60 +0,0 @@
 | 
				
			|||||||
name: Build and Deploy
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
on:
 | 
					 | 
				
			||||||
    push:
 | 
					 | 
				
			||||||
        tags:
 | 
					 | 
				
			||||||
            - "v*"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
jobs:
 | 
					 | 
				
			||||||
    build-and-deploy:
 | 
					 | 
				
			||||||
        runs-on: ubuntu-latest
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        steps:
 | 
					 | 
				
			||||||
            - name: Checkout Code
 | 
					 | 
				
			||||||
              uses: actions/checkout@v3
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            - name: Extract Tag Name
 | 
					 | 
				
			||||||
              id: extract_tag
 | 
					 | 
				
			||||||
              run: |
 | 
					 | 
				
			||||||
                  TAG_NAME=${GITHUB_REF#refs/tags/}
 | 
					 | 
				
			||||||
                  echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            - name: Set up Docker Buildx
 | 
					 | 
				
			||||||
              uses: docker/setup-buildx-action@v3
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            - name: Login to Docker Hub
 | 
					 | 
				
			||||||
              uses: docker/login-action@v3
 | 
					 | 
				
			||||||
              with:
 | 
					 | 
				
			||||||
                  username: ${{ secrets.DOCKERHUB_USERNAME }}
 | 
					 | 
				
			||||||
                  password: ${{ secrets.DOCKERHUB_PASSWORD }}
 | 
					 | 
				
			||||||
                 
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            - name: Build and Push Docker Image
 | 
					 | 
				
			||||||
              uses: docker/build-push-action@v5
 | 
					 | 
				
			||||||
              with:
 | 
					 | 
				
			||||||
                  context: .
 | 
					 | 
				
			||||||
                  file: ./Dockerfile
 | 
					 | 
				
			||||||
                  push: true
 | 
					 | 
				
			||||||
                  tags: |
 | 
					 | 
				
			||||||
                      rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }}
 | 
					 | 
				
			||||||
                  build-args: |
 | 
					 | 
				
			||||||
                      NEXT_PUBLIC_FASTAPI_URL=${{ secrets.NEXT_PUBLIC_FASTAPI_URL }}
 | 
					 | 
				
			||||||
                      NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID }}        
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            - name: SSH and Deploy
 | 
					 | 
				
			||||||
              uses: appleboy/ssh-action@master
 | 
					 | 
				
			||||||
              with:
 | 
					 | 
				
			||||||
                  host: ${{ secrets.SSH_HOST }}
 | 
					 | 
				
			||||||
                  username: ${{ secrets.SSH_USER }}
 | 
					 | 
				
			||||||
                  key: ${{ secrets.SSH_PRIVATE_KEY }}
 | 
					 | 
				
			||||||
                  script: |
 | 
					 | 
				
			||||||
                      echo "Using tag: ${{ steps.extract_tag.outputs.tag }}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                      cd /root/power-meter-dashboard
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                      sed -i "s|rooftopenergy/powermeter-frontend:.*|rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }}|g" docker-compose.yml
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                      docker compose down
 | 
					 | 
				
			||||||
                      docker compose pull
 | 
					 | 
				
			||||||
                      docker compose up -d
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ -27,8 +27,8 @@ jobs:
 | 
				
			|||||||
      - name: Build
 | 
					      - name: Build
 | 
				
			||||||
        run: npm run build
 | 
					        run: npm run build
 | 
				
			||||||
        env:
 | 
					        env:
 | 
				
			||||||
          NEXT_PUBLIC_URL: 'http://localhost:3005'
 | 
					          NEXT_PUBLIC_URL: 'http://localhost:3000'
 | 
				
			||||||
          NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3005'
 | 
					          NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3001'
 | 
				
			||||||
          DATABASE_URL: 'postgresql://dummy:dummy@localhost:5432/dummy'
 | 
					          DATABASE_URL: 'postgresql://dummy:dummy@localhost:5432/dummy'
 | 
				
			||||||
          SMTP_EMAIL: 'dummy@example.com'
 | 
					          SMTP_EMAIL: 'dummy@example.com'
 | 
				
			||||||
          SMTP_EMAIL_PASSWORD: 'dummy'
 | 
					          SMTP_EMAIL_PASSWORD: 'dummy'
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										56
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										56
									
								
								Dockerfile
									
									
									
									
									
								
							@ -1,56 +0,0 @@
 | 
				
			|||||||
# syntax=docker/dockerfile:1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# ---- Build stage ----
 | 
					 | 
				
			||||||
ARG NODE_VERSION=20
 | 
					 | 
				
			||||||
FROM node:${NODE_VERSION}-slim AS builder
 | 
					 | 
				
			||||||
WORKDIR /app
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
ENV NEXT_TELEMETRY_DISABLED=1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Build-time public env hook
 | 
					 | 
				
			||||||
ARG NEXT_PUBLIC_FASTAPI_URL\
 | 
					 | 
				
			||||||
    NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID
 | 
					 | 
				
			||||||
ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL}\
 | 
					 | 
				
			||||||
    NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# 1) Install deps with caching
 | 
					 | 
				
			||||||
COPY package.json package-lock.json* ./
 | 
					 | 
				
			||||||
RUN npm ci
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# 2) Copy source
 | 
					 | 
				
			||||||
COPY . .
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# 3) Generate Prisma client (safe no-op if unused)
 | 
					 | 
				
			||||||
RUN npx prisma generate || true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# 4) Build Next.js (requires next.config.js => output: 'standalone')
 | 
					 | 
				
			||||||
RUN npm run build
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# ---- Runtime stage ----
 | 
					 | 
				
			||||||
FROM node:${NODE_VERSION}-slim AS runner
 | 
					 | 
				
			||||||
WORKDIR /app
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
ENV NODE_ENV=production \
 | 
					 | 
				
			||||||
    NEXT_TELEMETRY_DISABLED=1 \
 | 
					 | 
				
			||||||
    HOSTNAME=0.0.0.0 \
 | 
					 | 
				
			||||||
    PORT=3005
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Minimal server & assets
 | 
					 | 
				
			||||||
COPY --from=builder /app/.next/standalone ./
 | 
					 | 
				
			||||||
COPY --from=builder /app/.next/static ./.next/static
 | 
					 | 
				
			||||||
COPY --from=builder /app/public ./public
 | 
					 | 
				
			||||||
COPY --from=builder /app/prisma ./prisma
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Optional (only if Prisma engine errors show up):
 | 
					 | 
				
			||||||
# COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
 | 
					 | 
				
			||||||
# COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
USER node
 | 
					 | 
				
			||||||
EXPOSE 3005
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# (Optional) healthcheck
 | 
					 | 
				
			||||||
# HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
 | 
					 | 
				
			||||||
#   CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.PORT||3005)+'/_next/static/webpack/')"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
CMD ["node", "server.js"]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ -14,7 +14,6 @@ import KpiBottom from '@/components/dashboards/kpibottom';
 | 
				
			|||||||
import { formatAddress } from '@/app/utils/formatAddress';
 | 
					import { formatAddress } from '@/app/utils/formatAddress';
 | 
				
			||||||
import { formatCrmTimestamp } from '@/app/utils/datetime';
 | 
					import { formatCrmTimestamp } from '@/app/utils/datetime';
 | 
				
			||||||
import LoggingControlCard from '@/components/dashboards/LoggingControl';
 | 
					import LoggingControlCard from '@/components/dashboards/LoggingControl';
 | 
				
			||||||
import { buildExportUrl, getFilenameFromCD } from "@/utils/export";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
 | 
					const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
 | 
				
			||||||
const MonthlyBarChart  = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false });
 | 
					const MonthlyBarChart  = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false });
 | 
				
			||||||
@ -41,7 +40,7 @@ type CrmProject = {
 | 
				
			|||||||
  custom_mobile_phone_no?: string | null;
 | 
					  custom_mobile_phone_no?: string | null;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Adjust this to your FastAPI route
 | 
					// Adjust this to your FastAPI route
 | 
				
			||||||
const START_LOGGING_ENDPOINT = (siteId: string) =>
 | 
					const START_LOGGING_ENDPOINT = (siteId: string) =>
 | 
				
			||||||
@ -60,7 +59,6 @@ const AdminDashboard = () => {
 | 
				
			|||||||
  const router = useRouter();
 | 
					  const router = useRouter();
 | 
				
			||||||
  const pathname = usePathname();
 | 
					  const pathname = usePathname();
 | 
				
			||||||
  const searchParams = useSearchParams();
 | 
					  const searchParams = useSearchParams();
 | 
				
			||||||
  const [authChecked, setAuthChecked] = useState(false);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // --- load CRM projects dynamically ---
 | 
					  // --- load CRM projects dynamically ---
 | 
				
			||||||
  const [sites, setSites] = useState<CrmProject[]>([]);
 | 
					  const [sites, setSites] = useState<CrmProject[]>([]);
 | 
				
			||||||
@ -69,42 +67,6 @@ const AdminDashboard = () => {
 | 
				
			|||||||
  // near other refs
 | 
					  // near other refs
 | 
				
			||||||
  const loggingRef = useRef<HTMLDivElement | null>(null);
 | 
					  const loggingRef = useRef<HTMLDivElement | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
useEffect(() => {
 | 
					 | 
				
			||||||
  let cancelled = false;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const checkAuth = async () => {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const res = await fetch(`${API}/auth/me`, {
 | 
					 | 
				
			||||||
        credentials: 'include',
 | 
					 | 
				
			||||||
        cache: 'no-store',
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!res.ok) {
 | 
					 | 
				
			||||||
        router.replace('/login');
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const user = await res.json().catch(() => null);
 | 
					 | 
				
			||||||
      if (!user?.id) {
 | 
					 | 
				
			||||||
        router.replace('/login');
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      // authenticated
 | 
					 | 
				
			||||||
    } catch {
 | 
					 | 
				
			||||||
      router.replace('/login');
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      if (!cancelled) setAuthChecked(true);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  checkAuth();
 | 
					 | 
				
			||||||
  return () => { cancelled = true; };
 | 
					 | 
				
			||||||
}, [router, API]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    setSitesLoading(true);
 | 
					    setSitesLoading(true);
 | 
				
			||||||
@ -326,7 +288,7 @@ useEffect(() => {
 | 
				
			|||||||
            setHasTodayData(true);   // and today has data too
 | 
					            setHasTodayData(true);   // and today has data too
 | 
				
			||||||
            break;
 | 
					            break;
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
  } catch {
 | 
					        } catch {
 | 
				
			||||||
          // ignore and keep polling
 | 
					          // ignore and keep polling
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        await new Promise(r => setTimeout(r, 3000));
 | 
					        await new Promise(r => setTimeout(r, 3000));
 | 
				
			||||||
@ -337,100 +299,7 @@ useEffect(() => {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // helpers
 | 
					 | 
				
			||||||
// helpers
 | 
					 | 
				
			||||||
const ymd = (d: Date) => d.toISOString().slice(0, 10);
 | 
					 | 
				
			||||||
const excelUrl = (site: string, device: string, fn: 'grid' | 'solar', dateYMD: string) =>
 | 
					 | 
				
			||||||
  `${API}/excel-fs/${encodeURIComponent(site)}/${encodeURIComponent(device)}/${fn}/${dateYMD}.xlsx`;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// popup state
 | 
					 | 
				
			||||||
const [isDownloadOpen, setIsDownloadOpen] = useState(false);
 | 
					 | 
				
			||||||
const [meter, setMeter] = useState('01');                         // ADW300 device id
 | 
					 | 
				
			||||||
const [fn, setFn] = useState<'grid' | 'solar'>('grid');           // which function
 | 
					 | 
				
			||||||
const [downloadDate, setDownloadDate] = useState(ymd(new Date())); // YYYY-MM-DD
 | 
					 | 
				
			||||||
const [downloading, setDownloading] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// action
 | 
					 | 
				
			||||||
// util: parse filename from Content-Disposition
 | 
					 | 
				
			||||||
function getFilenameFromCD(h: string | null): string | null {
 | 
					 | 
				
			||||||
  if (!h) return null;
 | 
					 | 
				
			||||||
  // filename*=UTF-8''name.ext  (RFC 5987)
 | 
					 | 
				
			||||||
  const star = /filename\*\s*=\s*([^']*)''([^;]+)/i.exec(h);
 | 
					 | 
				
			||||||
  if (star && star[2]) return decodeURIComponent(star[2]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // filename="name.ext" or filename=name.ext
 | 
					 | 
				
			||||||
  const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(h);
 | 
					 | 
				
			||||||
  if (plain && plain[2]) return plain[2];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return null;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const downloadExcel = async () => {
 | 
					 | 
				
			||||||
  if (!selectedProject) return;
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    setDownloading(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Prefer the simple day-based export
 | 
					 | 
				
			||||||
    const url = buildExportUrl({
 | 
					 | 
				
			||||||
      baseUrl: process.env.NEXT_PUBLIC_FASTAPI_URL,
 | 
					 | 
				
			||||||
      site: selectedProject.name,
 | 
					 | 
				
			||||||
      suffix: fn,
 | 
					 | 
				
			||||||
      serial: meter?.trim() || undefined,
 | 
					 | 
				
			||||||
      day: downloadDate, // "YYYY-MM-DD"
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const resp = await fetch(url, { credentials: "include" });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!resp.ok) {
 | 
					 | 
				
			||||||
      // server might return JSON error; try to surface it nicely
 | 
					 | 
				
			||||||
      const ctype = resp.headers.get("Content-Type") || "";
 | 
					 | 
				
			||||||
      let msg = `HTTP ${resp.status}`;
 | 
					 | 
				
			||||||
      if (ctype.includes("application/json")) {
 | 
					 | 
				
			||||||
        const j = await resp.json().catch(() => null);
 | 
					 | 
				
			||||||
        if (j?.detail) msg = String(j.detail);
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        const t = await resp.text().catch(() => "");
 | 
					 | 
				
			||||||
        if (t) msg = t;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      throw new Error(msg);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const blob = await resp.blob();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // 1) use server-provided filename if present
 | 
					 | 
				
			||||||
    const cd = resp.headers.get("Content-Disposition");
 | 
					 | 
				
			||||||
    let downloadName = getFilenameFromCD(cd);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // 2) client-side fallback (date-only as requested)
 | 
					 | 
				
			||||||
    if (!downloadName) {
 | 
					 | 
				
			||||||
      const serialPart = meter?.trim() ? meter.trim() : "ALL";
 | 
					 | 
				
			||||||
      downloadName = `${selectedProject.name}_${serialPart}_${fn}_${downloadDate}.xlsx`;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const a = document.createElement("a");
 | 
					 | 
				
			||||||
    const href = URL.createObjectURL(blob);
 | 
					 | 
				
			||||||
    a.href = href;
 | 
					 | 
				
			||||||
    a.download = downloadName;
 | 
					 | 
				
			||||||
    document.body.appendChild(a);
 | 
					 | 
				
			||||||
    a.click();
 | 
					 | 
				
			||||||
    a.remove();
 | 
					 | 
				
			||||||
    URL.revokeObjectURL(href);
 | 
					 | 
				
			||||||
    setIsDownloadOpen(false);
 | 
					 | 
				
			||||||
  } catch (e: any) {
 | 
					 | 
				
			||||||
    alert(`Download failed: ${e?.message ?? e}`);
 | 
					 | 
				
			||||||
  } finally {
 | 
					 | 
				
			||||||
    setDownloading(false);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // ---------- RENDER ----------
 | 
					  // ---------- RENDER ----------
 | 
				
			||||||
  if (!authChecked) {
 | 
					 | 
				
			||||||
    return <div>Checking authentication…</div>;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  
 | 
					 | 
				
			||||||
  if (sitesLoading) {
 | 
					  if (sitesLoading) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <DashboardLayout>
 | 
					      <DashboardLayout>
 | 
				
			||||||
@ -552,151 +421,10 @@ const downloadExcel = async () => {
 | 
				
			|||||||
              <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
 | 
					              <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
 | 
				
			||||||
                Export Chart Images to PDF
 | 
					                Export Chart Images to PDF
 | 
				
			||||||
              </button>
 | 
					              </button>
 | 
				
			||||||
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                onClick={() => setIsDownloadOpen(true)}
 | 
					 | 
				
			||||||
                className="text-sm lg:text-lg btn-primary"
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                Download Excel Log
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
          </>
 | 
					          </>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        {isDownloadOpen && (
 | 
					 | 
				
			||||||
  <div
 | 
					 | 
				
			||||||
    className="fixed inset-0 z-50 flex items-center justify-center"
 | 
					 | 
				
			||||||
    aria-modal="true"
 | 
					 | 
				
			||||||
    role="dialog"
 | 
					 | 
				
			||||||
    onKeyDown={(e) => e.key === 'Escape' && setIsDownloadOpen(false)}
 | 
					 | 
				
			||||||
  >
 | 
					 | 
				
			||||||
    {/* Backdrop */}
 | 
					 | 
				
			||||||
    <div
 | 
					 | 
				
			||||||
      className="absolute inset-0 bg-black/50"
 | 
					 | 
				
			||||||
      onClick={() => setIsDownloadOpen(false)}
 | 
					 | 
				
			||||||
    />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    {/* Modal */}
 | 
					 | 
				
			||||||
    <div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white dark:bg-rtgray-800 shadow-2xl">
 | 
					 | 
				
			||||||
      <div className="p-5 sm:p-6 border-b border-black/5 dark:border-white/10">
 | 
					 | 
				
			||||||
        <h3 className="text-lg font-semibold text-black/90 dark:text-white">
 | 
					 | 
				
			||||||
          Download Excel Log
 | 
					 | 
				
			||||||
        </h3>
 | 
					 | 
				
			||||||
        <p className="mt-1 text-sm text-black/60 dark:text-white/60">
 | 
					 | 
				
			||||||
          Choose device, function, and date to export the .xlsx generated by the logger.
 | 
					 | 
				
			||||||
        </p>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="p-5 sm:p-6 space-y-5">
 | 
					 | 
				
			||||||
        {/* Site (read-only preview) */}
 | 
					 | 
				
			||||||
        <div>
 | 
					 | 
				
			||||||
          <label className="block text-sm opacity-80 mb-1 dark:text-white">Site</label>
 | 
					 | 
				
			||||||
          <div className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm truncate dark:text-white">
 | 
					 | 
				
			||||||
            {selectedProject?.project_name || selectedProject?.name}
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {/* Device + Function */}
 | 
					 | 
				
			||||||
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
 | 
					 | 
				
			||||||
          <div>
 | 
					 | 
				
			||||||
            <label className="block text-sm opacity-80 mb-1 dark:text-white">Meter (Device)</label>
 | 
					 | 
				
			||||||
            <input
 | 
					 | 
				
			||||||
              value={meter}
 | 
					 | 
				
			||||||
              onChange={(e) => setMeter(e.target.value)}
 | 
					 | 
				
			||||||
              placeholder="01"
 | 
					 | 
				
			||||||
              className="input input-bordered w-full pl-2 rounded-lg"
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <p className="mt-1 text-xs opacity-70 dark:text-white">
 | 
					 | 
				
			||||||
              Matches topic: <code>ADW300/<site>/<b>{meter || '01'}</b>/…</code>
 | 
					 | 
				
			||||||
            </p>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          <div>
 | 
					 | 
				
			||||||
            <label className="block text-sm opacity-80 mb-1 dark:text-white">Function</label>
 | 
					 | 
				
			||||||
            <div className="flex rounded-xl overflow-hidden border border-black/10 dark:border-white/10">
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="button"
 | 
					 | 
				
			||||||
                onClick={() => setFn('grid')}
 | 
					 | 
				
			||||||
                className={`flex-1 px-3 py-2 text-sm ${
 | 
					 | 
				
			||||||
                  fn === 'grid'
 | 
					 | 
				
			||||||
                    ? 'bg-rtyellow-200 text-black'
 | 
					 | 
				
			||||||
                    : 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white'
 | 
					 | 
				
			||||||
                }`}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                Grid
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="button"
 | 
					 | 
				
			||||||
                onClick={() => setFn('solar')}
 | 
					 | 
				
			||||||
                className={`flex-1 px-3 py-2 text-sm ${
 | 
					 | 
				
			||||||
                  fn === 'solar'
 | 
					 | 
				
			||||||
                    ? 'bg-rtyellow-200 text-black'
 | 
					 | 
				
			||||||
                    : 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white'
 | 
					 | 
				
			||||||
                }`}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                Solar
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {/* Date + quick picks */}
 | 
					 | 
				
			||||||
        <div>
 | 
					 | 
				
			||||||
          <label className="block text-sm opacity-80 mb-1 dark:text-white">Date</label>
 | 
					 | 
				
			||||||
          <div className="flex items-center gap-3">
 | 
					 | 
				
			||||||
            <input
 | 
					 | 
				
			||||||
              type="date"
 | 
					 | 
				
			||||||
              value={downloadDate}
 | 
					 | 
				
			||||||
              onChange={(e) => setDownloadDate(e.target.value)}
 | 
					 | 
				
			||||||
              className="input input-bordered w-48 pl-2 rounded-lg"
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <div className="flex gap-2">
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="button"
 | 
					 | 
				
			||||||
                className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/10 dark:text-white"
 | 
					 | 
				
			||||||
                onClick={() => setDownloadDate(ymd(new Date()))}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                Today
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                type="button"
 | 
					 | 
				
			||||||
                className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg:white/10 dark:text-white"
 | 
					 | 
				
			||||||
                onClick={() => {
 | 
					 | 
				
			||||||
                  const d = new Date();
 | 
					 | 
				
			||||||
                  d.setDate(d.getDate() - 1);
 | 
					 | 
				
			||||||
                  setDownloadDate(ymd(d));
 | 
					 | 
				
			||||||
                }}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                Yesterday
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Footer */}
 | 
					 | 
				
			||||||
      <div className="p-5 sm:p-6 flex justify-end gap-3 border-t border-black/5 dark:border-white/10">
 | 
					 | 
				
			||||||
        <button
 | 
					 | 
				
			||||||
          type="button"
 | 
					 | 
				
			||||||
          className="btn btn-primary bg-red-500 hover:bg-red-600 border-transparent"
 | 
					 | 
				
			||||||
          onClick={() => setIsDownloadOpen(false)}
 | 
					 | 
				
			||||||
          disabled={downloading}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          Cancel
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
        <button
 | 
					 | 
				
			||||||
          type="button"
 | 
					 | 
				
			||||||
          className="btn btn-primary border-transparent"
 | 
					 | 
				
			||||||
          onClick={downloadExcel}
 | 
					 | 
				
			||||||
          disabled={downloading || !meter || !downloadDate}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          {downloading ? 'Preparing…' : 'Download'}
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
)}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </DashboardLayout>
 | 
					    </DashboardLayout>
 | 
				
			||||||
 | 
				
			|||||||
@ -16,7 +16,7 @@ type CrmProject = {
 | 
				
			|||||||
  custom_mobile_phone_no?: string | null;
 | 
					  custom_mobile_phone_no?: string | null;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SitesPage = () => {
 | 
					const SitesPage = () => {
 | 
				
			||||||
  const [projects, setProjects] = useState<CrmProject[]>([]);
 | 
					  const [projects, setProjects] = useState<CrmProject[]>([]);
 | 
				
			||||||
 | 
				
			|||||||
@ -1,115 +1,73 @@
 | 
				
			|||||||
// app/login/page.tsx
 | 
					 | 
				
			||||||
'use client';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import Link from 'next/link';
 | 
					import Link from 'next/link';
 | 
				
			||||||
import React, { useEffect, useState } from 'react';
 | 
					import { Metadata } from 'next';
 | 
				
			||||||
import { useRouter } from 'next/navigation';
 | 
					import React from 'react';
 | 
				
			||||||
import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form';
 | 
					import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function LoginPage() {
 | 
					type Props = {}
 | 
				
			||||||
  const router = useRouter();
 | 
					 | 
				
			||||||
  const [ready, setReady] = useState(false); // gate to avoid UI flash
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Use ONE client-exposed API env var everywhere
 | 
					const LoginPage = (props: Props) => {
 | 
				
			||||||
  const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					    return (
 | 
				
			||||||
 | 
					        <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
 | 
				
			||||||
  useEffect(() => {
 | 
					            {/* Background gradient layer */}
 | 
				
			||||||
    let cancelled = false;
 | 
					            <div className="absolute inset-0 -z-10">
 | 
				
			||||||
    const controller = new AbortController();
 | 
					                <img
 | 
				
			||||||
 | 
					                    src="/assets/images/auth/bg-gradient.png"
 | 
				
			||||||
    (async () => {
 | 
					                    alt="background gradient"
 | 
				
			||||||
      try {
 | 
					                    className="h-full w-full object-cover"
 | 
				
			||||||
        const res = await fetch(`${API}/auth/me`, {
 | 
					                />
 | 
				
			||||||
          credentials: 'include',
 | 
					                <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
 | 
				
			||||||
          cache: 'no-store',        // don't reuse a cached 401
 | 
					            </div>
 | 
				
			||||||
          signal: controller.signal,
 | 
					
 | 
				
			||||||
        });
 | 
					            {/* Background decorative objects */}
 | 
				
			||||||
 | 
					            <img
 | 
				
			||||||
        if (!res.ok) {
 | 
					                src="/assets/images/auth/coming-soon-object1.png"
 | 
				
			||||||
          if (!cancelled) setReady(true);
 | 
					                alt="left decor"
 | 
				
			||||||
          return;
 | 
					                className="absolute left-0 top-1/2 hidden h-full max-h-[893px] -translate-y-1/2 brightness-125 md:block"
 | 
				
			||||||
        }
 | 
					            />
 | 
				
			||||||
 | 
					            <img
 | 
				
			||||||
        const user = await res.json().catch(() => null);
 | 
					                src="/assets/images/auth/coming-soon-object3.png"
 | 
				
			||||||
        if (user?.id) {
 | 
					                alt="right decor"
 | 
				
			||||||
          // already logged in -> go straight to dashboard
 | 
					                className="absolute right-0 top-0 hidden h-[300px] brightness-125 md:block"
 | 
				
			||||||
          router.replace('/adminDashboard');
 | 
					            />
 | 
				
			||||||
          return;
 | 
					
 | 
				
			||||||
        }
 | 
					            {/* Centered card wrapper */}
 | 
				
			||||||
 | 
					            <div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16">
 | 
				
			||||||
        // not logged in -> show form
 | 
					                <div
 | 
				
			||||||
        if (!cancelled) setReady(true);
 | 
					                    className="relative w-full max-w-[870px] rounded-2xl p-1
 | 
				
			||||||
      } catch {
 | 
					                               bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)]
 | 
				
			||||||
        // network/error -> show form
 | 
					                               dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]"
 | 
				
			||||||
        if (!cancelled) setReady(true);
 | 
					                >
 | 
				
			||||||
      }
 | 
					                    {/* Inner card (glassmorphic effect) */}
 | 
				
			||||||
    })();
 | 
					                    <div className="relative z-10 rounded-2xl bg-white/10 px-8 py-16 backdrop-blur-lg dark:bg-white/10 lg:min-h-[600px]">
 | 
				
			||||||
 | 
					                        <div className="mx-auto w-full max-w-[440px] text-center">
 | 
				
			||||||
    return () => {
 | 
					                            {/* Header */}
 | 
				
			||||||
      cancelled = true;
 | 
					                            <h1 className="text-4xl font-extrabold uppercase tracking-wide text-yellow-400 mb-2">
 | 
				
			||||||
      controller.abort();
 | 
					                                Sign In
 | 
				
			||||||
    };
 | 
					                            </h1>
 | 
				
			||||||
  }, [router, API]);
 | 
					                            <p className="text-base font-medium text-gray-200 dark:text-gray-300 mb-8">
 | 
				
			||||||
 | 
					                                Enter your email and password to access your account.
 | 
				
			||||||
  if (!ready) return null; // or a spinner/skeleton
 | 
					                            </p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					                            {/* Login form */}
 | 
				
			||||||
    <div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
 | 
					                            <ComponentsAuthLoginForm />
 | 
				
			||||||
      {/* Background gradient layer */}
 | 
					
 | 
				
			||||||
      <div className="absolute inset-0 -z-10">
 | 
					                            {/* Footer link */}
 | 
				
			||||||
        <img
 | 
					                            <div className="mt-6 text-sm text-gray-200 dark:text-gray-300">
 | 
				
			||||||
          src="/assets/images/auth/bg-gradient.png"
 | 
					                                Don’t have an account?{" "}
 | 
				
			||||||
          alt="background gradient"
 | 
					                                <Link
 | 
				
			||||||
          className="h-full w-full object-cover"
 | 
					                                    href="/register"
 | 
				
			||||||
        />
 | 
					                                    className="text-yellow-400 font-semibold underline transition hover:text-white"
 | 
				
			||||||
        <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
 | 
					                                >
 | 
				
			||||||
      </div>
 | 
					                                    SIGN UP
 | 
				
			||||||
 | 
					                                </Link>
 | 
				
			||||||
      {/* Background decorative objects */}
 | 
					                            </div>
 | 
				
			||||||
      <img
 | 
					                        </div>
 | 
				
			||||||
        src="/assets/images/auth/coming-soon-object1.png"
 | 
					                    </div>
 | 
				
			||||||
        alt="left decor"
 | 
					                </div>
 | 
				
			||||||
        className="absolute left-0 top-1/2 hidden h-full max-h-[893px] -translate-y-1/2 brightness-125 md:block"
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
      <img
 | 
					 | 
				
			||||||
        src="/assets/images/auth/coming-soon-object3.png"
 | 
					 | 
				
			||||||
        alt="right decor"
 | 
					 | 
				
			||||||
        className="absolute right-0 top-0 hidden h-[300px] brightness-125 md:block"
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Centered card wrapper */}
 | 
					 | 
				
			||||||
      <div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16">
 | 
					 | 
				
			||||||
        <div
 | 
					 | 
				
			||||||
          className="relative w-full max-w-[870px] rounded-2xl p-1
 | 
					 | 
				
			||||||
                     bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)]
 | 
					 | 
				
			||||||
                     dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <div className="relative z-10 rounded-2xl bg-white/10 px-8 py-16 backdrop-blur-lg dark:bg-white/10 lg:min-h-[600px]">
 | 
					 | 
				
			||||||
            <div className="mx-auto w-full max-w-[440px] text-center">
 | 
					 | 
				
			||||||
              <h1 className="text-4xl font-extrabold uppercase tracking-wide text-yellow-400 mb-2">
 | 
					 | 
				
			||||||
                Sign In
 | 
					 | 
				
			||||||
              </h1>
 | 
					 | 
				
			||||||
              <p className="text-base font-medium text-gray-200 dark:text-gray-300 mb-8">
 | 
					 | 
				
			||||||
                Enter your email and password to access your account.
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              <ComponentsAuthLoginForm />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
              <div className="mt-6 text-sm text-gray-200 dark:text-gray-300">
 | 
					 | 
				
			||||||
                Don’t have an account?{' '}
 | 
					 | 
				
			||||||
                <Link
 | 
					 | 
				
			||||||
                  href="/register"
 | 
					 | 
				
			||||||
                  className="text-yellow-400 font-semibold underline transition hover:text-white"
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  SIGN UP
 | 
					 | 
				
			||||||
                </Link>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					    );
 | 
				
			||||||
    </div>
 | 
					};
 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default LoginPage
 | 
				
			||||||
 | 
				
			|||||||
@ -55,7 +55,7 @@ const RegisterPage = (props: Props) => {
 | 
				
			|||||||
                            <div className="mt-6 text-sm text-gray-200 dark:text-gray-300">
 | 
					                            <div className="mt-6 text-sm text-gray-200 dark:text-gray-300">
 | 
				
			||||||
                                Already have an account ?{" "}
 | 
					                                Already have an account ?{" "}
 | 
				
			||||||
                                <Link
 | 
					                                <Link
 | 
				
			||||||
                                    href="/login"
 | 
					                                    href="/register"
 | 
				
			||||||
                                    className="text-yellow-400 font-semibold underline transition hover:text-white"
 | 
					                                    className="text-yellow-400 font-semibold underline transition hover:text-white"
 | 
				
			||||||
                                >
 | 
					                                >
 | 
				
			||||||
                                    SIGN IN
 | 
					                                    SIGN IN
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										261
									
								
								app/(defaults)/chint/inverters/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										261
									
								
								app/(defaults)/chint/inverters/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,261 @@
 | 
				
			|||||||
 | 
					'use client';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import PanelCodeHighlight from '@/components/panel-code-highlight'
 | 
				
			||||||
 | 
					import React, { Fragment, useEffect, useState } from 'react';
 | 
				
			||||||
 | 
					import { Tab } from '@headlessui/react';
 | 
				
			||||||
 | 
					import IconHome from '@/components/icon/icon-home';
 | 
				
			||||||
 | 
					import IconUser from '@/components/icon/icon-user';
 | 
				
			||||||
 | 
					import IconPhone from '@/components/icon/icon-phone';
 | 
				
			||||||
 | 
					import { useRouter } from 'next/router';
 | 
				
			||||||
 | 
					import { useParams } from 'next/navigation';
 | 
				
			||||||
 | 
					import axios from 'axios';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const InverterViewPage = (props: Props) => {
 | 
				
			||||||
 | 
					    const [isMounted, setIsMounted] = useState(false)
 | 
				
			||||||
 | 
					    const [loading, setLoading] = useState(true)
 | 
				
			||||||
 | 
					    const params = useParams()
 | 
				
			||||||
 | 
					    const [inverter, setInverter] = useState<any>({})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    useEffect(() => {
 | 
				
			||||||
 | 
					        setIsMounted(true);
 | 
				
			||||||
 | 
					        fetchData()
 | 
				
			||||||
 | 
					    }, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const fetchData = async () => {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            if (!params || !params.id) {
 | 
				
			||||||
 | 
					                throw new Error("Invalid params or params.id is missing");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            const res = await axios.get(`https://api-a.fomware.com.cn/asset/v1/list?type=2&key=${params.id.toString()}`, {
 | 
				
			||||||
 | 
					                headers: {
 | 
				
			||||||
 | 
					                    "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            })
 | 
				
			||||||
 | 
					            console.log("res", res.data.data.devices[0])
 | 
				
			||||||
 | 
					            setInverter(res.data.data.devices[0])
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					            console.error("Error fetching data:", error);
 | 
				
			||||||
 | 
					        } finally {
 | 
				
			||||||
 | 
					            setLoading(false);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {loading ? <p>Loading...</p> : (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
 | 
					        <PanelCodeHighlight title={params?.id?.toString() || ""}>
 | 
				
			||||||
 | 
					            <div className="mb-5">
 | 
				
			||||||
 | 
					                {isMounted && (
 | 
				
			||||||
 | 
					                    <Tab.Group>
 | 
				
			||||||
 | 
					                        <Tab.List className="mt-3 flex flex-wrap border-b border-white-light dark:border-[#191e3a]">
 | 
				
			||||||
 | 
					                            <Tab as={Fragment}>
 | 
				
			||||||
 | 
					                                {({ selected }) => (
 | 
				
			||||||
 | 
					                                    <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
 | 
				
			||||||
 | 
					                                        Brief
 | 
				
			||||||
 | 
					                                    </button>
 | 
				
			||||||
 | 
					                                )}
 | 
				
			||||||
 | 
					                            </Tab>
 | 
				
			||||||
 | 
					                            <Tab as={Fragment}>
 | 
				
			||||||
 | 
					                                {({ selected }) => (
 | 
				
			||||||
 | 
					                                    <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
 | 
				
			||||||
 | 
					                                        Chart
 | 
				
			||||||
 | 
					                                    </button>
 | 
				
			||||||
 | 
					                                )}
 | 
				
			||||||
 | 
					                            </Tab>
 | 
				
			||||||
 | 
					                        </Tab.List>
 | 
				
			||||||
 | 
					                        <Tab.Panels>
 | 
				
			||||||
 | 
					                            <Tab.Panel>
 | 
				
			||||||
 | 
					                                <div className="active pt-5">
 | 
				
			||||||
 | 
					                                    <p className="mb-3 text-base font-semibold">Last Updated ( 2025-02-24 16:03:10 +0800 )</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                    <blockquote className="rounded-br-md rounded-tr-md border-l-2 !border-l-primary bg-white py-2 px-2 text-black dark:border-[#060818] dark:bg-[#060818]">
 | 
				
			||||||
 | 
					                                        <div className="flex items-start">
 | 
				
			||||||
 | 
					                                            <p className="m-0 font-semibold text-sm not-italic text-[#515365] dark:text-white-light">Basic Information</p>
 | 
				
			||||||
 | 
					                                        </div>
 | 
				
			||||||
 | 
					                                    </blockquote>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                    <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 text-gray-600 mt-3">
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Model: </span>{inverter.model}</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">SN: </span>{inverter.sn}</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Total Energy: </span>{inverter.eTotalWithUnit}</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Today Energy: </span>{inverter.eTodayWithUnit}</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Reactive Power: </span>{inverter.lastRTP["Reactive Power"].value} var</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Active Power: </span>{inverter.activePowerWithUnit}</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Inverter Mode: </span>{inverter.lastRTP["Inverter Mode"].value}</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Inner Temperature: </span>{inverter.lastRTP["Inner Temperature"].value} °C</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Create Time: </span>{inverter.createdAtStr}</p>
 | 
				
			||||||
 | 
					                                        <p><span className="font-semibold text-gray-400">Modules: </span>{inverter.moduleFw.map((item: {module:string, value:string}) => `${item.module}: ${item.value}`.trim()).join(", ")}</p>
 | 
				
			||||||
 | 
					                                    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                </div>
 | 
				
			||||||
 | 
					                            </Tab.Panel>
 | 
				
			||||||
 | 
					                            <Tab.Panel>Chart</Tab.Panel>
 | 
				
			||||||
 | 
					                        </Tab.Panels>
 | 
				
			||||||
 | 
					                    </Tab.Group>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </PanelCodeHighlight>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <div className="panel pt-1 mt-3">
 | 
				
			||||||
 | 
					            {isMounted && (
 | 
				
			||||||
 | 
					                <Tab.Group>
 | 
				
			||||||
 | 
					                    <Tab.List className="flex flex-wrap border-b border-white-light dark:border-[#191e3a]">
 | 
				
			||||||
 | 
					                        <Tab as={Fragment}>
 | 
				
			||||||
 | 
					                            {({ selected }) => (
 | 
				
			||||||
 | 
					                                <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
 | 
				
			||||||
 | 
					                                    INV-DC
 | 
				
			||||||
 | 
					                                </button>
 | 
				
			||||||
 | 
					                            )}
 | 
				
			||||||
 | 
					                        </Tab>
 | 
				
			||||||
 | 
					                        <Tab as={Fragment}>
 | 
				
			||||||
 | 
					                            {({ selected }) => (
 | 
				
			||||||
 | 
					                                <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
 | 
				
			||||||
 | 
					                                    INV-AC
 | 
				
			||||||
 | 
					                                </button>
 | 
				
			||||||
 | 
					                            )}
 | 
				
			||||||
 | 
					                        </Tab>
 | 
				
			||||||
 | 
					                        <Tab as={Fragment}>
 | 
				
			||||||
 | 
					                            {({ selected }) => (
 | 
				
			||||||
 | 
					                                <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
 | 
				
			||||||
 | 
					                                    Meter-AC
 | 
				
			||||||
 | 
					                                </button>
 | 
				
			||||||
 | 
					                            )}
 | 
				
			||||||
 | 
					                        </Tab>
 | 
				
			||||||
 | 
					                        <Tab as={Fragment}>
 | 
				
			||||||
 | 
					                            {({ selected }) => (
 | 
				
			||||||
 | 
					                                <button className={`${selected ? 'border-b !border-primary text-primary !outline-none' : ''} -mb-[1px] flex items-center border-transparent p-5 py-3 before:inline-block hover:border-b hover:!border-primary hover:text-primary`} >
 | 
				
			||||||
 | 
					                                    Meter-Load
 | 
				
			||||||
 | 
					                                </button>
 | 
				
			||||||
 | 
					                            )}
 | 
				
			||||||
 | 
					                        </Tab>
 | 
				
			||||||
 | 
					                    </Tab.List>
 | 
				
			||||||
 | 
					                    <Tab.Panels>
 | 
				
			||||||
 | 
					                        <Tab.Panel>
 | 
				
			||||||
 | 
					                            <div className="active pt-5">
 | 
				
			||||||
 | 
					                                <table className="w-full border-collapse">
 | 
				
			||||||
 | 
					                                    <thead>
 | 
				
			||||||
 | 
					                                        <tr className="bg-gray-200 text-gray-600 text-left">
 | 
				
			||||||
 | 
					                                            <th className="p-2"></th>
 | 
				
			||||||
 | 
					                                            <th className="p-2">Voltage(V)</th>
 | 
				
			||||||
 | 
					                                            <th className="p-2">Current(A)</th>
 | 
				
			||||||
 | 
					                                            <th className="p-2">Power(W)</th>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                    </thead>
 | 
				
			||||||
 | 
					                                    <tbody>
 | 
				
			||||||
 | 
					                                        <tr className="border-b text-gray-600">
 | 
				
			||||||
 | 
					                                            <td className="p-2">PV1/PV1</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV1 Voltage"] && inverter.lastRTP["PV1 Voltage"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV1 Current"] && inverter.lastRTP["PV1 Current"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["MPPT1 Power"] && inverter.lastRTP["MPPT1 Power"].value}</td>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                        <tr className="border-b text-gray-600">
 | 
				
			||||||
 | 
					                                            <td className="p-2">PV2/PV2</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV2 Voltage"] && inverter.lastRTP["PV2 Voltage"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV2 Current"] && inverter.lastRTP["PV2 Current"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["MPPT2 Power"] && inverter.lastRTP["MPPT2 Power"].value}</td>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                        <tr className="border-b text-gray-600">
 | 
				
			||||||
 | 
					                                            <td className="p-2">PV3/PV3</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV3 Voltage"] && inverter.lastRTP["PV3 Voltage"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV3 Current"] && inverter.lastRTP["PV3 Current"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["MPPT3 Power"] && inverter.lastRTP["MPPT3 Power"].value}</td>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                        <tr className="border-b text-gray-600">
 | 
				
			||||||
 | 
					                                            <td className="p-2">PV3/PV3</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV4 Voltage"] && inverter.lastRTP["PV4 Voltage"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["PV4 Current"] && inverter.lastRTP["PV4 Current"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["MPPT4 Power"] && inverter.lastRTP["MPPT4 Power"].value}</td>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                    </tbody>
 | 
				
			||||||
 | 
					                                </table>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
 | 
					                        </Tab.Panel>
 | 
				
			||||||
 | 
					                        <Tab.Panel>
 | 
				
			||||||
 | 
					                            <div className="pt-5">
 | 
				
			||||||
 | 
					                                <table className="w-full border-collapse">
 | 
				
			||||||
 | 
					                                    <thead>
 | 
				
			||||||
 | 
					                                        <tr className="bg-gray-200 text-gray-600 text-left">
 | 
				
			||||||
 | 
					                                            <th className="p-2"></th>
 | 
				
			||||||
 | 
					                                            <th className="p-2">Voltage(V)</th>
 | 
				
			||||||
 | 
					                                            <th className="p-2">Current(A)</th>
 | 
				
			||||||
 | 
					                                            <th className="p-2">Power(W)</th>
 | 
				
			||||||
 | 
					                                            <th className="p-2">Frequency(Hz)</th>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                    </thead>
 | 
				
			||||||
 | 
					                                    <tbody>
 | 
				
			||||||
 | 
					                                        <tr className="border-b text-gray-600">
 | 
				
			||||||
 | 
					                                            <td className="p-2">A</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L1 Voltage"] && inverter.lastRTP["Phase L1 Voltage"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L1 Current"] && inverter.lastRTP["Phase L1 Current"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L1 Power"] && inverter.lastRTP["Phase L1 Power"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L1 Frequency"] && inverter.lastRTP["Phase L1 Frequency"].value}</td>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                        <tr className="border-b text-gray-600">
 | 
				
			||||||
 | 
					                                            <td className="p-2">B</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L2 Voltage"] && inverter.lastRTP["Phase L2 Voltage"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L2 Current"] && inverter.lastRTP["Phase L2 Current"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L2 Power"] && inverter.lastRTP["Phase L2 Power"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L2 Frequency"] && inverter.lastRTP["Phase L2 Frequency"].value}</td>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                        <tr className="border-b text-gray-600">
 | 
				
			||||||
 | 
					                                            <td className="p-2">C</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L3 Voltage"] && inverter.lastRTP["Phase L3 Voltage"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L3 Current"] && inverter.lastRTP["Phase L3 Current"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L3 Power"] && inverter.lastRTP["Phase L3 Power"].value}</td>
 | 
				
			||||||
 | 
					                                            <td className="p-2">{inverter.lastRTP["Phase L3 Frequency"] && inverter.lastRTP["Phase L3 Frequency"].value}</td>
 | 
				
			||||||
 | 
					                                        </tr>
 | 
				
			||||||
 | 
					                                    </tbody>
 | 
				
			||||||
 | 
					                                </table>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
 | 
					                        </Tab.Panel>
 | 
				
			||||||
 | 
					                        <Tab.Panel>
 | 
				
			||||||
 | 
					                            <div className="pt-5">
 | 
				
			||||||
 | 
					                                <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-gray-600 mt-3">
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Today import Energy: </span>{inverter.lastRTP["Today import Energy"] && inverter.lastRTP["Today import Energy"].value} kWh</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L1-N phase voltage of grid: </span>{inverter.lastRTP["L1-N phase voltage of grid"] && inverter.lastRTP["L1-N phase voltage of grid"].value} V</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L2-N phase voltage of grid: </span>{inverter.lastRTP["L2-N phase voltage of grid"] && inverter.lastRTP["L2-N phase voltage of grid"].value} V</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L3-N phase voltage of grid: </span>{inverter.lastRTP["L3-N phase voltage of grid"] && inverter.lastRTP["L3-N phase voltage of grid"].value} V</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Today export Energy: </span>{inverter.lastRTP["Today export Energy"] && inverter.lastRTP["Today export Energy"].value} kWh</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L1 current of grid: </span>{inverter.lastRTP["L1 current of grid"] && inverter.lastRTP["L1 current of grid"].value} A</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L2 current of grid: </span>{inverter.lastRTP["L2 current of grid"] && inverter.lastRTP["L2 current of grid"].value} A</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L3 current of grid: </span>{inverter.lastRTP["L3 current of grid"] && inverter.lastRTP["L3 current of grid"].value} A</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Accumulated energy of positive: </span>{inverter.lastRTP["Accumulated energy of positive"] && inverter.lastRTP["Accumulated energy of positive"].value} kWh</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Phase L1 watt of grid: </span>{inverter.lastRTP["Phase L1 watt of grid"] && inverter.lastRTP["Phase L1 watt of grid"].value} KW</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Phase L2 watt of grid: </span>{inverter.lastRTP["Phase L2 watt of grid"] && inverter.lastRTP["Phase L2 watt of grid"].value} KW</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Phase L3 watt of grid: </span>{inverter.lastRTP["Phase L3 watt of grid"] && inverter.lastRTP["Phase L3 watt of grid"].value} KW</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Accumulated energy of negative: </span>{inverter.lastRTP["Accumulated energy of negative"] && inverter.lastRTP["Accumulated energy of negative"].value} kWh</p>
 | 
				
			||||||
 | 
					                                </div>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
 | 
					                        </Tab.Panel>
 | 
				
			||||||
 | 
					                        <Tab.Panel>
 | 
				
			||||||
 | 
					                            <div className="pt-5">
 | 
				
			||||||
 | 
					                                <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-gray-600 mt-3">
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Today load Energy: </span>{inverter.lastRTP["Today load Energy"] && inverter.lastRTP["Today load Energy"].value} kWh</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L1-N phase voltage of load: </span>{inverter.lastRTP["L1-N phase voltage of load"] && inverter.lastRTP["L1-N phase voltage of load"].value} V</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L2-N phase voltage of load: </span>{inverter.lastRTP["L2-N phase voltage of load"] && inverter.lastRTP["L2-N phase voltage of load"].value} V</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L3-N phase voltage of load: </span>{inverter.lastRTP["L3-N phase voltage of load"] && inverter.lastRTP["L3-N phase voltage of load"].value} V</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">Accumulated energy of load: </span>{inverter.lastRTP["Accumulated energy of load"] && inverter.lastRTP["Accumulated energy of load"].value} kWh</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L1 current of load: </span>{inverter.lastRTP["L1 current of load"] && inverter.lastRTP["L1 current of load"].value} A</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L2 current of load: </span>{inverter.lastRTP["L2 current of load"] && inverter.lastRTP["L2 current of load"].value} A</p>
 | 
				
			||||||
 | 
					                                    <p><span className="font-semibold text-gray-400">L3 current of load: </span>{inverter.lastRTP["L3 current of load"] && inverter.lastRTP["L3 current of load"].value} A</p>
 | 
				
			||||||
 | 
					                                </div>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
 | 
					                        </Tab.Panel>
 | 
				
			||||||
 | 
					                    </Tab.Panels>
 | 
				
			||||||
 | 
					                </Tab.Group>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default InverterViewPage
 | 
				
			||||||
							
								
								
									
										174
									
								
								app/(defaults)/chint/inverters/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								app/(defaults)/chint/inverters/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,174 @@
 | 
				
			|||||||
 | 
					"use client";
 | 
				
			||||||
 | 
					import IconTrashLines from '@/components/icon/icon-trash-lines';
 | 
				
			||||||
 | 
					import PanelCodeHighlight from '@/components/panel-code-highlight';
 | 
				
			||||||
 | 
					import ComponentsTablesSimple from '@/components/tables/components-tables-simple';
 | 
				
			||||||
 | 
					import { formatUnixTimestamp } from '@/utils/helpers';
 | 
				
			||||||
 | 
					import Tippy from '@tippyjs/react';
 | 
				
			||||||
 | 
					import axios from 'axios';
 | 
				
			||||||
 | 
					import React, { useEffect, useState } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// import ReactApexChart from 'react-apexcharts';
 | 
				
			||||||
 | 
					import dynamic from 'next/dynamic';
 | 
				
			||||||
 | 
					import Link from 'next/link';
 | 
				
			||||||
 | 
					const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SungrowInverters = (props: Props) => {
 | 
				
			||||||
 | 
					    const [inverters, setInverters] = useState<any[]>([])
 | 
				
			||||||
 | 
					    const [loading, setLoading] = useState(true)
 | 
				
			||||||
 | 
					    const [isMounted, setIsMounted] = useState(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    useEffect(() => {
 | 
				
			||||||
 | 
					        setIsMounted(true);
 | 
				
			||||||
 | 
					        const fetchData = async () => {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                const res = await axios.get("https://api-a.fomware.com.cn/asset/v1/list?type=2", {
 | 
				
			||||||
 | 
					                    headers: {
 | 
				
			||||||
 | 
					                        "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                console.log("res", res.data.data.devices)
 | 
				
			||||||
 | 
					                setInverters(res.data.data.devices)
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                console.error("Error fetching data:", error);
 | 
				
			||||||
 | 
					            } finally {
 | 
				
			||||||
 | 
					                setLoading(false);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        fetchData()
 | 
				
			||||||
 | 
					    }, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const chartConfigs: any = {
 | 
				
			||||||
 | 
					        options: {
 | 
				
			||||||
 | 
					            chart: {
 | 
				
			||||||
 | 
					                height: 58,
 | 
				
			||||||
 | 
					                type: 'line',
 | 
				
			||||||
 | 
					                fontFamily: 'Nunito, sans-serif',
 | 
				
			||||||
 | 
					                sparkline: {
 | 
				
			||||||
 | 
					                    enabled: true,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                dropShadow: {
 | 
				
			||||||
 | 
					                    enabled: true,
 | 
				
			||||||
 | 
					                    blur: 3,
 | 
				
			||||||
 | 
					                    color: '#009688',
 | 
				
			||||||
 | 
					                    opacity: 0.4,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            stroke: {
 | 
				
			||||||
 | 
					                curve: 'smooth',
 | 
				
			||||||
 | 
					                width: 2,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            colors: ['#009688'],
 | 
				
			||||||
 | 
					            grid: {
 | 
				
			||||||
 | 
					                padding: {
 | 
				
			||||||
 | 
					                    top: 5,
 | 
				
			||||||
 | 
					                    bottom: 5,
 | 
				
			||||||
 | 
					                    left: 5,
 | 
				
			||||||
 | 
					                    right: 5,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            tooltip: {
 | 
				
			||||||
 | 
					                x: {
 | 
				
			||||||
 | 
					                    show: false,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					                y: {
 | 
				
			||||||
 | 
					                    title: {
 | 
				
			||||||
 | 
					                        formatter: () => {
 | 
				
			||||||
 | 
					                            return '';
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // inverter status 0: initial, 1: standby, 2: fault, 3: running, 5: offline, 9: shutdown, 10: unknown
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					            {loading ? <p>Loading...</p> : (
 | 
				
			||||||
 | 
					            <PanelCodeHighlight title="Chint Inverters">
 | 
				
			||||||
 | 
					                <div className="table-responsive mb-5">
 | 
				
			||||||
 | 
					                    <table>
 | 
				
			||||||
 | 
					                        <thead>
 | 
				
			||||||
 | 
					                            <tr>
 | 
				
			||||||
 | 
					                                <th>Inverter Name</th>
 | 
				
			||||||
 | 
					                                <th>Site Name</th>
 | 
				
			||||||
 | 
					                                <th>Gateway SN</th>
 | 
				
			||||||
 | 
					                                <th>Inverter Status</th>
 | 
				
			||||||
 | 
					                                <th>Model</th>
 | 
				
			||||||
 | 
					                                <th>SN</th>
 | 
				
			||||||
 | 
					                                <th>Real Time Power</th>
 | 
				
			||||||
 | 
					                                <th>E-Today</th>
 | 
				
			||||||
 | 
					                                <th>WeekData</th>
 | 
				
			||||||
 | 
					                                <th>Created At</th>
 | 
				
			||||||
 | 
					                                <th>Updated At</th>
 | 
				
			||||||
 | 
					                            </tr>
 | 
				
			||||||
 | 
					                        </thead>
 | 
				
			||||||
 | 
					                        <tbody>
 | 
				
			||||||
 | 
					                            {inverters.map((data) => (
 | 
				
			||||||
 | 
					                                <tr key={data.id}>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div className="whitespace-nowrap"><Link href={`/chint/inverters/${data.name}`}>{data.name}</Link></div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div className="whitespace-nowrap">{data.siteName}</div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div>{data.gatewaySn}</div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div className={`whitespace-nowrap ${
 | 
				
			||||||
 | 
					                                            data.status === 0 ? "text-gray-500"    // Initial
 | 
				
			||||||
 | 
					                                            : data.status === 1 ? "text-blue-500"  // Standby
 | 
				
			||||||
 | 
					                                            : data.status === 2 ? "text-red-500"  // Fault
 | 
				
			||||||
 | 
					                                            : data.status === 3 ? "text-green-500" // Running
 | 
				
			||||||
 | 
					                                            : data.status === 5 ? "text-yellow-500" // Offline
 | 
				
			||||||
 | 
					                                            : data.status === 9 ? "text-purple-500" // Shutdown
 | 
				
			||||||
 | 
					                                            : "text-gray-400" // Unknown (default)
 | 
				
			||||||
 | 
					                                        }`}>
 | 
				
			||||||
 | 
					                                            {data.statusLabel}
 | 
				
			||||||
 | 
					                                        </div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div>{data.model}</div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div>{data.sn}</div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div>{data.activePowerWithUnit}</div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        <div>{data.eTodayWithUnit}</div>
 | 
				
			||||||
 | 
					                                    </td>
 | 
				
			||||||
 | 
					                                    <td>
 | 
				
			||||||
 | 
					                                        {isMounted && (
 | 
				
			||||||
 | 
					                                            <ReactApexChart
 | 
				
			||||||
 | 
					                                                series={[{ data: data.weekTrend.map((point: any) => point.y) }]}
 | 
				
			||||||
 | 
					                                                options={{
 | 
				
			||||||
 | 
					                                                    ...chartConfigs.options,
 | 
				
			||||||
 | 
					                                                    xaxis: { categories: data.weekTrend.map((point: any) => point.x) },
 | 
				
			||||||
 | 
					                                                }}
 | 
				
			||||||
 | 
					                                                type="line"
 | 
				
			||||||
 | 
					                                                height={58}
 | 
				
			||||||
 | 
					                                                width={'100%'}
 | 
				
			||||||
 | 
					                                            />
 | 
				
			||||||
 | 
					                                        )}                                    </td>
 | 
				
			||||||
 | 
					                                    <td>{formatUnixTimestamp(data.createdAt)}</td>
 | 
				
			||||||
 | 
					                                    <td>{formatUnixTimestamp(data.updatedAt)}</td>
 | 
				
			||||||
 | 
					                                </tr>
 | 
				
			||||||
 | 
					                            ))}
 | 
				
			||||||
 | 
					                        </tbody>
 | 
				
			||||||
 | 
					                    </table>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					            </PanelCodeHighlight>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default SungrowInverters
 | 
				
			||||||
							
								
								
									
										13
									
								
								app/(defaults)/chint/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/(defaults)/chint/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					import axios from 'axios';
 | 
				
			||||||
 | 
					import { Metadata } from 'next';
 | 
				
			||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const metadata: Metadata = {
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SungrowIndex = async () => {
 | 
				
			||||||
 | 
					    return <div>SungrowIndex</div>;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default SungrowIndex;
 | 
				
			||||||
							
								
								
									
										39
									
								
								app/(defaults)/chint/sites/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/(defaults)/chint/sites/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					"use client";
 | 
				
			||||||
 | 
					// app/(defaults)/sungrow/assets/page.tsx
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import ComponentsTablesSimple from "@/components/tables/components-tables-simple";
 | 
				
			||||||
 | 
					import axios from "axios";
 | 
				
			||||||
 | 
					import React, { useEffect, useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SungrowAssets =  () => {
 | 
				
			||||||
 | 
					    const [sites, setSites] = useState<any[]>([]);
 | 
				
			||||||
 | 
					    const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    useEffect(() => {
 | 
				
			||||||
 | 
					        const fetchData = async () => {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                const res = await axios.get("https://api-a.fomware.com.cn/site/v1/list", {
 | 
				
			||||||
 | 
					                    headers: {
 | 
				
			||||||
 | 
					                        "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					                console.log("res", res.data.data.siteInfos)
 | 
				
			||||||
 | 
					                setSites(res.data.data.siteInfos)
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                console.error("Error fetching data:", error);
 | 
				
			||||||
 | 
					            } finally {
 | 
				
			||||||
 | 
					                setLoading(false);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        fetchData()
 | 
				
			||||||
 | 
					    }, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					            {loading ? <p>Loading...</p> : <ComponentsTablesSimple tableData={sites} />}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default SungrowAssets;
 | 
				
			||||||
							
								
								
									
										105
									
								
								app/(defaults)/sungrow/plant/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								app/(defaults)/sungrow/plant/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,105 @@
 | 
				
			|||||||
 | 
					"use client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import IconTrashLines from '@/components/icon/icon-trash-lines';
 | 
				
			||||||
 | 
					import PanelCodeHighlight from '@/components/panel-code-highlight';
 | 
				
			||||||
 | 
					import ComponentsTablesSimple from '@/components/tables/components-tables-simple'
 | 
				
			||||||
 | 
					import { formatUnixTimestamp } from '@/utils/helpers';
 | 
				
			||||||
 | 
					import Tippy from '@tippyjs/react';
 | 
				
			||||||
 | 
					import axios from 'axios';
 | 
				
			||||||
 | 
					import React, { useEffect, useState } from "react"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const SungrowPlant = (props: Props) => {
 | 
				
			||||||
 | 
					    const [sites, setSites] = useState<any[]>([])
 | 
				
			||||||
 | 
					    const [loading, setLoading] = useState(true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    useEffect(() => {
 | 
				
			||||||
 | 
					        const fetchSites = async () => {
 | 
				
			||||||
 | 
					            try {
 | 
				
			||||||
 | 
					                const res = await fetch("/api/sungrow/site")
 | 
				
			||||||
 | 
					                const data = await res.json()
 | 
				
			||||||
 | 
					                console.log("data", data)
 | 
				
			||||||
 | 
					                setSites(data)
 | 
				
			||||||
 | 
					            } catch (error) {
 | 
				
			||||||
 | 
					                console.error("Error fetching inverters:", error)
 | 
				
			||||||
 | 
					            } finally {
 | 
				
			||||||
 | 
					                setLoading(false)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        fetchSites()
 | 
				
			||||||
 | 
					    }, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const statusLabels: Record<number, string> = {
 | 
				
			||||||
 | 
					        0: "Offline",
 | 
				
			||||||
 | 
					        1: "Normal",
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const plantTypeLabel: Record<number, string> = {
 | 
				
			||||||
 | 
					        3: "Commercial PV",
 | 
				
			||||||
 | 
					        4: "Residential PV",
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					        <div>
 | 
				
			||||||
 | 
					            {loading ? <p>Loading...</p> : (
 | 
				
			||||||
 | 
					                <PanelCodeHighlight title="Sungrow Sites">
 | 
				
			||||||
 | 
					                    <div className="table-responsive mb-5">
 | 
				
			||||||
 | 
					                        <table>
 | 
				
			||||||
 | 
					                            <thead>
 | 
				
			||||||
 | 
					                                <tr>
 | 
				
			||||||
 | 
					                                    <th>Site Name</th>
 | 
				
			||||||
 | 
					                                    <th>Status</th>
 | 
				
			||||||
 | 
					                                    <th>Plant Type</th>
 | 
				
			||||||
 | 
					                                    {/* <th>Installed Power</th>
 | 
				
			||||||
 | 
					                                    <th>Real-time Power</th>
 | 
				
			||||||
 | 
					                                    <th>Yield Today</th>
 | 
				
			||||||
 | 
					                                    <th>Monthly Yield</th>
 | 
				
			||||||
 | 
					                                    <th>Annual Yield</th>
 | 
				
			||||||
 | 
					                                    <th>Total Yield</th>
 | 
				
			||||||
 | 
					                                    <th>Equivalent Hours</th>
 | 
				
			||||||
 | 
					                                    <th>Remarks</th> */}
 | 
				
			||||||
 | 
					                                    <th className="text-center">Action</th>
 | 
				
			||||||
 | 
					                                </tr>
 | 
				
			||||||
 | 
					                            </thead>
 | 
				
			||||||
 | 
					                            <tbody>
 | 
				
			||||||
 | 
					                                {sites.map((data) => (
 | 
				
			||||||
 | 
					                                    <tr key={data.id}>
 | 
				
			||||||
 | 
					                                        <td>
 | 
				
			||||||
 | 
					                                            <div className="whitespace-nowrap">{data.ps_name}</div>
 | 
				
			||||||
 | 
					                                        </td>
 | 
				
			||||||
 | 
					                                        <td>
 | 
				
			||||||
 | 
					                                            <div className={`whitespace-nowrap ${ data.online_status !== 1 ? "text-danger" : "text-success" }`} >
 | 
				
			||||||
 | 
					                                                {statusLabels[data.online_status] || "-"}
 | 
				
			||||||
 | 
					                                            </div>
 | 
				
			||||||
 | 
					                                        </td>
 | 
				
			||||||
 | 
					                                        <td>{plantTypeLabel[data.ps_type] || "-"}</td>
 | 
				
			||||||
 | 
					                                        {/* <td></td>
 | 
				
			||||||
 | 
					                                        <td></td>
 | 
				
			||||||
 | 
					                                        <td></td>
 | 
				
			||||||
 | 
					                                        <td></td>
 | 
				
			||||||
 | 
					                                        <td></td>
 | 
				
			||||||
 | 
					                                        <td></td>
 | 
				
			||||||
 | 
					                                        <td></td>
 | 
				
			||||||
 | 
					                                        <td></td> */}
 | 
				
			||||||
 | 
					                                        <td className="text-center">
 | 
				
			||||||
 | 
					                                            <Tippy content="Delete">
 | 
				
			||||||
 | 
					                                                <button type="button">
 | 
				
			||||||
 | 
					                                                    <IconTrashLines className="m-auto" />
 | 
				
			||||||
 | 
					                                                </button>
 | 
				
			||||||
 | 
					                                            </Tippy>
 | 
				
			||||||
 | 
					                                        </td>
 | 
				
			||||||
 | 
					                                    </tr>
 | 
				
			||||||
 | 
					                                ))}
 | 
				
			||||||
 | 
					                            </tbody>
 | 
				
			||||||
 | 
					                        </table>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                    </PanelCodeHighlight>
 | 
				
			||||||
 | 
					            )}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default SungrowPlant
 | 
				
			||||||
							
								
								
									
										22
									
								
								app/api/sungrow/site/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/api/sungrow/site/route.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					import { NextResponse } from "next/server";
 | 
				
			||||||
 | 
					import axios from "axios";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function GET() {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					        const res = await axios.post("https://gateway.isolarcloud.com.hk/openapi/platform/queryPowerStationList", {
 | 
				
			||||||
 | 
					            "page": 1,
 | 
				
			||||||
 | 
					            "size": 10,
 | 
				
			||||||
 | 
					            "appkey": `${process.env.SUNGROW_APP_KEY}`
 | 
				
			||||||
 | 
					        } ,{
 | 
				
			||||||
 | 
					            headers: {
 | 
				
			||||||
 | 
					                "Authorization": `Bearer ${process.env.SUNGROW_ACCESS_TOKEN}`,
 | 
				
			||||||
 | 
					                "x-access-key": `${process.env.SUNGROW_SECRET_KEY}`
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        // console.log("res", res.data)
 | 
				
			||||||
 | 
					        return NextResponse.json(res.data.result_data.pageList)
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					        console.error("API fetch error:", error);
 | 
				
			||||||
 | 
					        return NextResponse.json({ error: "Failed to fetch inverters" }, { status: 500 });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,25 +1,30 @@
 | 
				
			|||||||
// app/layout.tsx
 | 
					'use client';
 | 
				
			||||||
import type { Metadata } from "next";
 | 
					import ProviderComponent from '@/components/layouts/provider-component';
 | 
				
			||||||
import ProviderComponent from "@/components/layouts/provider-component";
 | 
					import 'react-perfect-scrollbar/dist/css/styles.css';
 | 
				
			||||||
import "react-perfect-scrollbar/dist/css/styles.css";
 | 
					import '../styles/tailwind.css';
 | 
				
			||||||
import "../styles/tailwind.css";
 | 
					import { Metadata } from 'next';
 | 
				
			||||||
import { Nunito } from "next/font/google";
 | 
					import { Nunito } from 'next/font/google';
 | 
				
			||||||
import { Exo_2 } from "next/font/google";
 | 
					import { Exo_2 } from "next/font/google";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const exo2 = Exo_2({ subsets: ["latin"], variable: "--font-exo2", weight: ["200", "400"] });
 | 
					const exo2 = Exo_2({
 | 
				
			||||||
const nunito = Nunito({ weight: ["400","500","600","700","800"], subsets: ["latin"], display: "swap", variable: "--font-nunito" });
 | 
					  subsets: ["latin"],
 | 
				
			||||||
 | 
					  variable: "--font-exo2",
 | 
				
			||||||
 | 
					  weight: ["200", "400"],
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const metadata: Metadata = {
 | 
					const nunito = Nunito({
 | 
				
			||||||
  title: "Rooftop Meter",
 | 
					    weight: ['400', '500', '600', '700', '800'],
 | 
				
			||||||
  icons: { icon: "/favicon.png" }, // or "/favicon.ico"
 | 
					    subsets: ['latin'],
 | 
				
			||||||
};
 | 
					    display: 'swap',
 | 
				
			||||||
 | 
					    variable: '--font-nunito',
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
 | 
					export default function RootLayout({ children }: { children: React.ReactNode }) {
 | 
				
			||||||
  return (
 | 
					    return (
 | 
				
			||||||
    <html lang="en" className={`${exo2.variable} ${nunito.variable}`}>
 | 
					        <html lang="en">
 | 
				
			||||||
      <body>
 | 
					            <body className={exo2.variable}>
 | 
				
			||||||
        <ProviderComponent>{children}</ProviderComponent>
 | 
					                <ProviderComponent>{children}</ProviderComponent>
 | 
				
			||||||
      </body>
 | 
					            </body>
 | 
				
			||||||
    </html>
 | 
					        </html>
 | 
				
			||||||
  );
 | 
					    )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -9,12 +9,12 @@ export interface TimeSeriesResponse {
 | 
				
			|||||||
  generation: TimeSeriesEntry[];
 | 
					  generation: TimeSeriesEntry[];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const API_URL =
 | 
					const API_BASE_URL =
 | 
				
			||||||
  process.env.NEXT_PUBLIC_FASTAPI_URL ;
 | 
					  process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const crmapi = {
 | 
					export const crmapi = {
 | 
				
			||||||
  getProjects: async () => {
 | 
					  getProjects: async () => {
 | 
				
			||||||
    const res = await fetch(`${API_URL}/crm/projects`, {
 | 
					    const res = await fetch(`${API_BASE_URL}/crm/projects`, {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
 | 
					    if (!res.ok) throw new Error(`HTTP ${res.status}`);
 | 
				
			||||||
    return res.json();
 | 
					    return res.json();
 | 
				
			||||||
@ -28,7 +28,7 @@ export async function fetchPowerTimeseries(
 | 
				
			|||||||
): Promise<TimeSeriesResponse> { // <-- Change here
 | 
					): Promise<TimeSeriesResponse> { // <-- Change here
 | 
				
			||||||
  const params = new URLSearchParams({ site, start, end });
 | 
					  const params = new URLSearchParams({ site, start, end });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const res = await fetch(`${API_URL}/power-timeseries?${params.toString()}`);
 | 
					  const res = await fetch(`http://localhost:8000/power-timeseries?${params.toString()}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!res.ok) {
 | 
					  if (!res.ok) {
 | 
				
			||||||
    throw new Error(`Failed to fetch data: ${res.status}`);
 | 
					    throw new Error(`Failed to fetch data: ${res.status}`);
 | 
				
			||||||
@ -54,7 +54,7 @@ export async function fetchForecast(
 | 
				
			|||||||
    kwp: kwp.toString(),
 | 
					    kwp: kwp.toString(),
 | 
				
			||||||
  }).toString();
 | 
					  }).toString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const res = await fetch(`${API_URL}/forecast?${query}`);
 | 
					  const res = await fetch(`http://localhost:8000/forecast?${query}`);
 | 
				
			||||||
  if (!res.ok) throw new Error("Failed to fetch forecast");
 | 
					  if (!res.ok) throw new Error("Failed to fetch forecast");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return res.json();
 | 
					  return res.json();
 | 
				
			||||||
@ -73,7 +73,7 @@ export type MonthlyKPI = {
 | 
				
			|||||||
  error?: string;
 | 
					  error?: string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function fetchMonthlyKpi(params: {
 | 
					export async function fetchMonthlyKpi(params: {
 | 
				
			||||||
  site: string;
 | 
					  site: string;
 | 
				
			||||||
 | 
				
			|||||||
@ -3,46 +3,31 @@ import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
				
			|||||||
import IconMail from '@/components/icon/icon-mail';
 | 
					import IconMail from '@/components/icon/icon-mail';
 | 
				
			||||||
import { useRouter } from 'next/navigation';
 | 
					import { useRouter } from 'next/navigation';
 | 
				
			||||||
import { useState } from 'react';
 | 
					import { useState } from 'react';
 | 
				
			||||||
 | 
					import axios from 'axios';
 | 
				
			||||||
import toast from 'react-hot-toast';
 | 
					import toast from 'react-hot-toast';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type User = { id: string; email: string; is_active: boolean };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const ComponentsAuthLoginForm = () => {
 | 
					const ComponentsAuthLoginForm = () => {
 | 
				
			||||||
  const [email, setEmail] = useState('');
 | 
					  const [email, setEmail] = useState('');
 | 
				
			||||||
  const [password, setPassword] = useState('');
 | 
					  const [password, setPassword] = useState('');
 | 
				
			||||||
  const [loading, setLoading] = useState(false);
 | 
					  const [loading, setLoading] = useState(false);
 | 
				
			||||||
  const router = useRouter();
 | 
					  const router = useRouter();
 | 
				
			||||||
  const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const submitForm = async (e: React.FormEvent<HTMLFormElement>) => {
 | 
					  const submitForm = async (e: React.FormEvent) => {
 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault();
 | 
				
			||||||
    setLoading(true);
 | 
					    setLoading(true);
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const res = await fetch(`${API}/auth/login`, {
 | 
					      const res = await axios.post('/api/login', { email, password });
 | 
				
			||||||
        method: 'POST',
 | 
					      toast.success(res.data?.message || 'Login successful!');
 | 
				
			||||||
        headers: { 'Content-Type': 'application/json' },
 | 
					 | 
				
			||||||
        body: JSON.stringify({ email, password }),
 | 
					 | 
				
			||||||
        credentials: 'include', // cookie from FastAPI
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      let data: any = null;
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        data = await res.json();
 | 
					 | 
				
			||||||
      } catch {
 | 
					 | 
				
			||||||
        // non-JSON error
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!res.ok) {
 | 
					 | 
				
			||||||
        const msg = data?.detail || data?.message || 'Invalid credentials';
 | 
					 | 
				
			||||||
        throw new Error(msg);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const user: User = data;
 | 
					 | 
				
			||||||
      toast.success(`Welcome ${user.email}`);
 | 
					 | 
				
			||||||
      router.push('/adminDashboard');
 | 
					      router.push('/adminDashboard');
 | 
				
			||||||
      router.refresh();
 | 
					      router.refresh();
 | 
				
			||||||
 | 
					      // token cookie is already set by the server:
 | 
				
			||||||
    } catch (err: any) {
 | 
					    } catch (err: any) {
 | 
				
			||||||
      toast.error(err?.message ?? 'Login failed');
 | 
					      console.error('Login error:', err);
 | 
				
			||||||
 | 
					      const msg =
 | 
				
			||||||
 | 
					        err?.response?.data?.message ||
 | 
				
			||||||
 | 
					        err?.message ||
 | 
				
			||||||
 | 
					        'Invalid credentials';
 | 
				
			||||||
 | 
					      toast.error(msg);
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      setLoading(false);
 | 
					      setLoading(false);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -67,7 +52,6 @@ const ComponentsAuthLoginForm = () => {
 | 
				
			|||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className="pb-2">
 | 
					      <div className="pb-2">
 | 
				
			||||||
        <label htmlFor="Password" className="text-yellow-400 text-left">Password</label>
 | 
					        <label htmlFor="Password" className="text-yellow-400 text-left">Password</label>
 | 
				
			||||||
        <div className="relative text-white-dark">
 | 
					        <div className="relative text-white-dark">
 | 
				
			||||||
@ -86,7 +70,6 @@ const ComponentsAuthLoginForm = () => {
 | 
				
			|||||||
          </span>
 | 
					          </span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
      <button
 | 
					      <button
 | 
				
			||||||
        type="submit"
 | 
					        type="submit"
 | 
				
			||||||
        disabled={loading}
 | 
					        disabled={loading}
 | 
				
			||||||
@ -100,4 +83,3 @@ const ComponentsAuthLoginForm = () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export default ComponentsAuthLoginForm;
 | 
					export default ComponentsAuthLoginForm;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -33,14 +33,12 @@ export default function ComponentsAuthRegisterForm({ redirectTo = "/dashboard" }
 | 
				
			|||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const API = process.env.NEXT_PUBLIC_FASTAPI_URL; // e.g. http://localhost:8000
 | 
					 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      setLoading(true);
 | 
					      setLoading(true);
 | 
				
			||||||
      const res = await fetch(`${API}/auth/register`, {
 | 
					      const res = await fetch("/api/register", {
 | 
				
			||||||
        method: 'POST',
 | 
					        method: "POST",
 | 
				
			||||||
        headers: { 'Content-Type': 'application/json' },
 | 
					        headers: { "Content-Type": "application/json" },
 | 
				
			||||||
        body: JSON.stringify({ email, password }),
 | 
					        body: JSON.stringify({ email, password }),
 | 
				
			||||||
        credentials: 'include',
 | 
					 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const data = await res.json();
 | 
					      const data = await res.json();
 | 
				
			||||||
 | 
				
			|||||||
@ -18,8 +18,6 @@ import { color } from 'html2canvas/dist/types/css/types/color';
 | 
				
			|||||||
import DatePicker from 'react-datepicker';
 | 
					import DatePicker from 'react-datepicker';
 | 
				
			||||||
import 'react-datepicker/dist/react-datepicker.css';
 | 
					import 'react-datepicker/dist/react-datepicker.css';
 | 
				
			||||||
import './datepicker-dark.css'; // custom dark mode styles
 | 
					import './datepicker-dark.css'; // custom dark mode styles
 | 
				
			||||||
import 'chartjs-adapter-date-fns';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ChartJS.register(zoomPlugin);
 | 
					ChartJS.register(zoomPlugin);
 | 
				
			||||||
@ -70,6 +68,7 @@ function powerSeriesToEnergySeries(
 | 
				
			|||||||
  return out;
 | 
					  return out;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function groupTimeSeries(
 | 
					function groupTimeSeries(
 | 
				
			||||||
  data: TimeSeriesEntry[],
 | 
					  data: TimeSeriesEntry[],
 | 
				
			||||||
  mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly',
 | 
					  mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly',
 | 
				
			||||||
@ -83,11 +82,11 @@ function groupTimeSeries(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    switch (mode) {
 | 
					    switch (mode) {
 | 
				
			||||||
      case 'day': {
 | 
					      case 'day': {
 | 
				
			||||||
        // Snap to 5-minute buckets in local (KL) time
 | 
					 | 
				
			||||||
        const local = new Date(
 | 
					        const local = new Date(
 | 
				
			||||||
          date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
 | 
					          date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        local.setSeconds(0, 0);
 | 
					        const minute = local.getMinutes() < 30 ? 0 : 30;
 | 
				
			||||||
 | 
					        local.setMinutes(minute, 0, 0);
 | 
				
			||||||
        key = local.toISOString();
 | 
					        key = local.toISOString();
 | 
				
			||||||
        break;
 | 
					        break;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@ -126,17 +125,7 @@ function groupTimeSeries(
 | 
				
			|||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// ---- NEW: build a 5-minute time grid for the day view
 | 
					
 | 
				
			||||||
function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] {
 | 
					 | 
				
			||||||
  const grid: string[] = [];
 | 
					 | 
				
			||||||
  const t = new Date(start);
 | 
					 | 
				
			||||||
  t.setSeconds(0, 0);
 | 
					 | 
				
			||||||
  while (t.getTime() <= end.getTime()) {
 | 
					 | 
				
			||||||
    grid.push(new Date(t).toISOString());
 | 
					 | 
				
			||||||
    t.setTime(t.getTime() + stepMinutes * 60 * 1000);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return grid;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
					const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			||||||
  const chartRef = useRef<any>(null);
 | 
					  const chartRef = useRef<any>(null);
 | 
				
			||||||
@ -149,6 +138,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
  const LIVE_REFRESH_MS = 300000;       // 5min when viewing a single day
 | 
					  const LIVE_REFRESH_MS = 300000;       // 5min when viewing a single day
 | 
				
			||||||
  const SLOW_REFRESH_MS = 600000;      // 10min for weekly/monthly/yearly
 | 
					  const SLOW_REFRESH_MS = 600000;      // 10min for weekly/monthly/yearly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const fetchAndSet = React.useCallback(async () => {
 | 
					  const fetchAndSet = React.useCallback(async () => {
 | 
				
			||||||
    const now = new Date();
 | 
					    const now = new Date();
 | 
				
			||||||
    let start: Date;
 | 
					    let start: Date;
 | 
				
			||||||
@ -184,6 +174,15 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
      const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
 | 
					      const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd);
 | 
				
			||||||
      setConsumption(res.consumption);
 | 
					      setConsumption(res.consumption);
 | 
				
			||||||
      setGeneration(res.generation);
 | 
					      setGeneration(res.generation);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Forecast only needs updating for the selected day
 | 
				
			||||||
 | 
					      const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 25.67);
 | 
				
			||||||
 | 
					      const selectedDateStr = selectedDate.toISOString().split('T')[0];
 | 
				
			||||||
 | 
					      setForecast(
 | 
				
			||||||
 | 
					        forecastData
 | 
				
			||||||
 | 
					          .filter(({ time }: any) => time.startsWith(selectedDateStr))
 | 
				
			||||||
 | 
					          .map(({ time, forecast }: any) => ({ time, value: forecast }))
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error('Failed to fetch energy timeseries:', error);
 | 
					      console.error('Failed to fetch energy timeseries:', error);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -223,22 +222,23 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
    };
 | 
					    };
 | 
				
			||||||
  }, [fetchAndSet, viewMode]);
 | 
					  }, [fetchAndSet, viewMode]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function useIsDarkMode() {
 | 
					  function useIsDarkMode() {
 | 
				
			||||||
    const [isDark, setIsDark] = useState(() =>
 | 
					  const [isDark, setIsDark] = useState(() =>
 | 
				
			||||||
      typeof document !== 'undefined'
 | 
					    typeof document !== 'undefined'
 | 
				
			||||||
        ? document.body.classList.contains('dark')
 | 
					      ? document.body.classList.contains('dark')
 | 
				
			||||||
        : false
 | 
					      : false
 | 
				
			||||||
    );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
      const check = () => setIsDark(document.body.classList.contains('dark'));
 | 
					    const check = () => setIsDark(document.body.classList.contains('dark'));
 | 
				
			||||||
      const observer = new MutationObserver(check);
 | 
					    const observer = new MutationObserver(check);
 | 
				
			||||||
      observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
 | 
					    observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
 | 
				
			||||||
      return () => observer.disconnect();
 | 
					    return () => observer.disconnect();
 | 
				
			||||||
    }, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return isDark;
 | 
					  return isDark;
 | 
				
			||||||
  }
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const now = new Date();
 | 
					    const now = new Date();
 | 
				
			||||||
@ -278,17 +278,17 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
        setGeneration(res.generation);
 | 
					        setGeneration(res.generation);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // ⬇️ ADD THIS here — fetch forecast
 | 
					        // ⬇️ ADD THIS here — fetch forecast
 | 
				
			||||||
        const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67);
 | 
					      const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67);
 | 
				
			||||||
        const selectedDateStr = selectedDate.toISOString().split('T')[0];
 | 
					      const selectedDateStr = selectedDate.toISOString().split('T')[0];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        setForecast(
 | 
					      setForecast(
 | 
				
			||||||
          forecastData
 | 
					        forecastData
 | 
				
			||||||
            .filter(({ time }) => time.startsWith(selectedDateStr))  // ✅ filter only selected date
 | 
					          .filter(({ time }) => time.startsWith(selectedDateStr))  // ✅ filter only selected date
 | 
				
			||||||
            .map(({ time, forecast }) => ({
 | 
					          .map(({ time, forecast }) => ({
 | 
				
			||||||
              time,
 | 
					            time,
 | 
				
			||||||
              value: forecast
 | 
					            value: forecast
 | 
				
			||||||
            }))
 | 
					          }))
 | 
				
			||||||
        );
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
        console.error('Failed to fetch energy timeseries:', error);
 | 
					        console.error('Failed to fetch energy timeseries:', error);
 | 
				
			||||||
@ -300,58 +300,48 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const isEnergyView = viewMode !== 'day';
 | 
					  const isEnergyView = viewMode !== 'day';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Convert to energy series for aggregated views
 | 
					// Convert to energy series for aggregated views
 | 
				
			||||||
  const consumptionForGrouping = isEnergyView
 | 
					const consumptionForGrouping = isEnergyView
 | 
				
			||||||
    ? powerSeriesToEnergySeries(consumption, 30)
 | 
					  ? powerSeriesToEnergySeries(consumption, 30)
 | 
				
			||||||
    : consumption;
 | 
					  : consumption;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const generationForGrouping = isEnergyView
 | 
					const generationForGrouping = isEnergyView
 | 
				
			||||||
    ? powerSeriesToEnergySeries(generation, 30)
 | 
					  ? powerSeriesToEnergySeries(generation, 30)
 | 
				
			||||||
    : generation;
 | 
					  : generation;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const forecastForGrouping = isEnergyView
 | 
					const forecastForGrouping = isEnergyView
 | 
				
			||||||
    ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60
 | 
					  ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60
 | 
				
			||||||
    : forecast;
 | 
					  : forecast;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Group: sum for energy views, mean for day view
 | 
					// Group: sum for energy views, mean for day view
 | 
				
			||||||
  const groupedConsumption = groupTimeSeries(
 | 
					const groupedConsumption = groupTimeSeries(
 | 
				
			||||||
    consumptionForGrouping,
 | 
					  consumptionForGrouping,
 | 
				
			||||||
    viewMode,
 | 
					  viewMode,
 | 
				
			||||||
    isEnergyView ? 'sum' : 'mean'
 | 
					  isEnergyView ? 'sum' : 'mean'
 | 
				
			||||||
  );
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const groupedGeneration = groupTimeSeries(
 | 
					const groupedGeneration = groupTimeSeries(
 | 
				
			||||||
    generationForGrouping,
 | 
					  generationForGrouping,
 | 
				
			||||||
    viewMode,
 | 
					  viewMode,
 | 
				
			||||||
    isEnergyView ? 'sum' : 'mean'
 | 
					  isEnergyView ? 'sum' : 'mean'
 | 
				
			||||||
  );
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const groupedForecast = groupTimeSeries(
 | 
					const groupedForecast = groupTimeSeries(
 | 
				
			||||||
    forecastForGrouping,
 | 
					  forecastForGrouping,
 | 
				
			||||||
    viewMode,
 | 
					  viewMode,
 | 
				
			||||||
    isEnergyView ? 'sum' : 'mean'
 | 
					  isEnergyView ? 'sum' : 'mean'
 | 
				
			||||||
  );
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
 | 
					  const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const dataTimesDay = [
 | 
					 | 
				
			||||||
    ...groupedConsumption.map(d => Date.parse(d.time)),
 | 
					 | 
				
			||||||
    ...groupedGeneration.map(d => Date.parse(d.time)),
 | 
					 | 
				
			||||||
    ...groupedForecast.map(d => Date.parse(d.time)),
 | 
					 | 
				
			||||||
  ].filter(Number.isFinite).sort((a, b) => a - b);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const dayGrid = viewMode === 'day'
 | 
					  const allTimes = Array.from(new Set([
 | 
				
			||||||
  ? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1)
 | 
					  ...groupedConsumption.map(d => d.time),
 | 
				
			||||||
  : [];
 | 
					  ...groupedGeneration.map(d => d.time),
 | 
				
			||||||
 | 
					  ...groupedForecast.map(d => d.time),
 | 
				
			||||||
 | 
					])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const unionTimes = Array.from(new Set([
 | 
					 | 
				
			||||||
    ...groupedConsumption.map(d => d.time),
 | 
					 | 
				
			||||||
    ...groupedGeneration.map(d => d.time),
 | 
					 | 
				
			||||||
    ...groupedForecast.map(d => d.time),
 | 
					 | 
				
			||||||
  ])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const allTimes = viewMode === 'day' ? dayGrid : unionTimes;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value]));
 | 
					  const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value]));
 | 
				
			||||||
  const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value]));
 | 
					  const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value]));
 | 
				
			||||||
@ -359,38 +349,6 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
 | 
				
			|||||||
  const [startIndex, setStartIndex] = useState(0);
 | 
					  const [startIndex, setStartIndex] = useState(0);
 | 
				
			||||||
  const [endIndex, setEndIndex] = useState(allTimes.length - 1);
 | 
					  const [endIndex, setEndIndex] = useState(allTimes.length - 1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // after allTimes, consumptionMap, generationMap, forecastMap
 | 
					 | 
				
			||||||
const hasDataAt = (t: string) =>
 | 
					 | 
				
			||||||
  t in consumptionMap || t in generationMap || t in forecastMap;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const firstAvailableIndex = allTimes.findIndex(hasDataAt);
 | 
					 | 
				
			||||||
const lastAvailableIndex = (() => {
 | 
					 | 
				
			||||||
  for (let i = allTimes.length - 1; i >= 0; i--) {
 | 
					 | 
				
			||||||
    if (hasDataAt(allTimes[i])) return i;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  return -1;
 | 
					 | 
				
			||||||
})();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const selectableIndices =
 | 
					 | 
				
			||||||
  firstAvailableIndex === -1 || lastAvailableIndex === -1
 | 
					 | 
				
			||||||
    ? []
 | 
					 | 
				
			||||||
    : Array.from(
 | 
					 | 
				
			||||||
        { length: lastAvailableIndex - firstAvailableIndex + 1 },
 | 
					 | 
				
			||||||
        (_, k) => firstAvailableIndex + k
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
  if (selectableIndices.length === 0) {
 | 
					 | 
				
			||||||
    setStartIndex(0);
 | 
					 | 
				
			||||||
    setEndIndex(Math.max(0, allTimes.length - 1));
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  const minIdx = selectableIndices[0];
 | 
					 | 
				
			||||||
  const maxIdx = selectableIndices[selectableIndices.length - 1];
 | 
					 | 
				
			||||||
  setStartIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
 | 
					 | 
				
			||||||
  setEndIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx));
 | 
					 | 
				
			||||||
}, [viewMode, allTimes.length, firstAvailableIndex, lastAvailableIndex]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (typeof window !== 'undefined') {
 | 
					    if (typeof window !== 'undefined') {
 | 
				
			||||||
@ -399,18 +357,9 @@ const selectableIndices =
 | 
				
			|||||||
  }, []);
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
  if (selectableIndices.length) {
 | 
					 | 
				
			||||||
    const minIdx = selectableIndices[0];
 | 
					 | 
				
			||||||
    const maxIdx = selectableIndices[selectableIndices.length - 1];
 | 
					 | 
				
			||||||
    setStartIndex(minIdx);
 | 
					 | 
				
			||||||
    setEndIndex(maxIdx);
 | 
					 | 
				
			||||||
  } else {
 | 
					 | 
				
			||||||
    setStartIndex(0);
 | 
					    setStartIndex(0);
 | 
				
			||||||
    setEndIndex(Math.max(0, allTimes.length - 1));
 | 
					    setEndIndex(allTimes.length - 1);
 | 
				
			||||||
  }
 | 
					  }, [viewMode, allTimes.length]);
 | 
				
			||||||
  // run whenever mode changes or the timeline changes
 | 
					 | 
				
			||||||
}, [viewMode, allTimes, firstAvailableIndex, lastAvailableIndex]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const formatLabel = (key: string) => {
 | 
					  const formatLabel = (key: string) => {
 | 
				
			||||||
    switch (viewMode) {
 | 
					    switch (viewMode) {
 | 
				
			||||||
@ -431,47 +380,35 @@ const selectableIndices =
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const filteredLabels = allTimes.slice(startIndex, endIndex + 1);
 | 
					  const filteredLabels = allTimes.slice(startIndex, endIndex + 1);
 | 
				
			||||||
 | 
					  const filteredConsumption = filteredLabels.map(t => consumptionMap[t] ?? 0);
 | 
				
			||||||
 | 
					  const filteredGeneration = filteredLabels.map(t => generationMap[t] ?? 0);
 | 
				
			||||||
 | 
					  const filteredForecast = filteredLabels.map(t => forecastMap[t] ?? null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const minutesOfDayForLabels =
 | 
					 | 
				
			||||||
  viewMode === 'day'
 | 
					 | 
				
			||||||
    ? filteredLabels.map((iso) => {
 | 
					 | 
				
			||||||
        const d = new Date(iso);
 | 
					 | 
				
			||||||
        const kl = new Date(d.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }));
 | 
					 | 
				
			||||||
        return kl.getHours() * 60 + kl.getMinutes();
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
    : [];
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // ---- CHANGED: use nulls for missing buckets (not zeros)
 | 
					  const allValues = [...filteredConsumption, ...filteredGeneration].filter(v => v !== null) as number[];
 | 
				
			||||||
  const filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null));
 | 
					 | 
				
			||||||
  const filteredGeneration  = filteredLabels.map(t => (t in generationMap  ? generationMap[t]  : null));
 | 
					 | 
				
			||||||
  const filteredForecast    = filteredLabels.map(t => (t in forecastMap    ? forecastMap[t]    : null));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const allValues = [...filteredConsumption, ...filteredGeneration].filter(
 | 
					 | 
				
			||||||
    (v): v is number => v !== null
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0;
 | 
					  const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0;
 | 
				
			||||||
  const yAxisSuggestedMax = maxValue * 1.15;
 | 
					  const yAxisSuggestedMax = maxValue * 1.15;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const isDark = useIsDarkMode();
 | 
					  const isDark = useIsDarkMode();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const axisColor = isDark ? '#fff' : '#222';
 | 
					const axisColor = isDark ? '#fff' : '#222';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
 | 
					function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) {
 | 
				
			||||||
    const { ctx: g, chartArea } = ctx.chart;
 | 
					  const { ctx: g, chartArea } = ctx.chart;
 | 
				
			||||||
    if (!chartArea) return hex; // initial render fallback
 | 
					  if (!chartArea) return hex; // initial render fallback
 | 
				
			||||||
    const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
 | 
					  const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
 | 
				
			||||||
    // top more opaque → bottom fades out
 | 
					  // top more opaque → bottom fades out
 | 
				
			||||||
    gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0'));
 | 
					  gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0'));
 | 
				
			||||||
    gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
 | 
					  gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0'));
 | 
				
			||||||
    return gradient;
 | 
					  return gradient;
 | 
				
			||||||
  }
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Define colors for both light and dark modes
 | 
					// Define colors for both light and dark modes
 | 
				
			||||||
  const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
 | 
					const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
 | 
				
			||||||
  const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
 | 
					const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode
 | 
				
			||||||
  const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
 | 
					const forecastColor = '#fcd913'; // A golden yellow that works well in both modes
 | 
				
			||||||
  const yUnit = isEnergyView ? 'kWh' : 'kW';
 | 
					const yUnit = isEnergyView ? 'kWh' : 'kW';
 | 
				
			||||||
  const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
 | 
					const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const data = {
 | 
					  const data = {
 | 
				
			||||||
    labels: filteredLabels.map(formatLabel),
 | 
					    labels: filteredLabels.map(formatLabel),
 | 
				
			||||||
@ -481,57 +418,42 @@ const selectableIndices =
 | 
				
			|||||||
        data: filteredConsumption,
 | 
					        data: filteredConsumption,
 | 
				
			||||||
        borderColor: consumptionColor,
 | 
					        borderColor: consumptionColor,
 | 
				
			||||||
        backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
 | 
					        backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
 | 
				
			||||||
        fill: true,
 | 
					        fill: true,                                   // <-- fill under line
 | 
				
			||||||
        tension: 0.2,
 | 
					        tension: 0.4,
 | 
				
			||||||
        spanGaps: true,
 | 
					        spanGaps: true,
 | 
				
			||||||
        pointRadius: 0.7,        // default is 3, make smaller
 | 
					 | 
				
			||||||
        pointHoverRadius: 4,   // a bit bigger on hover
 | 
					 | 
				
			||||||
        borderWidth: 2,
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        label: 'Generation',
 | 
					        label: 'Generation',
 | 
				
			||||||
        data: filteredGeneration,
 | 
					        data: filteredGeneration,
 | 
				
			||||||
        borderColor: generationColor,
 | 
					        borderColor: generationColor,
 | 
				
			||||||
        backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
 | 
					        backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
 | 
				
			||||||
        fill: true,
 | 
					        fill: true,                                   // <-- fill under line
 | 
				
			||||||
        tension: 0.2,
 | 
					        tension: 0.4,
 | 
				
			||||||
        spanGaps: true,
 | 
					        spanGaps: true,
 | 
				
			||||||
        pointRadius: 0.7,        // default is 3, make smaller
 | 
					 | 
				
			||||||
        pointHoverRadius: 4,   // a bit bigger on hover
 | 
					 | 
				
			||||||
        borderWidth: 2,
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
        label: 'Forecasted Solar',
 | 
					      label: 'Forecasted Solar',
 | 
				
			||||||
        data: filteredForecast,
 | 
					      data: filteredForecast,
 | 
				
			||||||
        borderColor: '#fcd913',
 | 
					      borderColor: '#fcd913', // orange
 | 
				
			||||||
        backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
 | 
					      backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03),
 | 
				
			||||||
        tension: 0.4,
 | 
					      tension: 0.4,
 | 
				
			||||||
        borderDash: [5, 5],
 | 
					      borderDash: [5, 5], // dashed line to distinguish forecast
 | 
				
			||||||
        fill: true,
 | 
					      fill: true,
 | 
				
			||||||
        spanGaps: true,
 | 
					      spanGaps: true,
 | 
				
			||||||
        pointRadius: 1,        // default is 3, make smaller
 | 
					    }
 | 
				
			||||||
        pointHoverRadius: 4,   // a bit bigger on hover
 | 
					 | 
				
			||||||
        borderWidth: 2,
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const options = {
 | 
					  const options = {
 | 
				
			||||||
    responsive: true,
 | 
					    responsive: true,
 | 
				
			||||||
    maintainAspectRatio: false,
 | 
					    maintainAspectRatio: false,
 | 
				
			||||||
    normalized: true,                // faster lookup
 | 
					 | 
				
			||||||
    plugins: {
 | 
					    plugins: {
 | 
				
			||||||
      decimation: {
 | 
					    legend: {
 | 
				
			||||||
      enabled: true,
 | 
					      position: 'top',
 | 
				
			||||||
      algorithm: 'lttb',           // best visual fidelity
 | 
					      labels: {
 | 
				
			||||||
      samples: 400,                // cap points actually drawn (~400 is a good default)
 | 
					        color: axisColor, // legend text color
 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
      legend: {
 | 
					 | 
				
			||||||
        position: 'top',
 | 
					 | 
				
			||||||
        labels: {
 | 
					 | 
				
			||||||
          color: axisColor, // legend text color
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
      zoom: {
 | 
					      zoom: {
 | 
				
			||||||
        zoom: {
 | 
					        zoom: {
 | 
				
			||||||
          wheel: { enabled: true },
 | 
					          wheel: { enabled: true },
 | 
				
			||||||
@ -541,26 +463,25 @@ const selectableIndices =
 | 
				
			|||||||
        pan: { enabled: true, mode: 'x' as const },
 | 
					        pan: { enabled: true, mode: 'x' as const },
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      tooltip: {
 | 
					      tooltip: {
 | 
				
			||||||
        enabled: true,
 | 
					      enabled: true,
 | 
				
			||||||
        mode: 'index',
 | 
					      mode: 'index',
 | 
				
			||||||
        intersect: false,
 | 
					      intersect: false,
 | 
				
			||||||
        backgroundColor: isDark ? '#232b3e' : '#fff',
 | 
					      backgroundColor: isDark ? '#232b3e' : '#fff',
 | 
				
			||||||
        titleColor: axisColor,
 | 
					      titleColor: axisColor,
 | 
				
			||||||
        bodyColor: axisColor,
 | 
					      bodyColor: axisColor,
 | 
				
			||||||
        borderColor: isDark ? '#444' : '#ccc',
 | 
					      borderColor: isDark ? '#444' : '#ccc',
 | 
				
			||||||
        borderWidth: 1,
 | 
					      borderWidth: 1,
 | 
				
			||||||
        callbacks: {
 | 
					      callbacks: {
 | 
				
			||||||
          label: (ctx: any) => {
 | 
					      label: (ctx: any) => {
 | 
				
			||||||
            const dsLabel = ctx.dataset.label || '';
 | 
					        const dsLabel = ctx.dataset.label || '';
 | 
				
			||||||
            const val = ctx.parsed.y;
 | 
					        const val = ctx.parsed.y;
 | 
				
			||||||
            return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
 | 
					        return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    scales: {
 | 
					    scales: {
 | 
				
			||||||
      x: {
 | 
					      x: {
 | 
				
			||||||
        type: 'category' as const,
 | 
					 | 
				
			||||||
        title: {
 | 
					        title: {
 | 
				
			||||||
          display: true,
 | 
					          display: true,
 | 
				
			||||||
          color: axisColor,
 | 
					          color: axisColor,
 | 
				
			||||||
@ -577,44 +498,18 @@ const selectableIndices =
 | 
				
			|||||||
          font: { weight: 'normal' as const },
 | 
					          font: { weight: 'normal' as const },
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        ticks: {
 | 
					        ticks: {
 | 
				
			||||||
    color: axisColor,
 | 
					        color: axisColor,
 | 
				
			||||||
    autoSkip: false,          // let our callback decide
 | 
					 | 
				
			||||||
    maxRotation: 0,
 | 
					 | 
				
			||||||
    callback(
 | 
					 | 
				
			||||||
        this: any,
 | 
					 | 
				
			||||||
        tickValue: string | number,
 | 
					 | 
				
			||||||
        index: number,
 | 
					 | 
				
			||||||
        ticks: any[]
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        if (viewMode !== 'day') return this.getLabelForValue(tickValue as number);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const scale = this.chart.scales.x;
 | 
					 | 
				
			||||||
        const min = Math.max(0, Math.floor(scale.min ?? 0));
 | 
					 | 
				
			||||||
        const max = Math.min(ticks.length - 1, Math.ceil(scale.max ?? ticks.length - 1));
 | 
					 | 
				
			||||||
        const visibleCount = Math.max(1, max - min + 1);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        let step = 30;                 // ≥ 6h
 | 
					 | 
				
			||||||
        if (visibleCount < 80) step = 10;// 2–6h
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // On a category scale, tickValue is usually the index (number).
 | 
					 | 
				
			||||||
        const idx = typeof tickValue === 'number' ? tickValue : index;
 | 
					 | 
				
			||||||
        const m = minutesOfDayForLabels[idx];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (m != null && m % step === 0) {
 | 
					 | 
				
			||||||
          return this.getLabelForValue(idx);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return ''; // hide crowded labels
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      y: {
 | 
					      y: {
 | 
				
			||||||
        beginAtZero: true,
 | 
					        beginAtZero: true,
 | 
				
			||||||
        suggestedMax: yAxisSuggestedMax,
 | 
					        suggestedMax: yAxisSuggestedMax,
 | 
				
			||||||
        title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
 | 
					        title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
 | 
				
			||||||
        ticks: {
 | 
					        ticks: {
 | 
				
			||||||
          color: axisColor,
 | 
					        color: axisColor,
 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  } as const;
 | 
					  } as const;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -624,7 +519,7 @@ const selectableIndices =
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
 | 
					    <div className="bg-white p-4 rounded-lg shadow-md dark:bg-rtgray-800 dark:text-white-light">
 | 
				
			||||||
      <div className="h-98 w-full"  onDoubleClick={handleResetZoom}>
 | 
					      <div className="h-98 w-full">
 | 
				
			||||||
        <div className="flex justify-between items-center mb-2">
 | 
					        <div className="flex justify-between items-center mb-2">
 | 
				
			||||||
          <h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2>
 | 
					          <h2 className="text-lg font-bold dark:text-white-light">Energy Consumption & Generation</h2>
 | 
				
			||||||
          <button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm">
 | 
					          <button onClick={handleResetZoom} className="btn-primary px-8 py-2 text-sm">
 | 
				
			||||||
@ -653,15 +548,13 @@ const selectableIndices =
 | 
				
			|||||||
                const val = Number(e.target.value);
 | 
					                const val = Number(e.target.value);
 | 
				
			||||||
                setStartIndex(val <= endIndex ? val : endIndex);
 | 
					                setStartIndex(val <= endIndex ? val : endIndex);
 | 
				
			||||||
              }}
 | 
					              }}
 | 
				
			||||||
              disabled={selectableIndices.length === 0}
 | 
					 | 
				
			||||||
              className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
 | 
					              className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              {selectableIndices.map((absIdx) => (
 | 
					              {allTimes.map((label, idx) => (
 | 
				
			||||||
                <option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option>
 | 
					                <option key={idx} value={idx}>{formatLabel(label)}</option>
 | 
				
			||||||
              ))}
 | 
					              ))}
 | 
				
			||||||
            </select>
 | 
					            </select>
 | 
				
			||||||
          </label>
 | 
					          </label>
 | 
				
			||||||
 | 
					 | 
				
			||||||
          <label className="font-medium ">
 | 
					          <label className="font-medium ">
 | 
				
			||||||
            To:{' '}
 | 
					            To:{' '}
 | 
				
			||||||
            <select
 | 
					            <select
 | 
				
			||||||
@ -670,15 +563,13 @@ const selectableIndices =
 | 
				
			|||||||
                const val = Number(e.target.value);
 | 
					                const val = Number(e.target.value);
 | 
				
			||||||
                setEndIndex(val >= startIndex ? val : startIndex);
 | 
					                setEndIndex(val >= startIndex ? val : startIndex);
 | 
				
			||||||
              }}
 | 
					              }}
 | 
				
			||||||
              disabled={selectableIndices.length === 0}
 | 
					 | 
				
			||||||
              className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
 | 
					              className="dark:bg-rtgray-700 border dark:border-rtgray-700 rounded p-1"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              {selectableIndices.map((absIdx) => (
 | 
					              {allTimes.map((label, idx) => (
 | 
				
			||||||
                <option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option>
 | 
					                <option key={idx} value={idx}>{formatLabel(label)}</option>
 | 
				
			||||||
              ))}
 | 
					              ))}
 | 
				
			||||||
            </select>
 | 
					            </select>
 | 
				
			||||||
          </label>
 | 
					          </label>
 | 
				
			||||||
 | 
					 | 
				
			||||||
          <label className="font-medium">
 | 
					          <label className="font-medium">
 | 
				
			||||||
            View:{' '}
 | 
					            View:{' '}
 | 
				
			||||||
            <select
 | 
					            <select
 | 
				
			||||||
@ -704,3 +595,10 @@ const selectableIndices =
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default EnergyLineChart;
 | 
					export default EnergyLineChart;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -21,8 +21,6 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
 | 
				
			|||||||
  const [kpiData, setKpiData] = useState<MonthlyKPI | null>(null);
 | 
					  const [kpiData, setKpiData] = useState<MonthlyKPI | null>(null);
 | 
				
			||||||
  const [loading, setLoading] = useState(false);
 | 
					  const [loading, setLoading] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (!siteId || !month) return;
 | 
					    if (!siteId || !month) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -30,7 +28,7 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
 | 
				
			|||||||
      setLoading(true);
 | 
					      setLoading(true);
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        const res = await fetch(
 | 
					        const res = await fetch(
 | 
				
			||||||
          `${API_URL}/kpi/monthly?site=${siteId}&month=${month}`
 | 
					          `http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        setKpiData(await res.json());
 | 
					        setKpiData(await res.json());
 | 
				
			||||||
      } catch (err) {
 | 
					      } catch (err) {
 | 
				
			||||||
 | 
				
			|||||||
@ -11,7 +11,7 @@ interface LoggingControlCardProps {
 | 
				
			|||||||
  className?: string;
 | 
					  className?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type FnState = {
 | 
					type FnState = {
 | 
				
			||||||
  serial: string;
 | 
					  serial: string;
 | 
				
			||||||
 | 
				
			|||||||
@ -26,7 +26,7 @@ interface SiteCardProps {
 | 
				
			|||||||
  fallbackStatus?: string;       // optional backup status if CRM is missing it
 | 
					  fallbackStatus?: string;       // optional backup status if CRM is missing it
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => {
 | 
					const SiteCard: React.FC<SiteCardProps> = ({ siteId, className = '', fallbackStatus }) => {
 | 
				
			||||||
  const [project, setProject] = useState<CrmProject | null>(null);
 | 
					  const [project, setProject] = useState<CrmProject | null>(null);
 | 
				
			||||||
 | 
				
			|||||||
@ -15,7 +15,8 @@ interface SiteStatusProps {
 | 
				
			|||||||
  lastSyncTimestamp: string;
 | 
					  lastSyncTimestamp: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
 | 
				
			||||||
 | 
					const WS_URL  = process.env.NEXT_PUBLIC_WS_URL  ?? "ws://localhost:8000/ws";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const SiteStatus = ({
 | 
					const SiteStatus = ({
 | 
				
			||||||
  selectedSite,
 | 
					  selectedSite,
 | 
				
			||||||
@ -29,7 +30,7 @@ const SiteStatus = ({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  // --- WebSocket to receive MQTT-forwarded messages ---
 | 
					  // --- WebSocket to receive MQTT-forwarded messages ---
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const ws = new WebSocket(`${API_URL}/ws`);
 | 
					    const ws = new WebSocket(WS_URL);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ws.onopen = () => console.log("WebSocket connected");
 | 
					    ws.onopen = () => console.log("WebSocket connected");
 | 
				
			||||||
    ws.onclose = () => console.log("WebSocket disconnected");
 | 
					    ws.onclose = () => console.log("WebSocket disconnected");
 | 
				
			||||||
 | 
				
			|||||||
@ -3,18 +3,19 @@ import { useEffect, useState } from 'react';
 | 
				
			|||||||
import { useDispatch, useSelector } from 'react-redux';
 | 
					import { useDispatch, useSelector } from 'react-redux';
 | 
				
			||||||
import Link from 'next/link';
 | 
					import Link from 'next/link';
 | 
				
			||||||
import { IRootState } from '@/store';
 | 
					import { IRootState } from '@/store';
 | 
				
			||||||
import { toggleTheme, toggleSidebar } from '@/store/themeConfigSlice';
 | 
					import { toggleTheme, toggleSidebar, toggleRTL } from '@/store/themeConfigSlice';
 | 
				
			||||||
import Image from 'next/image';
 | 
					import Image from 'next/image';
 | 
				
			||||||
import Dropdown from '@/components/dropdown';
 | 
					import Dropdown from '@/components/dropdown';
 | 
				
			||||||
import IconMenu from '@/components/icon/icon-menu';
 | 
					import IconMenu from '@/components/icon/icon-menu';
 | 
				
			||||||
import IconSun from '@/components/icon/icon-sun';
 | 
					import IconSun from '@/components/icon/icon-sun';
 | 
				
			||||||
import IconMoon from '@/components/icon/icon-moon';
 | 
					import IconMoon from '@/components/icon/icon-moon';
 | 
				
			||||||
import IconUser from '@/components/icon/icon-user';
 | 
					import IconUser from '@/components/icon/icon-user';
 | 
				
			||||||
 | 
					import IconMail from '@/components/icon/icon-mail';
 | 
				
			||||||
import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
					import IconLockDots from '@/components/icon/icon-lock-dots';
 | 
				
			||||||
import IconLogout from '@/components/icon/icon-logout';
 | 
					import IconLogout from '@/components/icon/icon-logout';
 | 
				
			||||||
import { usePathname, useRouter } from 'next/navigation';
 | 
					import { usePathname, useRouter } from 'next/navigation';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type UserData = { id: string; email: string; is_active: boolean };
 | 
					type UserData = { id: string; email: string; createdAt: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function Header() {
 | 
					export default function Header() {
 | 
				
			||||||
  const pathname = usePathname();
 | 
					  const pathname = usePathname();
 | 
				
			||||||
@ -26,16 +27,17 @@ export default function Header() {
 | 
				
			|||||||
  const [user, setUser] = useState<UserData | null>(null);
 | 
					  const [user, setUser] = useState<UserData | null>(null);
 | 
				
			||||||
  const [loadingUser, setLoadingUser] = useState(true);
 | 
					  const [loadingUser, setLoadingUser] = useState(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
 | 
					  // Highlight active menu (your original effect)
 | 
				
			||||||
 | 
					 | 
				
			||||||
  // highlight active menu
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const selector = document.querySelector(
 | 
					    const selector = document.querySelector(
 | 
				
			||||||
      `ul.horizontal-menu a[href="${window.location.pathname}"]`
 | 
					      'ul.horizontal-menu a[href="' + window.location.pathname + '"]'
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    if (selector) {
 | 
					    if (selector) {
 | 
				
			||||||
      document
 | 
					      document
 | 
				
			||||||
        .querySelectorAll('ul.horizontal-menu .nav-link.active, ul.horizontal-menu a.active')
 | 
					        .querySelectorAll('ul.horizontal-menu .nav-link.active')
 | 
				
			||||||
 | 
					        .forEach((el) => el.classList.remove('active'));
 | 
				
			||||||
 | 
					      document
 | 
				
			||||||
 | 
					        .querySelectorAll('ul.horizontal-menu a.active')
 | 
				
			||||||
        .forEach((el) => el.classList.remove('active'));
 | 
					        .forEach((el) => el.classList.remove('active'));
 | 
				
			||||||
      selector.classList.add('active');
 | 
					      selector.classList.add('active');
 | 
				
			||||||
      const ul: any = selector.closest('ul.sub-menu');
 | 
					      const ul: any = selector.closest('ul.sub-menu');
 | 
				
			||||||
@ -46,16 +48,16 @@ export default function Header() {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [pathname]);
 | 
					  }, [pathname]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async function loadUser(signal?: AbortSignal) {
 | 
					  async function loadUser() {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const res = await fetch(`${API}/auth/me`, {
 | 
					    const res = await fetch('/api/auth/me', {
 | 
				
			||||||
      credentials: 'include',
 | 
					      method: 'GET',
 | 
				
			||||||
      cache: 'no-store',
 | 
					      credentials: 'include', // send cookie
 | 
				
			||||||
      signal,
 | 
					      cache: 'no-store',      // avoid stale cached responses
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    if (!res.ok) throw new Error();
 | 
					    if (!res.ok) throw new Error();
 | 
				
			||||||
    const data = await res.json().catch(() => null);
 | 
					    const data = await res.json();
 | 
				
			||||||
    setUser(data?.id ? (data as UserData) : null);
 | 
					    setUser(data.user);
 | 
				
			||||||
  } catch {
 | 
					  } catch {
 | 
				
			||||||
    setUser(null);
 | 
					    setUser(null);
 | 
				
			||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
@ -63,53 +65,44 @@ export default function Header() {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					useEffect(() => {
 | 
				
			||||||
    setLoadingUser(true);
 | 
					  setLoadingUser(true);
 | 
				
			||||||
    const controller = new AbortController();
 | 
					  loadUser();
 | 
				
			||||||
    loadUser(controller.signal);
 | 
					  // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
    return () => controller.abort();
 | 
					}, [pathname]); // re-fetch on route change (after login redirect)
 | 
				
			||||||
  }, [pathname, API]);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleLogout = async () => {
 | 
					  const handleLogout = async () => {
 | 
				
			||||||
    try {
 | 
					    await fetch('/api/auth/logout', { method: 'POST' });
 | 
				
			||||||
    await fetch(`${API}/auth/logout`, {
 | 
					 | 
				
			||||||
      method: 'POST',
 | 
					 | 
				
			||||||
      credentials: 'include',
 | 
					 | 
				
			||||||
      cache: 'no-store',
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  } catch (_) {
 | 
					 | 
				
			||||||
    // ignore
 | 
					 | 
				
			||||||
  } finally {
 | 
					 | 
				
			||||||
    setUser(null);
 | 
					    setUser(null);
 | 
				
			||||||
    window.location.href = '/login';
 | 
					    router.push('/login'); // go to login
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
 | 
					    <header className={`z-40 ${themeConfig.semidark && themeConfig.menu === 'horizontal' ? 'dark' : ''}`}>
 | 
				
			||||||
      <div className="shadow-sm">
 | 
					      <div className="shadow-sm">
 | 
				
			||||||
        <div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-rtgray-900">
 | 
					        <div className="relative flex w-full items-center bg-white px-5 py-2.5 dark:bg-rtgray-900">
 | 
				
			||||||
          {/* Logo + mobile toggler */}
 | 
					          {/* Logo */}
 | 
				
			||||||
          <div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
 | 
					          <div className="horizontal-logo flex items-center justify-between ltr:mr-2 rtl:ml-2 lg:hidden">
 | 
				
			||||||
            <div className="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12">
 | 
					            <div className="relative h-10 w-32 sm:h-11 sm:w-36 md:h-12 md:w-27 shrink-0 max-h-12">
 | 
				
			||||||
              <Image
 | 
					                <Image
 | 
				
			||||||
                src="/assets/images/newfulllogo.png"
 | 
					                src="/assets/images/newfulllogo.png"
 | 
				
			||||||
                alt="logo"
 | 
					                alt="logo"
 | 
				
			||||||
                fill
 | 
					                fill
 | 
				
			||||||
                className="object-cover"
 | 
					                className="object-cover"
 | 
				
			||||||
                priority
 | 
					                priority
 | 
				
			||||||
                sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem"
 | 
					                sizes="(max-width: 640px) 8rem, (max-width: 768px) 9rem, (max-width: 1024px) 10rem, 10rem"
 | 
				
			||||||
              />
 | 
					                />
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <button
 | 
					            <button
 | 
				
			||||||
              type="button"
 | 
					                type="button"
 | 
				
			||||||
              onClick={() => dispatch(toggleSidebar())}
 | 
					                onClick={() => dispatch(toggleSidebar())}
 | 
				
			||||||
              className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700"
 | 
					                className="collapse-icon flex p-2 rounded-full hover:bg-rtgray-200 dark:text-white dark:hover:bg-rtgray-700"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <IconMenu className="h-6 w-6" />
 | 
					                <IconMenu className="h-6 w-6" />
 | 
				
			||||||
            </button>
 | 
					            </button>
 | 
				
			||||||
          </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          {/* Right-side actions */}
 | 
					          {/* Right-side actions */}
 | 
				
			||||||
          <div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] lg:space-x-2">
 | 
					          <div className="flex items-center justify-end space-x-1.5 ltr:ml-auto rtl:mr-auto rtl:space-x-reverse dark:text-[#d0d2d6] lg:space-x-2">
 | 
				
			||||||
@ -131,21 +124,21 @@ export default function Header() {
 | 
				
			|||||||
            )}
 | 
					            )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {/* User dropdown */}
 | 
					            {/* User dropdown */}
 | 
				
			||||||
            <div className="dropdown flex shrink-0">
 | 
					            <div className="dropdown flex shrink-0 ">
 | 
				
			||||||
              {loadingUser ? (
 | 
					              {loadingUser ? (
 | 
				
			||||||
                <div className="h-9 w-9 rounded-full animate-pulse bg-gray-300 dark:bg-rtgray-800" />
 | 
					                <div className="h-9 w-9 rounded-full animate-pulse bg-gray-300 dark:bg-rtgray-800" />
 | 
				
			||||||
              ) : user ? (
 | 
					              ) : user ? (
 | 
				
			||||||
                <Dropdown
 | 
					            <Dropdown
 | 
				
			||||||
                  placement={isRtl ? 'bottom-start' : 'bottom-end'}
 | 
					                placement={isRtl ? 'bottom-start' : 'bottom-end'}
 | 
				
			||||||
                  btnClassName="relative group block"
 | 
					                btnClassName="relative group block"
 | 
				
			||||||
                  panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2"
 | 
					                panelClassName="rounded-lg shadow-lg border border-white/10 bg-rtgray-100 dark:bg-rtgray-800 p-2" // ✅
 | 
				
			||||||
                  button={
 | 
					                button={
 | 
				
			||||||
                    <div className="h-9 w-9 rounded-full bg-rtgray-200 dark:bg-rtgray-800 flex items-center justify-center group-hover:bg-rtgray-300 dark:group-hover:bg-rtgray-700">
 | 
					                        <div className="h-9 w-9 rounded-full bg-rtgray-200 dark:bg-rtgray-800 flex items-center justify-center group-hover:bg-rtgray-300 dark:group-hover:bg-rtgray-700">
 | 
				
			||||||
                      <IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" />
 | 
					                        <IconUser className="h-5 w-5 text-gray-600 dark:text-gray-300" />
 | 
				
			||||||
                    </div>
 | 
					                        </div>
 | 
				
			||||||
                  }
 | 
					                    }
 | 
				
			||||||
                >
 | 
					                >
 | 
				
			||||||
                  <ul className="w-[230px] font-semibold text-dark">
 | 
					                <ul className="w-[230px] font-semibold text-dark"> {/* make sure this stays transparent */}
 | 
				
			||||||
                    <li className="px-4 py-4 flex items-center">
 | 
					                    <li className="px-4 py-4 flex items-center">
 | 
				
			||||||
                      <div className="truncate ltr:pl-1.5 rtl:pr-4">
 | 
					                      <div className="truncate ltr:pl-1.5 rtl:pr-4">
 | 
				
			||||||
                        <h4 className="text-sm text-left">{user.email}</h4>
 | 
					                        <h4 className="text-sm text-left">{user.email}</h4>
 | 
				
			||||||
 | 
				
			|||||||
@ -10,10 +10,10 @@ export function middleware(req: NextRequest) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
        jwt.verify(token, SECRET_KEY);
 | 
					        jwt.verify(token, SECRET_KEY);
 | 
				
			||||||
    return NextResponse.next();
 | 
					        return NextResponse.next();
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
        return NextResponse.redirect(new URL("/login", req.url));
 | 
					        return NextResponse.redirect(new URL("/login", req.url));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const config = { matcher: ["/dashboard", "/profile"] };
 | 
					export const config = { matcher: ["/dashboard", "/profile"] };
 | 
				
			||||||
 | 
				
			|||||||
@ -1,19 +1,10 @@
 | 
				
			|||||||
/** @type {import('next').NextConfig} */
 | 
					/** @type {import('next').NextConfig} */
 | 
				
			||||||
const nextConfig = {
 | 
					const nextConfig = {
 | 
				
			||||||
    output: 'standalone',
 | 
					 | 
				
			||||||
    reactStrictMode: false,
 | 
					    reactStrictMode: false,
 | 
				
			||||||
    swcMinify: true,
 | 
					    swcMinify: true,
 | 
				
			||||||
    eslint: {
 | 
					    eslint: {
 | 
				
			||||||
        ignoreDuringBuilds: true,
 | 
					        ignoreDuringBuilds: true,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    async rewrites() {
 | 
					 | 
				
			||||||
    return [
 | 
					 | 
				
			||||||
      {
 | 
					 | 
				
			||||||
        source: "/api/:path*",
 | 
					 | 
				
			||||||
        destination: "http://backend:8000/:path*", // internal Docker hostname
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = nextConfig;
 | 
					module.exports = nextConfig;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										32
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										32
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -22,7 +22,6 @@
 | 
				
			|||||||
                "axios": "^1.7.9",
 | 
					                "axios": "^1.7.9",
 | 
				
			||||||
                "bcrypt": "^5.1.1",
 | 
					                "bcrypt": "^5.1.1",
 | 
				
			||||||
                "chart.js": "^4.4.9",
 | 
					                "chart.js": "^4.4.9",
 | 
				
			||||||
                "chartjs-adapter-date-fns": "^3.0.0",
 | 
					 | 
				
			||||||
                "chartjs-plugin-zoom": "^2.2.0",
 | 
					                "chartjs-plugin-zoom": "^2.2.0",
 | 
				
			||||||
                "cookie": "^1.0.2",
 | 
					                "cookie": "^1.0.2",
 | 
				
			||||||
                "date-fns": "^4.1.0",
 | 
					                "date-fns": "^4.1.0",
 | 
				
			||||||
@ -32,7 +31,6 @@
 | 
				
			|||||||
                "he": "^1.2.0",
 | 
					                "he": "^1.2.0",
 | 
				
			||||||
                "html2canvas": "^1.4.1",
 | 
					                "html2canvas": "^1.4.1",
 | 
				
			||||||
                "i18next": "^22.4.10",
 | 
					                "i18next": "^22.4.10",
 | 
				
			||||||
                "jose": "^6.0.12",
 | 
					 | 
				
			||||||
                "jsonwebtoken": "^9.0.2",
 | 
					                "jsonwebtoken": "^9.0.2",
 | 
				
			||||||
                "jspdf": "^3.0.1",
 | 
					                "jspdf": "^3.0.1",
 | 
				
			||||||
                "next": "14.0.3",
 | 
					                "next": "14.0.3",
 | 
				
			||||||
@ -4795,16 +4793,6 @@
 | 
				
			|||||||
                "pnpm": ">=8"
 | 
					                "pnpm": ">=8"
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "node_modules/chartjs-adapter-date-fns": {
 | 
					 | 
				
			||||||
            "version": "3.0.0",
 | 
					 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
 | 
					 | 
				
			||||||
            "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
 | 
					 | 
				
			||||||
            "license": "MIT",
 | 
					 | 
				
			||||||
            "peerDependencies": {
 | 
					 | 
				
			||||||
                "chart.js": ">=2.8.0",
 | 
					 | 
				
			||||||
                "date-fns": ">=2.0.0"
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "node_modules/chartjs-plugin-zoom": {
 | 
					        "node_modules/chartjs-plugin-zoom": {
 | 
				
			||||||
            "version": "2.2.0",
 | 
					            "version": "2.2.0",
 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
 | 
					            "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
 | 
				
			||||||
@ -7119,15 +7107,6 @@
 | 
				
			|||||||
                "jiti": "bin/jiti.js"
 | 
					                "jiti": "bin/jiti.js"
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "node_modules/jose": {
 | 
					 | 
				
			||||||
            "version": "6.0.12",
 | 
					 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
 | 
					 | 
				
			||||||
            "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==",
 | 
					 | 
				
			||||||
            "license": "MIT",
 | 
					 | 
				
			||||||
            "funding": {
 | 
					 | 
				
			||||||
                "url": "https://github.com/sponsors/panva"
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "node_modules/js-sdsl": {
 | 
					        "node_modules/js-sdsl": {
 | 
				
			||||||
            "version": "4.2.0",
 | 
					            "version": "4.2.0",
 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz",
 | 
					            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz",
 | 
				
			||||||
@ -13085,12 +13064,6 @@
 | 
				
			|||||||
                "@kurkle/color": "^0.3.0"
 | 
					                "@kurkle/color": "^0.3.0"
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "chartjs-adapter-date-fns": {
 | 
					 | 
				
			||||||
            "version": "3.0.0",
 | 
					 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
 | 
					 | 
				
			||||||
            "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
 | 
					 | 
				
			||||||
            "requires": {}
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "chartjs-plugin-zoom": {
 | 
					        "chartjs-plugin-zoom": {
 | 
				
			||||||
            "version": "2.2.0",
 | 
					            "version": "2.2.0",
 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
 | 
					            "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz",
 | 
				
			||||||
@ -14720,11 +14693,6 @@
 | 
				
			|||||||
            "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz",
 | 
					            "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz",
 | 
				
			||||||
            "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q=="
 | 
					            "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q=="
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "jose": {
 | 
					 | 
				
			||||||
            "version": "6.0.12",
 | 
					 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
 | 
					 | 
				
			||||||
            "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "js-sdsl": {
 | 
					        "js-sdsl": {
 | 
				
			||||||
            "version": "4.2.0",
 | 
					            "version": "4.2.0",
 | 
				
			||||||
            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz",
 | 
					            "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz",
 | 
				
			||||||
 | 
				
			|||||||
@ -23,7 +23,6 @@
 | 
				
			|||||||
        "axios": "^1.7.9",
 | 
					        "axios": "^1.7.9",
 | 
				
			||||||
        "bcrypt": "^5.1.1",
 | 
					        "bcrypt": "^5.1.1",
 | 
				
			||||||
        "chart.js": "^4.4.9",
 | 
					        "chart.js": "^4.4.9",
 | 
				
			||||||
        "chartjs-adapter-date-fns": "^3.0.0",
 | 
					 | 
				
			||||||
        "chartjs-plugin-zoom": "^2.2.0",
 | 
					        "chartjs-plugin-zoom": "^2.2.0",
 | 
				
			||||||
        "cookie": "^1.0.2",
 | 
					        "cookie": "^1.0.2",
 | 
				
			||||||
        "date-fns": "^4.1.0",
 | 
					        "date-fns": "^4.1.0",
 | 
				
			||||||
@ -33,7 +32,6 @@
 | 
				
			|||||||
        "he": "^1.2.0",
 | 
					        "he": "^1.2.0",
 | 
				
			||||||
        "html2canvas": "^1.4.1",
 | 
					        "html2canvas": "^1.4.1",
 | 
				
			||||||
        "i18next": "^22.4.10",
 | 
					        "i18next": "^22.4.10",
 | 
				
			||||||
        "jose": "^6.0.12",
 | 
					 | 
				
			||||||
        "jsonwebtoken": "^9.0.2",
 | 
					        "jsonwebtoken": "^9.0.2",
 | 
				
			||||||
        "jspdf": "^3.0.1",
 | 
					        "jspdf": "^3.0.1",
 | 
				
			||||||
        "next": "14.0.3",
 | 
					        "next": "14.0.3",
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										46
									
								
								pages/api/auth/me.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								pages/api/auth/me.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
				
			|||||||
 | 
					// pages/api/auth/me.ts
 | 
				
			||||||
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
 | 
					import jwt, { JwtPayload } from "jsonwebtoken";
 | 
				
			||||||
 | 
					import { PrismaClient } from "@prisma/client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const prisma = new PrismaClient();
 | 
				
			||||||
 | 
					const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function readCookieToken(req: NextApiRequest) {
 | 
				
			||||||
 | 
					  const cookie = req.headers.cookie || "";
 | 
				
			||||||
 | 
					  const match = cookie.split("; ").find((c) => c.startsWith("token="));
 | 
				
			||||||
 | 
					  return match?.split("=")[1];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function readAuthBearer(req: NextApiRequest) {
 | 
				
			||||||
 | 
					  const auth = req.headers.authorization;
 | 
				
			||||||
 | 
					  if (!auth?.startsWith("Bearer ")) return undefined;
 | 
				
			||||||
 | 
					  return auth.slice("Bearer ".length);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function hasEmail(payload: string | JwtPayload): payload is JwtPayload & { email: string } {
 | 
				
			||||||
 | 
					  return typeof payload === "object" && payload !== null && typeof (payload as any).email === "string";
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
 | 
					  if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const token = readAuthBearer(req) ?? readCookieToken(req);
 | 
				
			||||||
 | 
					    if (!token) return res.status(401).json({ message: "Unauthorized" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const decoded = jwt.verify(token, SECRET_KEY);
 | 
				
			||||||
 | 
					    if (!hasEmail(decoded)) return res.status(401).json({ message: "Invalid token" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const user = await prisma.user.findUnique({
 | 
				
			||||||
 | 
					      where: { email: decoded.email },
 | 
				
			||||||
 | 
					      select: { id: true, email: true, createdAt: true },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!user) return res.status(401).json({ message: "User not found" });
 | 
				
			||||||
 | 
					    return res.status(200).json({ user });
 | 
				
			||||||
 | 
					  } catch {
 | 
				
			||||||
 | 
					    return res.status(401).json({ message: "Invalid token" });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										42
									
								
								pages/api/login.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								pages/api/login.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					// pages/api/login.ts
 | 
				
			||||||
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
 | 
					import { PrismaClient } from "@prisma/client";
 | 
				
			||||||
 | 
					import bcrypt from "bcrypt";
 | 
				
			||||||
 | 
					import jwt from "jsonwebtoken";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const prisma = new PrismaClient();
 | 
				
			||||||
 | 
					const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
 | 
					  if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { email, password } = req.body as { email?: string; password?: string };
 | 
				
			||||||
 | 
					    if (!email || !password) return res.status(400).json({ message: "Email and password are required" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const user = await prisma.user.findUnique({ where: { email } });
 | 
				
			||||||
 | 
					    if (!user) return res.status(401).json({ message: "Invalid credentials" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const isMatch = await bcrypt.compare(password, user.password);
 | 
				
			||||||
 | 
					    if (!isMatch) return res.status(401).json({ message: "Invalid credentials" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const isProd = process.env.NODE_ENV === "production";
 | 
				
			||||||
 | 
					    const cookie = [
 | 
				
			||||||
 | 
					      `token=${token}`,
 | 
				
			||||||
 | 
					      "HttpOnly",
 | 
				
			||||||
 | 
					      "Path=/",
 | 
				
			||||||
 | 
					      "SameSite=Strict",
 | 
				
			||||||
 | 
					      `Max-Age=${60 * 60 * 24}`, // 1 day
 | 
				
			||||||
 | 
					      isProd ? "Secure" : "",    // only secure in prod
 | 
				
			||||||
 | 
					    ].filter(Boolean).join("; ");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    res.setHeader("Set-Cookie", cookie);
 | 
				
			||||||
 | 
					    return res.status(200).json({ message: "Login successful" });
 | 
				
			||||||
 | 
					  } catch (e) {
 | 
				
			||||||
 | 
					    console.error(e);
 | 
				
			||||||
 | 
					    return res.status(500).json({ message: "Something went wrong" });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								pages/api/logout.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					// pages/api/auth/logout.ts
 | 
				
			||||||
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
 | 
					  const isProd = process.env.NODE_ENV === "production";
 | 
				
			||||||
 | 
					  res.setHeader(
 | 
				
			||||||
 | 
					    "Set-Cookie",
 | 
				
			||||||
 | 
					    [
 | 
				
			||||||
 | 
					      "token=", // empty token
 | 
				
			||||||
 | 
					      "HttpOnly",
 | 
				
			||||||
 | 
					      "Path=/",
 | 
				
			||||||
 | 
					      "SameSite=Strict",
 | 
				
			||||||
 | 
					      "Max-Age=0", // expire immediately
 | 
				
			||||||
 | 
					      isProd ? "Secure" : "",
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					      .filter(Boolean)
 | 
				
			||||||
 | 
					      .join("; ")
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  return res.status(200).json({ message: "Logged out" });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										42
									
								
								pages/api/register.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								pages/api/register.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					// pages/api/register.ts
 | 
				
			||||||
 | 
					import type { NextApiRequest, NextApiResponse } from "next";
 | 
				
			||||||
 | 
					import { PrismaClient } from "@prisma/client";
 | 
				
			||||||
 | 
					import bcrypt from "bcrypt";
 | 
				
			||||||
 | 
					import jwt from "jsonwebtoken";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const prisma = new PrismaClient();
 | 
				
			||||||
 | 
					const SECRET_KEY = process.env.JWT_SECRET as string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default async function handler(req: NextApiRequest, res: NextApiResponse) {
 | 
				
			||||||
 | 
					  if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { email, password } = req.body as { email?: string; password?: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!email || !password) return res.status(400).json({ message: "Email and password are required" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const existingUser = await prisma.user.findUnique({ where: { email } });
 | 
				
			||||||
 | 
					    if (existingUser) return res.status(400).json({ message: "User already exists" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const hashedPassword = await bcrypt.hash(password, 10);
 | 
				
			||||||
 | 
					    const user = await prisma.user.create({
 | 
				
			||||||
 | 
					      data: { email, password: hashedPassword },
 | 
				
			||||||
 | 
					      select: { id: true, email: true, createdAt: true }, // do NOT expose password
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const token = jwt.sign({ sub: String(user.id), email: user.email }, SECRET_KEY, { expiresIn: "1d" });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Set a secure, httpOnly cookie
 | 
				
			||||||
 | 
					    const maxAge = 60 * 60 * 24; // 1 day
 | 
				
			||||||
 | 
					    res.setHeader(
 | 
				
			||||||
 | 
					      "Set-Cookie",
 | 
				
			||||||
 | 
					      `token=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict; Secure`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return res.status(201).json({ message: "User registered", user });
 | 
				
			||||||
 | 
					  } catch (err) {
 | 
				
			||||||
 | 
					    console.error(err);
 | 
				
			||||||
 | 
					    return res.status(500).json({ message: "Something went wrong" });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -1,18 +0,0 @@
 | 
				
			|||||||
/*
 | 
					 | 
				
			||||||
  Warnings:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  - You are about to drop the `EnergyData` table. If the table is not empty, all the data it contains will be lost.
 | 
					 | 
				
			||||||
  - You are about to drop the `Site` table. If the table is not empty, all the data it contains will be lost.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
*/
 | 
					 | 
				
			||||||
-- DropForeignKey
 | 
					 | 
				
			||||||
ALTER TABLE "public"."EnergyData" DROP CONSTRAINT "EnergyData_consumptionSiteId_fkey";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- DropForeignKey
 | 
					 | 
				
			||||||
ALTER TABLE "public"."EnergyData" DROP CONSTRAINT "EnergyData_generationSiteId_fkey";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- DropTable
 | 
					 | 
				
			||||||
DROP TABLE "public"."EnergyData";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
-- DropTable
 | 
					 | 
				
			||||||
DROP TABLE "public"."Site";
 | 
					 | 
				
			||||||
@ -1,12 +0,0 @@
 | 
				
			|||||||
/*
 | 
					 | 
				
			||||||
  Warnings:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  - You are about to drop the column `password` on the `User` table. All the data in the column will be lost.
 | 
					 | 
				
			||||||
  - Added the required column `passwordHash` to the `User` table without a default value. This is not possible if the table is not empty.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
*/
 | 
					 | 
				
			||||||
-- AlterTable
 | 
					 | 
				
			||||||
ALTER TABLE "public"."User" DROP COLUMN "password",
 | 
					 | 
				
			||||||
ADD COLUMN     "name" TEXT,
 | 
					 | 
				
			||||||
ADD COLUMN     "passwordHash" TEXT NOT NULL,
 | 
					 | 
				
			||||||
ADD COLUMN     "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
 | 
					 | 
				
			||||||
@ -1,14 +0,0 @@
 | 
				
			|||||||
/*
 | 
					 | 
				
			||||||
  Warnings:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  - You are about to drop the column `name` on the `User` table. All the data in the column will be lost.
 | 
					 | 
				
			||||||
  - You are about to drop the column `passwordHash` on the `User` table. All the data in the column will be lost.
 | 
					 | 
				
			||||||
  - You are about to drop the column `updatedAt` on the `User` table. All the data in the column will be lost.
 | 
					 | 
				
			||||||
  - Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
*/
 | 
					 | 
				
			||||||
-- AlterTable
 | 
					 | 
				
			||||||
ALTER TABLE "public"."User" DROP COLUMN "name",
 | 
					 | 
				
			||||||
DROP COLUMN "passwordHash",
 | 
					 | 
				
			||||||
DROP COLUMN "updatedAt",
 | 
					 | 
				
			||||||
ADD COLUMN     "password" TEXT NOT NULL;
 | 
					 | 
				
			||||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 3.2 KiB  | 
@ -1,70 +0,0 @@
 | 
				
			|||||||
// utils/export.ts
 | 
					 | 
				
			||||||
export type ExportParams = {
 | 
					 | 
				
			||||||
  baseUrl?: string;               // e.g. process.env.NEXT_PUBLIC_API_URL
 | 
					 | 
				
			||||||
  site: string;                   // PROJ-0028
 | 
					 | 
				
			||||||
  suffix?: "grid" | "solar";      // default "grid"
 | 
					 | 
				
			||||||
  serial?: string | null;         // device id like "01"
 | 
					 | 
				
			||||||
  day?: string;                   // "YYYY-MM-DD" (preferred)
 | 
					 | 
				
			||||||
  start?: string;                 // ISO string (if not using day)
 | 
					 | 
				
			||||||
  end?: string;                   // ISO string (if not using day)
 | 
					 | 
				
			||||||
  columns?: string[];             // optional list of columns
 | 
					 | 
				
			||||||
  localTz?: string;               // default "Asia/Kuala_Lumpur"
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function buildExportUrl(p: ExportParams): string {
 | 
					 | 
				
			||||||
  const {
 | 
					 | 
				
			||||||
    baseUrl = "",
 | 
					 | 
				
			||||||
    site,
 | 
					 | 
				
			||||||
    suffix = "grid",
 | 
					 | 
				
			||||||
    serial,
 | 
					 | 
				
			||||||
    day,
 | 
					 | 
				
			||||||
    start,
 | 
					 | 
				
			||||||
    end,
 | 
					 | 
				
			||||||
    columns,
 | 
					 | 
				
			||||||
    localTz = "Asia/Kuala_Lumpur",
 | 
					 | 
				
			||||||
  } = p;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const params = new URLSearchParams();
 | 
					 | 
				
			||||||
  params.set("site", site);
 | 
					 | 
				
			||||||
  params.set("suffix", suffix);
 | 
					 | 
				
			||||||
  params.set("local_tz", localTz);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const s = serial?.trim();
 | 
					 | 
				
			||||||
  if (s) params.set("serial", s);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (day) {
 | 
					 | 
				
			||||||
    params.set("day", day); // simple whole-day export
 | 
					 | 
				
			||||||
  } else {
 | 
					 | 
				
			||||||
    if (!start || !end) throw new Error("Provide either day=YYYY-MM-DD or both start and end.");
 | 
					 | 
				
			||||||
    params.set("start", start); // URLSearchParams will encode '+' correctly
 | 
					 | 
				
			||||||
    params.set("end", end);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (columns?.length) {
 | 
					 | 
				
			||||||
    // backend expects ?columns=... repeated; append each
 | 
					 | 
				
			||||||
    columns.forEach(c => params.append("columns", c));
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // ensure there's a single slash join for /export/xlsx
 | 
					 | 
				
			||||||
  const root = baseUrl.replace(/\/+$/, "");
 | 
					 | 
				
			||||||
  return `${root}/export/xlsx?${params.toString()}`;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/** Parse filename from Content-Disposition (handles RFC5987 filename*) */
 | 
					 | 
				
			||||||
export function getFilenameFromCD(cd: string | null): string | null {
 | 
					 | 
				
			||||||
  if (!cd) return null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // filename*=UTF-8''encoded-name.xlsx
 | 
					 | 
				
			||||||
  const star = /filename\*\s*=\s*([^']*)''([^;]+)/i.exec(cd);
 | 
					 | 
				
			||||||
  if (star && star[2]) {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      return decodeURIComponent(star[2]);
 | 
					 | 
				
			||||||
    } catch {
 | 
					 | 
				
			||||||
      return star[2];
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // filename="name.xlsx" OR filename=name.xlsx
 | 
					 | 
				
			||||||
  const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(cd);
 | 
					 | 
				
			||||||
  return plain ? plain[2] : null;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user