+
+
+
Reservations
+
Book shared equipment and manage reservations
+
+
+
+
+
+
+
+
+
+
+ {currentList.length === 0 ? (
+
+ ) : (
+ 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 (
+
+ );
+ })}
+
+
+
+ {/* Table */}
+
+
+
+
+ | Period |
+ Opening |
+ Depreciation |
+ Closing |
+
+
+
+ {visibleSchedule.map((entry) => (
+
+ | {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" ? (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ ) : (
+
+
+ setManualId(e.target.value)}
+ className="w-full px-4 py-3 rounded-lg text-lg text-center"
+ autoFocus
+ />
+
+
+
+ )}
+
+
+
+
+
+ );
+}
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))
+ );
+});