Compare commits
33 Commits
features/s
...
master
Author | SHA1 | Date | |
---|---|---|---|
00fe939804 | |||
ed131acab4 | |||
eac2bb51e2 | |||
4c6a1a0cb4 | |||
f5b41dd230 | |||
418f23586b | |||
86682398db | |||
fce26a2bc4 | |||
f77aa0358e | |||
c28cb86fdb | |||
9ac0f389dd | |||
1ec15ac214 | |||
dca62ebe8b | |||
03e073d06e | |||
b49e4b5b2f | |||
aaee8f7c14 | |||
3fc57ad117 | |||
354e12d365 | |||
a1e7985cf8 | |||
6ad368d64e | |||
49badd488a | |||
13a389efc8 | |||
4d21924690 | |||
bb972e81ae | |||
9b65e38542 | |||
5041c5a27d | |||
036bff862a | |||
a375f8a8f4 | |||
79c611d061 | |||
1133e52ec0 | |||
209516b1a6 | |||
e689384977 | |||
175b759611 |
11
.env.example
11
.env.example
@ -1,11 +1,2 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:3005
|
NEXT_PUBLIC_FASTAPI_URL="http://localhost:8000"
|
||||||
|
|
||||||
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=
|
|
||||||
|
60
.gitea/workflows/deploy.yml
Normal file
60
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
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:3000'
|
NEXT_PUBLIC_URL: 'http://localhost:3005'
|
||||||
NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3001'
|
NEXT_PUBLIC_FORECAST_URL: 'http://localhost:3005'
|
||||||
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
Normal file
56
Dockerfile
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 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,6 +14,7 @@ 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 });
|
||||||
@ -40,7 +41,7 @@ type CrmProject = {
|
|||||||
custom_mobile_phone_no?: string | null;
|
custom_mobile_phone_no?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';
|
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
|
|
||||||
// Adjust this to your FastAPI route
|
// Adjust this to your FastAPI route
|
||||||
const START_LOGGING_ENDPOINT = (siteId: string) =>
|
const START_LOGGING_ENDPOINT = (siteId: string) =>
|
||||||
@ -59,6 +60,7 @@ 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[]>([]);
|
||||||
@ -67,6 +69,42 @@ 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);
|
||||||
@ -288,7 +326,7 @@ const AdminDashboard = () => {
|
|||||||
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));
|
||||||
@ -299,7 +337,100 @@ const AdminDashboard = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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>
|
||||||
@ -421,10 +552,151 @@ const AdminDashboard = () => {
|
|||||||
<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_API_URL ?? 'http://localhost:8000';
|
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
|
|
||||||
const SitesPage = () => {
|
const SitesPage = () => {
|
||||||
const [projects, setProjects] = useState<CrmProject[]>([]);
|
const [projects, setProjects] = useState<CrmProject[]>([]);
|
||||||
|
@ -1,73 +1,115 @@
|
|||||||
|
// app/login/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Metadata } from 'next';
|
import React, { useEffect, useState } from 'react';
|
||||||
import React from 'react';
|
import { useRouter } from 'next/navigation';
|
||||||
import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form';
|
import ComponentsAuthLoginForm from '@/components/auth/components-auth-login-form';
|
||||||
|
|
||||||
type Props = {}
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [ready, setReady] = useState(false); // gate to avoid UI flash
|
||||||
|
|
||||||
const LoginPage = (props: Props) => {
|
// Use ONE client-exposed API env var everywhere
|
||||||
return (
|
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
<div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
|
|
||||||
{/* Background gradient layer */}
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<img
|
|
||||||
src="/assets/images/auth/bg-gradient.png"
|
|
||||||
alt="background gradient"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Background decorative objects */}
|
useEffect(() => {
|
||||||
<img
|
let cancelled = false;
|
||||||
src="/assets/images/auth/coming-soon-object1.png"
|
const controller = new AbortController();
|
||||||
alt="left decor"
|
|
||||||
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 */}
|
(async () => {
|
||||||
<div className="relative flex min-h-screen items-center justify-center px-6 py-10 sm:px-16">
|
try {
|
||||||
<div
|
const res = await fetch(`${API}/auth/me`, {
|
||||||
className="relative w-full max-w-[870px] rounded-2xl p-1
|
credentials: 'include',
|
||||||
bg-[linear-gradient(45deg,#fffbe6_0%,rgba(255,251,230,0)_25%,rgba(255,251,230,0)_75%,#fffbe6_100%)]
|
cache: 'no-store', // don't reuse a cached 401
|
||||||
dark:bg-[linear-gradient(52.22deg,#facc15_0%,rgba(250,204,21,0)_20%,rgba(250,204,21,0)_80%,#facc15_100%)]"
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await res.json().catch(() => null);
|
||||||
|
if (user?.id) {
|
||||||
|
// already logged in -> go straight to dashboard
|
||||||
|
router.replace('/adminDashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// not logged in -> show form
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
} catch {
|
||||||
|
// network/error -> show form
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [router, API]);
|
||||||
|
|
||||||
|
if (!ready) return null; // or a spinner/skeleton
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-h-screen overflow-hidden bg-[#060818] text-white">
|
||||||
|
{/* Background gradient layer */}
|
||||||
|
<div className="absolute inset-0 -z-10">
|
||||||
|
<img
|
||||||
|
src="/assets/images/auth/bg-gradient.png"
|
||||||
|
alt="background gradient"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Background decorative objects */}
|
||||||
|
<img
|
||||||
|
src="/assets/images/auth/coming-soon-object1.png"
|
||||||
|
alt="left decor"
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
{/* Inner card (glassmorphic effect) */}
|
SIGN UP
|
||||||
<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]">
|
</Link>
|
||||||
<div className="mx-auto w-full max-w-[440px] text-center">
|
</div>
|
||||||
{/* Header */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Login form */}
|
|
||||||
<ComponentsAuthLoginForm />
|
|
||||||
|
|
||||||
{/* Footer link */}
|
|
||||||
<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>
|
</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="/register"
|
href="/login"
|
||||||
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
|
||||||
|
@ -1,261 +0,0 @@
|
|||||||
'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
|
|
@ -1,174 +0,0 @@
|
|||||||
"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
|
|
@ -1,13 +0,0 @@
|
|||||||
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;
|
|
@ -1,39 +0,0 @@
|
|||||||
"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;
|
|
@ -1,105 +0,0 @@
|
|||||||
"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
|
|
@ -1,22 +0,0 @@
|
|||||||
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,30 +1,25 @@
|
|||||||
'use client';
|
// app/layout.tsx
|
||||||
import ProviderComponent from '@/components/layouts/provider-component';
|
import type { Metadata } from "next";
|
||||||
import 'react-perfect-scrollbar/dist/css/styles.css';
|
import ProviderComponent from "@/components/layouts/provider-component";
|
||||||
import '../styles/tailwind.css';
|
import "react-perfect-scrollbar/dist/css/styles.css";
|
||||||
import { Metadata } from 'next';
|
import "../styles/tailwind.css";
|
||||||
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({
|
const exo2 = Exo_2({ subsets: ["latin"], variable: "--font-exo2", weight: ["200", "400"] });
|
||||||
subsets: ["latin"],
|
const nunito = Nunito({ weight: ["400","500","600","700","800"], subsets: ["latin"], display: "swap", variable: "--font-nunito" });
|
||||||
variable: "--font-exo2",
|
|
||||||
weight: ["200", "400"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const nunito = Nunito({
|
export const metadata: Metadata = {
|
||||||
weight: ['400', '500', '600', '700', '800'],
|
title: "Rooftop Meter",
|
||||||
subsets: ['latin'],
|
icons: { icon: "/favicon.png" }, // or "/favicon.ico"
|
||||||
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">
|
<html lang="en" className={`${exo2.variable} ${nunito.variable}`}>
|
||||||
<body className={exo2.variable}>
|
<body>
|
||||||
<ProviderComponent>{children}</ProviderComponent>
|
<ProviderComponent>{children}</ProviderComponent>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,12 +9,12 @@ export interface TimeSeriesResponse {
|
|||||||
generation: TimeSeriesEntry[];
|
generation: TimeSeriesEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";
|
process.env.NEXT_PUBLIC_FASTAPI_URL ;
|
||||||
|
|
||||||
export const crmapi = {
|
export const crmapi = {
|
||||||
getProjects: async () => {
|
getProjects: async () => {
|
||||||
const res = await fetch(`${API_BASE_URL}/crm/projects`, {
|
const res = await fetch(`${API_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(`http://localhost:8000/power-timeseries?${params.toString()}`);
|
const res = await fetch(`${API_URL}/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(`http://localhost:8000/forecast?${query}`);
|
const res = await fetch(`${API_URL}/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_API_URL ?? "http://localhost:8000";
|
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
|
|
||||||
export async function fetchMonthlyKpi(params: {
|
export async function fetchMonthlyKpi(params: {
|
||||||
site: string;
|
site: string;
|
||||||
|
@ -3,31 +3,46 @@ 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) => {
|
const submitForm = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await axios.post('/api/login', { email, password });
|
const res = await fetch(`${API}/auth/login`, {
|
||||||
toast.success(res.data?.message || 'Login successful!');
|
method: 'POST',
|
||||||
|
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) {
|
||||||
console.error('Login error:', err);
|
toast.error(err?.message ?? 'Login failed');
|
||||||
const msg =
|
|
||||||
err?.response?.data?.message ||
|
|
||||||
err?.message ||
|
|
||||||
'Invalid credentials';
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -52,6 +67,7 @@ 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">
|
||||||
@ -70,6 +86,7 @@ const ComponentsAuthLoginForm = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@ -83,3 +100,4 @@ const ComponentsAuthLoginForm = () => {
|
|||||||
|
|
||||||
export default ComponentsAuthLoginForm;
|
export default ComponentsAuthLoginForm;
|
||||||
|
|
||||||
|
|
||||||
|
@ -33,12 +33,14 @@ 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/register", {
|
const res = await fetch(`${API}/auth/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,6 +18,8 @@ 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);
|
||||||
@ -68,7 +70,6 @@ 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',
|
||||||
@ -82,11 +83,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' })
|
||||||
);
|
);
|
||||||
const minute = local.getMinutes() < 30 ? 0 : 30;
|
local.setSeconds(0, 0);
|
||||||
local.setMinutes(minute, 0, 0);
|
|
||||||
key = local.toISOString();
|
key = local.toISOString();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -125,7 +126,17 @@ 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);
|
||||||
@ -138,7 +149,6 @@ 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;
|
||||||
@ -174,15 +184,6 @@ 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);
|
||||||
}
|
}
|
||||||
@ -222,23 +223,22 @@ 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,48 +300,58 @@ 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 allTimes = Array.from(new Set([
|
const dayGrid = viewMode === 'day'
|
||||||
...groupedConsumption.map(d => d.time),
|
? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1)
|
||||||
...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]));
|
||||||
@ -349,6 +359,38 @@ const groupedForecast = groupTimeSeries(
|
|||||||
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') {
|
||||||
@ -357,9 +399,18 @@ const groupedForecast = groupTimeSeries(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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(allTimes.length - 1);
|
setEndIndex(Math.max(0, 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) {
|
||||||
@ -380,35 +431,47 @@ const groupedForecast = groupTimeSeries(
|
|||||||
};
|
};
|
||||||
|
|
||||||
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();
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const allValues = [...filteredConsumption, ...filteredGeneration].filter(v => v !== null) as number[];
|
// ---- CHANGED: use nulls for missing buckets (not zeros)
|
||||||
|
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),
|
||||||
@ -418,42 +481,57 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|||||||
data: filteredConsumption,
|
data: filteredConsumption,
|
||||||
borderColor: consumptionColor,
|
borderColor: consumptionColor,
|
||||||
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
|
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
|
||||||
fill: true, // <-- fill under line
|
fill: true,
|
||||||
tension: 0.4,
|
tension: 0.2,
|
||||||
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 under line
|
fill: true,
|
||||||
tension: 0.4,
|
tension: 0.2,
|
||||||
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', // orange
|
borderColor: '#fcd913',
|
||||||
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], // dashed line to distinguish forecast
|
borderDash: [5, 5],
|
||||||
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: {
|
||||||
legend: {
|
decimation: {
|
||||||
position: 'top',
|
enabled: true,
|
||||||
labels: {
|
algorithm: 'lttb', // best visual fidelity
|
||||||
color: axisColor, // legend text color
|
samples: 400, // cap points actually drawn (~400 is a good default)
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: axisColor, // legend text color
|
||||||
|
},
|
||||||
|
},
|
||||||
zoom: {
|
zoom: {
|
||||||
zoom: {
|
zoom: {
|
||||||
wheel: { enabled: true },
|
wheel: { enabled: true },
|
||||||
@ -463,25 +541,26 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|||||||
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,
|
||||||
@ -498,18 +577,44 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|||||||
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;
|
||||||
|
|
||||||
@ -519,7 +624,7 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|||||||
|
|
||||||
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">
|
<div className="h-98 w-full" onDoubleClick={handleResetZoom}>
|
||||||
<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">
|
||||||
@ -548,13 +653,15 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|||||||
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"
|
||||||
>
|
>
|
||||||
{allTimes.map((label, idx) => (
|
{selectableIndices.map((absIdx) => (
|
||||||
<option key={idx} value={idx}>{formatLabel(label)}</option>
|
<option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="font-medium ">
|
<label className="font-medium ">
|
||||||
To:{' '}
|
To:{' '}
|
||||||
<select
|
<select
|
||||||
@ -563,13 +670,15 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|||||||
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"
|
||||||
>
|
>
|
||||||
{allTimes.map((label, idx) => (
|
{selectableIndices.map((absIdx) => (
|
||||||
<option key={idx} value={idx}>{formatLabel(label)}</option>
|
<option key={absIdx} value={absIdx}>{formatLabel(allTimes[absIdx])}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="font-medium">
|
<label className="font-medium">
|
||||||
View:{' '}
|
View:{' '}
|
||||||
<select
|
<select
|
||||||
@ -595,10 +704,3 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)';
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default EnergyLineChart;
|
export default EnergyLineChart;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,6 +21,8 @@ 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;
|
||||||
|
|
||||||
@ -28,7 +30,7 @@ const KPI_Table: React.FC<KPI_TableProps> = ({ siteId, month }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`http://localhost:8000/kpi/monthly?site=${siteId}&month=${month}`
|
`${API_URL}/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_API_URL ?? 'http://localhost:8000';
|
const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
|
|
||||||
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_API_URL ?? 'http://localhost:8000';
|
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
|
|
||||||
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,8 +15,7 @@ interface SiteStatusProps {
|
|||||||
lastSyncTimestamp: string;
|
lastSyncTimestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
const API_URL = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8000/ws";
|
|
||||||
|
|
||||||
const SiteStatus = ({
|
const SiteStatus = ({
|
||||||
selectedSite,
|
selectedSite,
|
||||||
@ -30,7 +29,7 @@ const SiteStatus = ({
|
|||||||
|
|
||||||
// --- WebSocket to receive MQTT-forwarded messages ---
|
// --- WebSocket to receive MQTT-forwarded messages ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ws = new WebSocket(WS_URL);
|
const ws = new WebSocket(`${API_URL}/ws`);
|
||||||
|
|
||||||
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,19 +3,18 @@ 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, toggleRTL } from '@/store/themeConfigSlice';
|
import { toggleTheme, toggleSidebar } 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; createdAt: string };
|
type UserData = { id: string; email: string; is_active: boolean };
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -27,17 +26,16 @@ 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);
|
||||||
|
|
||||||
// Highlight active menu (your original effect)
|
const API = process.env.NEXT_PUBLIC_FASTAPI_URL;
|
||||||
|
|
||||||
|
// 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')
|
.querySelectorAll('ul.horizontal-menu .nav-link.active, ul.horizontal-menu a.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');
|
||||||
@ -48,16 +46,16 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
async function loadUser() {
|
async function loadUser(signal?: AbortSignal) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/me', {
|
const res = await fetch(`${API}/auth/me`, {
|
||||||
method: 'GET',
|
credentials: 'include',
|
||||||
credentials: 'include', // send cookie
|
cache: 'no-store',
|
||||||
cache: 'no-store', // avoid stale cached responses
|
signal,
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => null);
|
||||||
setUser(data.user);
|
setUser(data?.id ? (data as UserData) : null);
|
||||||
} catch {
|
} catch {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} finally {
|
} finally {
|
||||||
@ -65,44 +63,53 @@ export default function Header() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingUser(true);
|
setLoadingUser(true);
|
||||||
loadUser();
|
const controller = new AbortController();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
loadUser(controller.signal);
|
||||||
}, [pathname]); // re-fetch on route change (after login redirect)
|
return () => controller.abort();
|
||||||
|
}, [pathname, API]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
try {
|
||||||
|
await fetch(`${API}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
router.push('/login'); // go to login
|
window.location.href = '/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 */}
|
{/* Logo + mobile toggler */}
|
||||||
<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">
|
||||||
@ -124,21 +131,21 @@ useEffect(() => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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"> {/* make sure this stays transparent */}
|
<ul className="w-[230px] font-semibold text-dark">
|
||||||
<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,10 +1,19 @@
|
|||||||
/** @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,6 +22,7 @@
|
|||||||
"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",
|
||||||
@ -31,6 +32,7 @@
|
|||||||
"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",
|
||||||
@ -4793,6 +4795,16 @@
|
|||||||
"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",
|
||||||
@ -7107,6 +7119,15 @@
|
|||||||
"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",
|
||||||
@ -13064,6 +13085,12 @@
|
|||||||
"@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",
|
||||||
@ -14693,6 +14720,11 @@
|
|||||||
"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,6 +23,7 @@
|
|||||||
"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,6 +33,7 @@
|
|||||||
"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",
|
||||||
|
@ -1,46 +0,0 @@
|
|||||||
// 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" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
|||||||
// 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" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
|||||||
// 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" });
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
// 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" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
18
prisma/migrations/20250815012144_init_users/migration.sql
Normal file
18
prisma/migrations/20250815012144_init_users/migration.sql
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
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";
|
@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
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;
|
14
prisma/migrations/20250815022446_add_user/migration.sql
Normal file
14
prisma/migrations/20250815022446_add_user/migration.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
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: 3.2 KiB After Width: | Height: | Size: 78 KiB |
70
utils/export.ts
Normal file
70
utils/export.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// 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