diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 65f3a1fa..b0a97db7 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -37,6 +37,9 @@ jobs: - name: Run TypeScript type-check run: npm run type-check + - name: Run unit tests + run: npm run test + - name: Run Next.js build env: NEXT_PUBLIC_API_URL: http://localhost:6003/api @@ -91,4 +94,4 @@ jobs: with: name: playwright-traces path: frontend/test-results - retention-days: 7 \ No newline at end of file + retention-days: 7 diff --git a/frontend/app/scan/[assetId]/page.tsx b/frontend/app/scan/[assetId]/page.tsx index cd11188d..c840d0c5 100644 --- a/frontend/app/scan/[assetId]/page.tsx +++ b/frontend/app/scan/[assetId]/page.tsx @@ -2,9 +2,10 @@ import { useParams, useRouter } from "next/navigation"; import { useState, useEffect } from "react"; -import { Package, ExternalLink, AlertTriangle, LogIn } from "lucide-react"; +import { Package, AlertTriangle, LogIn, WifiOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; +import axios from "axios"; interface PublicAsset { id: string; @@ -17,6 +18,8 @@ interface PublicAsset { contactEmail?: string; } +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:6003/api"; + export default function ScanPage() { const params = useParams(); const router = useRouter(); @@ -24,16 +27,20 @@ export default function ScanPage() { const [asset, setAsset] = useState(null); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(false); useEffect(() => { setLoading(true); - fetch(`/api/assets/scan/${assetId}`) - .then((r) => { - if (!r.ok) throw new Error("Not found"); - return r.json(); + axios + .get(`${API_BASE}/assets/scan/${assetId}`) + .then((r) => setAsset(r.data?.data ?? r.data)) + .catch((err) => { + if (err.response?.status === 404) { + setNotFound(true); + } else { + setError(true); + } }) - .then((body) => setAsset(body?.data ?? body)) - .catch(() => setNotFound(true)) .finally(() => setLoading(false)); }, [assetId]); @@ -45,6 +52,25 @@ export default function ScanPage() { ); } + if (error) { + return ( +
+
+
+ +
+

Connection Error

+

+ Could not reach the server. Please check your connection and try again. +

+ +
+
+ ); + } + if (notFound || !asset) { return (
@@ -56,9 +82,12 @@ export default function ScanPage() {

This QR code does not match any registered asset in our system.

-

+

If you found this asset, please contact the owner for assistance.

+
); @@ -67,7 +96,6 @@ export default function ScanPage() { return (
- {/* Public view — minimal safe info */}
{asset.imageUrl && ( {asset.name} @@ -90,7 +118,6 @@ export default function ScanPage() { )}
- {/* Quick actions */}
); -} +} \ No newline at end of file diff --git a/frontend/components/Dashboard/DashboardCharts.tsx b/frontend/components/Dashboard/DashboardCharts.tsx deleted file mode 100644 index 5cc394c0..00000000 --- a/frontend/components/Dashboard/DashboardCharts.tsx +++ /dev/null @@ -1,308 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { - PieChart, - Pie, - Cell, - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, - Legend, -} from 'recharts'; -import { ReportsSummary } from '@/lib/api/reports'; -import { AssetStatus } from '@/lib/query/types/asset'; - -// ─── Color palettes ─────────────────────────────────────────────────────────── -const STATUS_COLORS: Record = { - [AssetStatus.ACTIVE]: '#22c55e', - [AssetStatus.ASSIGNED]: '#3b82f6', - [AssetStatus.MAINTENANCE]: '#f59e0b', - [AssetStatus.RETIRED]: '#9ca3af', -}; - -const CATEGORY_COLORS = [ - '#6366f1', '#ec4899', '#14b8a6', '#f97316', - '#8b5cf6', '#06b6d4', '#84cc16', '#ef4444', -]; - -// ─── Accessible toggle ──────────────────────────────────────────────────────── -function ChartToggle({ - showTable, - onToggle, - chartId, -}: { - showTable: boolean; - onToggle: () => void; - chartId: string; -}) { - return ( - - ); -} - -// ─── Status Distribution Donut ──────────────────────────────────────────────── -function StatusDonut({ byStatus }: { byStatus: Record }) { - const [showTable, setShowTable] = useState(false); - - const data = Object.entries(byStatus).map(([name, value]) => ({ name, value })); - - return ( -
-
-

Status Distribution

- setShowTable((v) => !v)} - chartId="status-donut-table" - /> -
- - {showTable ? ( - - - - - - - - - {data.map(({ name, value }) => ( - - - - - ))} - -
StatusCount
{name.toLowerCase()}{value}
- ) : ( - - - - {data.map(({ name }) => ( - - ))} - - ( - - {value.toLowerCase()} - - )} - /> - [value ?? 0, (name ?? '').toLowerCase()]} - /> - - - )} -
- ); -} - -// ─── Category Bar Chart ─────────────────────────────────────────────────────── -function CategoryBar({ - byCategory, -}: { - byCategory: { name: string; count: number }[]; -}) { - const [showTable, setShowTable] = useState(false); - - const data = [...byCategory] - .sort((a, b) => b.count - a.count) - .slice(0, 8) - .map((item, i) => ({ ...item, fill: CATEGORY_COLORS[i % CATEGORY_COLORS.length] })); - - return ( -
-
-

