diff --git a/frontend/app/(dashboard)/assets/[id]/page.tsx b/frontend/app/(dashboard)/assets/[id]/page.tsx index a68560ac..56634b62 100644 --- a/frontend/app/(dashboard)/assets/[id]/page.tsx +++ b/frontend/app/(dashboard)/assets/[id]/page.tsx @@ -27,6 +27,7 @@ import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { EditAssetModal } from "@/components/assets/edit-asset-modal"; +import { InlineEdit } from "@/components/assets/inline-edit"; import { PhotoGallery } from "@/components/assets/photo-gallery"; import { useAsset, @@ -383,7 +384,12 @@ export default function AssetDetailPage() { )}
- + { /* TODO: call API */ }} + display={} + />
@@ -468,9 +474,17 @@ export default function AssetDetailPage() { { /* TODO: call API */ }} + display={ + asset.currentValue != null + ? `$${Number(asset.currentValue).toLocaleString()}` + : "โ€”" + } + /> } /> = { + PENDING: "bg-yellow-100 text-yellow-700", + CONFIRMED: "bg-green-100 text-green-700", + CANCELLED: "bg-gray-100 text-gray-500", + COMPLETED: "bg-blue-100 text-blue-700", +}; + +type Tab = "my" | "pending"; + +export default function ReservationsPage() { + const [tab, setTab] = useState("my"); + const [reservations, setReservations] = useState(MOCK); + const [showBooking, setShowBooking] = useState(false); + + const myReservations = reservations; + const pendingConfirmations = reservations.filter((r) => r.status === "PENDING"); + const currentList = tab === "my" ? myReservations : pendingConfirmations; + + const handleConfirm = (id: string) => { + setReservations((prev) => prev.map((r) => r.id === id ? { ...r, status: "CONFIRMED" as const } : r)); + }; + + const handleCancel = (id: string) => { + setReservations((prev) => prev.map((r) => r.id === id ? { ...r, status: "CANCELLED" as const } : r)); + }; + + return ( +
+
+
+

Reservations

+

Book shared equipment and manage reservations

+
+ +
+ +
+ + +
+ +
+ {currentList.length === 0 ? ( +
+ +

No reservations

+
+ ) : ( + currentList.map((r) => ( +
+
+
+
+

{r.assetName}

+ {r.status} +
+

{r.purpose}

+

+ {new Date(r.startsAt).toLocaleDateString()} {new Date(r.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + {" โ€” "} + {new Date(r.endsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + {" ยท "}Requested by {r.requester} +

+
+
+ {tab === "pending" && r.status === "PENDING" && ( + <> + + + + )} + {tab === "my" && r.status === "PENDING" && ( + + )} +
+
+
+ )) + )} +
+ + {showBooking && ( +
+
setShowBooking(false)} /> +
+

New Reservation

+
+ + +
+
+
+
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index d905c77a..45ec2163 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -16,6 +16,8 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "Assets Up", description: "Modern Assets and Inventory Management Platform", + manifest: "/manifest.json", + themeColor: "#111827", }; export default function RootLayout({ diff --git a/frontend/components/assets/depreciation-schedule.tsx b/frontend/components/assets/depreciation-schedule.tsx new file mode 100644 index 00000000..04ad03f6 --- /dev/null +++ b/frontend/components/assets/depreciation-schedule.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState } from "react"; +import { TrendingDown, DollarSign, BarChart3 } from "lucide-react"; + +interface DepreciationEntry { + period: number; + openingValue: number; + depreciation: number; + closingValue: number; +} + +interface DepreciationData { + method: string; + usefulLifeMonths: number; + salvageValue: number; + purchaseCost: number; + currentValue: number; + schedule: DepreciationEntry[]; +} + +const MOCK_DATA: DepreciationData = { + method: "STRAIGHT_LINE", + usefulLifeMonths: 60, + salvageValue: 500, + purchaseCost: 5000, + currentValue: 3200, + schedule: Array.from({ length: 12 }, (_, i) => ({ + period: i + 1, + openingValue: 5000 - i * 375, + depreciation: 375, + closingValue: 5000 - (i + 1) * 375, + })), +}; + +export function DepreciationSchedule({ data = MOCK_DATA }: { data?: DepreciationData }) { + const [expanded, setExpanded] = useState(false); + const visibleSchedule = expanded ? data.schedule : data.schedule.slice(0, 6); + + return ( +
+

Depreciation Schedule

+ + {/* Stats */} +
+
+

Purchase Cost

+

${data.purchaseCost.toLocaleString()}

+
+
+

Current Book Value

+

${data.currentValue.toLocaleString()}

+
+
+

Salvage Value

+

${data.salvageValue.toLocaleString()}

+
+
+ + {/* Simple bar chart */} +
+

Book Value Over Time

+
+ {data.schedule.map((entry) => { + const height = (entry.closingValue / data.purchaseCost) * 100; + return ( +
+
+ {entry.period} +
+ ); + })} +
+
+ + {/* Table */} +
+ + + + + + + + + + + {visibleSchedule.map((entry) => ( + + + + + + + ))} + +
PeriodOpeningDepreciationClosing
{entry.period}${entry.openingValue.toLocaleString()}-${entry.depreciation.toLocaleString()}${entry.closingValue.toLocaleString()}
+
+ {data.schedule.length > 6 && ( + + )} +
+ ); +} + +export function DepreciationFinancialsTab() { + return ( +
+
+
+
+ + Total Purchase Value +
+

$125,000

+
+
+
+ + Current Book Value +
+

$87,500

+
+
+
+ + Monthly Depreciation +
+

$2,083

+
+
+ + {/* Category breakdown placeholder */} +
+

Book Value by Category

+
+ {[ + { name: "Electronics", value: 45000, pct: 51 }, + { name: "Furniture", value: 22000, pct: 25 }, + { name: "Vehicles", value: 15500, pct: 18 }, + { name: "Other", value: 5000, pct: 6 }, + ].map((cat) => ( +
+
+ {cat.name} + ${cat.value.toLocaleString()} +
+
+
+
+
+ ))} +
+
+ + +
+ ); +} diff --git a/frontend/components/assets/inline-edit.tsx b/frontend/components/assets/inline-edit.tsx new file mode 100644 index 00000000..4cb9a713 --- /dev/null +++ b/frontend/components/assets/inline-edit.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Pencil, Check, X } from "lucide-react"; + +interface InlineEditProps { + value: string | number | null | undefined; + label: string; + type?: "text" | "number"; + onSave: (value: string | number) => Promise; + display?: React.ReactNode; +} + +export function InlineEdit({ value, label, type = "text", onSave, display }: InlineEditProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value ?? ""); + const [saving, setSaving] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.focus(); + }, [editing]); + + const handleSave = async () => { + setSaving(true); + try { + const val = type === "number" ? Number(draft) : draft; + await onSave(val); + setEditing(false); + } catch { + setDraft(value ?? ""); + } finally { + setSaving(false); + } + }; + + if (!editing) { + return ( + + ); + } + + return ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") { setEditing(false); setDraft(value ?? ""); } }} + className="w-32 px-2 py-1 text-sm border rounded focus:ring-1 focus:ring-blue-500" + /> + + +
+ ); +} diff --git a/frontend/components/qr-scanner.tsx b/frontend/components/qr-scanner.tsx new file mode 100644 index 00000000..66592c09 --- /dev/null +++ b/frontend/components/qr-scanner.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Camera, X, Keyboard } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface QRScannerProps { + onScan: (assetId: string) => void; + onClose: () => void; +} + +export function QRScanner({ onScan, onClose }: QRScannerProps) { + const videoRef = useRef(null); + const [mode, setMode] = useState<"camera" | "manual">("camera"); + const [manualId, setManualId] = useState(""); + const [error, setError] = useState(""); + const [scanning, setScanning] = useState(false); + + useEffect(() => { + if (mode !== "camera") return; + let stream: MediaStream | null = null; + + const startCamera = async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + setScanning(true); + // Try BarcodeDetector API + if ("BarcodeDetector" in window) { + const detector = new (window as any).BarcodeDetector({ formats: ["qr_code"] }); + const detect = async () => { + if (!videoRef.current || !scanning) return; + try { + const barcodes = await detector.detect(videoRef.current); + if (barcodes.length > 0) { + const value = barcodes[0].rawValue; + const match = value.match(/\/scan\/([a-zA-Z0-9-]+)/); + onScan(match ? match[1] : value); + return; + } + } catch {} + requestAnimationFrame(detect); + }; + detect(); + } + } + } catch { + setError("Camera access denied. Use manual entry instead."); + setMode("manual"); + } + }; + + startCamera(); + return () => { + setScanning(false); + stream?.getTracks().forEach((t) => t.stop()); + }; + }, [mode, onScan]); + + return ( +
+
+

Scan Asset QR

+ +
+ + {mode === "camera" ? ( +
+
+ ); +} diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 00000000..dc3b405c --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "AssetsUp", + "short_name": "AssetsUp", + "description": "Modern Assets and Inventory Management Platform", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#111827", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..80627a18 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,33 @@ +const CACHE_NAME = "assetsup-v1"; +const APP_SHELL = ["/dashboard", "/assets", "/manifest.json"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + // API requests: network-first + if (request.url.includes("/api/")) { + event.respondWith( + fetch(request).catch(() => caches.match(request)) + ); + return; + } + // App shell: cache-first + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request)) + ); +});