Assets by Category

- setShowTable((v) => !v)} - chartId="category-bar-table" - /> -
- - {showTable ? ( - - - - - - - - - {data.map(({ name, count }) => ( - - - - - ))} - -
CategoryCount
{name}{count}
- ) : data.length === 0 ? ( -

No category data

- ) : ( - - - - - [value ?? 0, 'Assets']} - /> - - {data.map(({ name, fill }) => ( - - ))} - - - - )} -
- ); -} - -// ─── Department Bar Chart ───────────────────────────────────────────────────── -function DepartmentBar({ - byDepartment, -}: { - byDepartment: { name: string; count: number }[]; -}) { - const [showTable, setShowTable] = useState(false); - - const data = [...byDepartment] - .sort((a, b) => b.count - a.count) - .slice(0, 8); - - return ( -
-
-

Assets by Department

- setShowTable((v) => !v)} - chartId="dept-bar-table" - /> -
- - {showTable ? ( - - - - - - - - - {data.map(({ name, count }) => ( - - - - - ))} - -
DepartmentCount
{name}{count}
- ) : data.length === 0 ? ( -

No department data

- ) : ( - - - - - [value ?? 0, 'Assets']} - /> - - - - )} -
- ); -} - -// ─── Main Export ────────────────────────────────────────────────────────────── -interface Props { - data: ReportsSummary; -} - -export default function DashboardCharts({ data }: Props) { - const { byStatus, byCategory, byDepartment } = data; - return ( -
-
- - - -
-
- ); -} diff --git a/frontend/components/Dashboard/DateRangeSelector.tsx b/frontend/components/Dashboard/DateRangeSelector.tsx deleted file mode 100644 index 3f1d4e92..00000000 --- a/frontend/components/Dashboard/DateRangeSelector.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import { useCallback } from 'react'; -import { useRouter, usePathname, useSearchParams } from 'next/navigation'; -import { Calendar } from 'lucide-react'; - -export type DateRangePreset = '7d' | '30d' | '90d' | '1y' | 'custom'; - -const PRESETS: { label: string; value: DateRangePreset }[] = [ - { label: '7 days', value: '7d' }, - { label: '30 days', value: '30d' }, - { label: '90 days', value: '90d' }, - { label: '1 year', value: '1y' }, -]; - -interface DateRangeSelectorProps { - /** Called when the range changes. `from` and `to` are ISO date strings. */ - onChange?: (range: { from: string; to: string }) => void; -} - -/** - * Date range selector that reflects its state in URL search params - * (?from=YYYY-MM-DD&to=YYYY-MM-DD&preset=30d). - * Views are shareable and survive page reload. - */ -export function DateRangeSelector({ onChange }: DateRangeSelectorProps) { - const router = useRouter(); - const pathname = usePathname(); - const params = useSearchParams(); - - const currentPreset = (params.get('preset') as DateRangePreset) ?? '30d'; - const fromParam = params.get('from') ?? ''; - const toParam = params.get('to') ?? ''; - - /** Compute ISO dates from a preset string */ - const presetToDates = useCallback((preset: DateRangePreset): { from: string; to: string } => { - const to = new Date(); - const from = new Date(); - const map: Record, number> = { - '7d': 7, - '30d': 30, - '90d': 90, - '1y': 365, - }; - if (preset !== 'custom') { - from.setDate(from.getDate() - map[preset]); - } - const fmt = (d: Date) => d.toISOString().split('T')[0]; - return { from: fmt(from), to: fmt(to) }; - }, []); - - const pushParams = useCallback( - (preset: DateRangePreset, from: string, to: string) => { - const sp = new URLSearchParams(Array.from(params.entries())); - sp.set('preset', preset); - sp.set('from', from); - sp.set('to', to); - router.push(`${pathname}?${sp.toString()}`); - onChange?.({ from, to }); - }, - [params, pathname, router, onChange], - ); - - const handlePreset = (preset: DateRangePreset) => { - const { from, to } = presetToDates(preset); - pushParams(preset, from, to); - }; - - const handleCustomFrom = (e: React.ChangeEvent) => { - pushParams('custom', e.target.value, toParam || new Date().toISOString().split('T')[0]); - }; - - const handleCustomTo = (e: React.ChangeEvent) => { - pushParams('custom', fromParam, e.target.value); - }; - - const computedValue = currentPreset === 'custom' - ? { from: fromParam, to: toParam } - : presetToDates(currentPreset); - - return ( -
-
- ); -} diff --git a/frontend/package.json b/frontend/package.json index 32223d16..3ecf34e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build --turbopack", "start": "next start", "lint": "eslint", - "test": "jest", + "type-check": "tsc --noEmit", + "test": "jest --ci", "test:e2e": "playwright test" }, "dependencies": { @@ -16,6 +17,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.2.2", + "@stellar/freighter-api": "^1.8.0", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "^8.21.3", diff --git a/frontend/store/Dashboard/DashboardCharts.tsx b/frontend/store/Dashboard/DashboardCharts.tsx deleted file mode 100644 index 5cc394c0..00000000 --- a/frontend/store/Dashboard/DashboardCharts.tsx +++ /dev/null @@ -1,308 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { - PieChart, - Pie, - Cell, - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, - Legend, -} from 'recharts'; -import { ReportsSummary } from '@/lib/api/reports'; -import { AssetStatus } from '@/lib/query/types/asset'; - -// ─── Color palettes ─────────────────────────────────────────────────────────── -const STATUS_COLORS: Record = { - [AssetStatus.ACTIVE]: '#22c55e', - [AssetStatus.ASSIGNED]: '#3b82f6', - [AssetStatus.MAINTENANCE]: '#f59e0b', - [AssetStatus.RETIRED]: '#9ca3af', -}; - -const CATEGORY_COLORS = [ - '#6366f1', '#ec4899', '#14b8a6', '#f97316', - '#8b5cf6', '#06b6d4', '#84cc16', '#ef4444', -]; - -// ─── Accessible toggle ──────────────────────────────────────────────────────── -function ChartToggle({ - showTable, - onToggle, - chartId, -}: { - showTable: boolean; - onToggle: () => void; - chartId: string; -}) { - return ( - - ); -} - -// ─── Status Distribution Donut ──────────────────────────────────────────────── -function StatusDonut({ byStatus }: { byStatus: Record }) { - const [showTable, setShowTable] = useState(false); - - const data = Object.entries(byStatus).map(([name, value]) => ({ name, value })); - - return ( -
-
-

Status Distribution

- setShowTable((v) => !v)} - chartId="status-donut-table" - /> -
- - {showTable ? ( - - - - - - - - - {data.map(({ name, value }) => ( - - - - - ))} - -
StatusCount
{name.toLowerCase()}{value}
- ) : ( - - - - {data.map(({ name }) => ( - - ))} - - ( - - {value.toLowerCase()} - - )} - /> - [value ?? 0, (name ?? '').toLowerCase()]} - /> - - - )} -
- ); -} - -// ─── Category Bar Chart ─────────────────────────────────────────────────────── -function CategoryBar({ - byCategory, -}: { - byCategory: { name: string; count: number }[]; -}) { - const [showTable, setShowTable] = useState(false); - - const data = [...byCategory] - .sort((a, b) => b.count - a.count) - .slice(0, 8) - .map((item, i) => ({ ...item, fill: CATEGORY_COLORS[i % CATEGORY_COLORS.length] })); - - return ( -
-
-

Assets by Category

- setShowTable((v) => !v)} - chartId="category-bar-table" - /> -
- - {showTable ? ( - - - - - - - - - {data.map(({ name, count }) => ( - - - - - ))} - -
CategoryCount
{name}{count}
- ) : data.length === 0 ? ( -

No category data

- ) : ( - - - - - [value ?? 0, 'Assets']} - /> - - {data.map(({ name, fill }) => ( - - ))} - - - - )} -
- ); -} - -// ─── Department Bar Chart ───────────────────────────────────────────────────── -function DepartmentBar({ - byDepartment, -}: { - byDepartment: { name: string; count: number }[]; -}) { - const [showTable, setShowTable] = useState(false); - - const data = [...byDepartment] - .sort((a, b) => b.count - a.count) - .slice(0, 8); - - return ( -
-
-

Assets by Department

- setShowTable((v) => !v)} - chartId="dept-bar-table" - /> -
- - {showTable ? ( - - - - - - - - - {data.map(({ name, count }) => ( - - - - - ))} - -
DepartmentCount
{name}{count}
- ) : data.length === 0 ? ( -

No department data

- ) : ( - - - - - [value ?? 0, 'Assets']} - /> - - - - )} -
- ); -} - -// ─── Main Export ────────────────────────────────────────────────────────────── -interface Props { - data: ReportsSummary; -} - -export default function DashboardCharts({ data }: Props) { - const { byStatus, byCategory, byDepartment } = data; - return ( -
-
- - - -
-
- ); -} diff --git a/frontend/store/Dashboard/DateRangeSelector.tsx b/frontend/store/Dashboard/DateRangeSelector.tsx deleted file mode 100644 index 3f1d4e92..00000000 --- a/frontend/store/Dashboard/DateRangeSelector.tsx +++ /dev/null @@ -1,128 +0,0 @@ -'use client'; - -import { useCallback } from 'react'; -import { useRouter, usePathname, useSearchParams } from 'next/navigation'; -import { Calendar } from 'lucide-react'; - -export type DateRangePreset = '7d' | '30d' | '90d' | '1y' | 'custom'; - -const PRESETS: { label: string; value: DateRangePreset }[] = [ - { label: '7 days', value: '7d' }, - { label: '30 days', value: '30d' }, - { label: '90 days', value: '90d' }, - { label: '1 year', value: '1y' }, -]; - -interface DateRangeSelectorProps { - /** Called when the range changes. `from` and `to` are ISO date strings. */ - onChange?: (range: { from: string; to: string }) => void; -} - -/** - * Date range selector that reflects its state in URL search params - * (?from=YYYY-MM-DD&to=YYYY-MM-DD&preset=30d). - * Views are shareable and survive page reload. - */ -export function DateRangeSelector({ onChange }: DateRangeSelectorProps) { - const router = useRouter(); - const pathname = usePathname(); - const params = useSearchParams(); - - const currentPreset = (params.get('preset') as DateRangePreset) ?? '30d'; - const fromParam = params.get('from') ?? ''; - const toParam = params.get('to') ?? ''; - - /** Compute ISO dates from a preset string */ - const presetToDates = useCallback((preset: DateRangePreset): { from: string; to: string } => { - const to = new Date(); - const from = new Date(); - const map: Record, number> = { - '7d': 7, - '30d': 30, - '90d': 90, - '1y': 365, - }; - if (preset !== 'custom') { - from.setDate(from.getDate() - map[preset]); - } - const fmt = (d: Date) => d.toISOString().split('T')[0]; - return { from: fmt(from), to: fmt(to) }; - }, []); - - const pushParams = useCallback( - (preset: DateRangePreset, from: string, to: string) => { - const sp = new URLSearchParams(Array.from(params.entries())); - sp.set('preset', preset); - sp.set('from', from); - sp.set('to', to); - router.push(`${pathname}?${sp.toString()}`); - onChange?.({ from, to }); - }, - [params, pathname, router, onChange], - ); - - const handlePreset = (preset: DateRangePreset) => { - const { from, to } = presetToDates(preset); - pushParams(preset, from, to); - }; - - const handleCustomFrom = (e: React.ChangeEvent) => { - pushParams('custom', e.target.value, toParam || new Date().toISOString().split('T')[0]); - }; - - const handleCustomTo = (e: React.ChangeEvent) => { - pushParams('custom', fromParam, e.target.value); - }; - - const computedValue = currentPreset === 'custom' - ? { from: fromParam, to: toParam } - : presetToDates(currentPreset); - - return ( -
-
- ); -}