feat(topics) - add list topic

This commit is contained in:
2026-02-06 15:32:46 +07:00
parent 2ce6a4ab8c
commit 2172614789
15 changed files with 188 additions and 86 deletions

View File

@@ -25,35 +25,41 @@ export default function NavigationLayout({
action, action,
mainClassName, mainClassName,
bgImage, bgImage,
backText = 'back' backText = "back",
}: NavigationLayoutProps) { }: NavigationLayoutProps) {
const router = useRouter(); const router = useRouter();
return ( return (
<div <div
className={cn( className={cn(
"bg-linear-to-b from-gray-1 to-gray-2 h-full flex flex-col relative overflow-y-auto", "bg-linear-to-b from-gray-1 to-gray-2 h-full flex flex-col relative overflow-y-auto",
className, className,
bgImage && 'bg-cover bg-top-left' bgImage && "bg-cover bg-top-left",
)} )}
style={{ style={{
...(bgImage ? { backgroundImage: `url(${bgImage})` } : {}) ...(bgImage ? { backgroundImage: `url(${bgImage})` } : {}),
}} }}
> >
{ bgImage && <div className="absolute left-0 top-0 h-full w-full bg-dark-1 opacity-20"/> } {bgImage && (
<div className="absolute left-0 top-0 h-full w-full bg-dark-1 opacity-20" />
)}
{title && ( {title && (
<div className="text-base font-bold text-neutral-900 w-full text-center p-4"> <div className="text-base font-bold text-neutral-900 w-full text-center p-4">
{title} {title}
</div> </div>
)} )}
<div className={cn('flex items-center justify-between relative w-full', title && 'absolute w-full left-0 top-0')}> <div
className={cn(
"flex items-center justify-between relative w-full",
title && "absolute w-full left-0 top-0",
)}
>
{isBack && ( {isBack && (
<span <span
onClick={() => router.history.back()} onClick={() => router.history.back()}
className={cn( className={cn(
"flex justify-start shrink-0 items-center gap-2 text-base font-semibold cursor-pointer w-fit p-4", "flex justify-start shrink-0 items-center gap-2 text-base font-semibold cursor-pointer w-fit p-4",
backClassName backClassName,
)} )}
> >
<ChevronLeft size={24} /> <ChevronLeft size={24} />
@@ -66,7 +72,7 @@ export default function NavigationLayout({
className={cn( className={cn(
"h-full relative pt-18", "h-full relative pt-18",
isBottomNav && "overflow-y-auto", isBottomNav && "overflow-y-auto",
mainClassName mainClassName,
)} )}
> >
{children} {children}

View File

@@ -1,19 +1,16 @@
import { Link, useRouterState } from "@tanstack/react-router"; import { Link, useRouterState } from "@tanstack/react-router";
import { BarChart3, DoorOpen, Home, Settings } from "lucide-react"; import { BarChart3, Home, ListCheck, Settings } from "lucide-react";
import { cn } from "../../../utils/classname"; import { cn } from "../../../utils/classname";
export default function BottomNavigation() { export default function BottomNavigation() {
const location = useRouterState({ const location = useRouterState({
select: (state) => state.location, select: (state) => state.location,
}) });
console.log(location.pathname)
const tabs = [ const tabs = [
{ path: "/", icon: Home, label: "Dashboard", disabled: false }, { path: "/", icon: Home, label: "Dashboard", disabled: false },
{ path: "/analysis", icon: BarChart3, label: "analysis", disabled: true }, { path: "/analysis", icon: BarChart3, label: "analysis", disabled: true },
{ path: "/rooms", icon: DoorOpen, label: "Rooms", disabled: true }, { path: "/topics", icon: ListCheck, label: "Topics", disabled: false },
{ path: "/settings", icon: Settings, label: "Settings", disabled: false }, { path: "/settings", icon: Settings, label: "Settings", disabled: false },
]; ];
@@ -24,7 +21,11 @@ console.log(location.pathname)
<Link <Link
key={key} key={key}
to={tab.path} to={tab.path}
className={cn('flex-1 py-4 flex flex-col items-center gap-1 transition-colors text-neutral-900 disabled:text-neutral-300', tab.path === location.pathname && 'text-orange-500', tab.disabled && 'pointer-events-none text-neutral-300')} className={cn(
"flex-1 py-4 flex flex-col items-center gap-1 transition-colors text-neutral-900 disabled:text-neutral-300",
tab.path === location.pathname && "text-orange-500",
tab.disabled && "pointer-events-none text-neutral-300",
)}
> >
<tab.icon size={24} /> <tab.icon size={24} />
<span className="text-xs font-medium">{tab.label}</span> <span className="text-xs font-medium">{tab.label}</span>

View File

@@ -2,4 +2,6 @@ export const ENV = {
apiUrl: import.meta.env.VITE_API_URL, apiUrl: import.meta.env.VITE_API_URL,
apiKey: import.meta.env.VITE_SIGN_KEY, apiKey: import.meta.env.VITE_SIGN_KEY,
secretKey: import.meta.env.VITE_SIGN_SECRET_KEY, secretKey: import.meta.env.VITE_SIGN_SECRET_KEY,
} basicUsername: import.meta.env.VITE_BASIC_AUTH_USERNAME,
basicPassword: import.meta.env.VITE_BASIC_AUTH_PASSWORD,
};

View File

@@ -11,17 +11,15 @@ export default function SettingsFeature() {
tower: "", tower: "",
floor: "", floor: "",
unit: "", unit: "",
credential: "",
}); });
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
function isObjectValueDifferent( function isObjectValueDifferent(a: MerchantForm, b: MerchantForm): boolean {
a: MerchantForm, return (Object.keys(a) as (keyof MerchantForm)[]).some(
b: MerchantForm (key) => a[key] !== b[key],
): boolean { );
return (Object.keys(a) as (keyof MerchantForm)[]).some( }
(key) => a[key] !== b[key]
);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target; const { name, value } = e.target;
@@ -31,30 +29,27 @@ function isObjectValueDifferent(
})); }));
} }
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const merchant = getMerchant(); const merchant = getMerchant();
if(merchant){ if (merchant) {
const isDifferent = isObjectValueDifferent(merchant, form); const isDifferent = isObjectValueDifferent(merchant, form);
if (isDifferent) { if (isDifferent) {
clearDevice(); // reset device kalau merchant berubah clearDevice(); // reset device kalau merchant berubah
}
} }
setLoading(true);
saveMerchant(form);
setTimeout(() => {
setLoading(false);
navigate({ to: "/" });
}, 200);
} }
setLoading(true);
saveMerchant(form);
setTimeout(() => {
setLoading(false);
navigate({ to: "/" });
}, 200);
}
useEffect(() => { useEffect(() => {
const merchant = getMerchant(); const merchant = getMerchant();
if (merchant) { if (merchant) {
@@ -119,6 +114,18 @@ function handleSubmit(e: React.FormEvent) {
className="p-2 border border-neutral-200 w-full rounded-md" className="p-2 border border-neutral-200 w-full rounded-md"
/> />
</div> </div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Credential
</label>
<input
name="credential"
value={form.credential}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
/>
</div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { getTopicsState } from "../../../repositories/device";
export function useTopics() {
const result = useQuery({
queryKey: ["topics/state"],
queryFn: () => getTopicsState(),
});
return {
...result,
data: result?.data?.data,
};
}

View File

@@ -0,0 +1,28 @@
import { Edit, PlusIcon, Trash2 } from "lucide-react";
import { useTopics } from "./hooks/queries";
export default function TopicsFeature() {
const { data } = useTopics();
return (
<div className="flex flex-col gap-4">
<div className="p-4 flex justify-end">
<button className="shrink-0 px-4 py-2 rounded-md flex items-center justify-center transition-all bg-orange-500 text-white cursor-pointer">
<PlusIcon />
Tambah Topic
</button>
</div>
{data?.map((item, key) => (
<div
key={key}
className="bg-white border-b border-neutral-200 p-4 flex justify-between"
>
<h1>{item.topic}</h1>
<div className="flex gap-2 ">
<Edit className="text-blue-500 cursor-pointer" size={18} />
<Trash2 className="text-red-500 cursor-pointer" size={18} />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import NavigationLayout from "../../components/layout/navigation.layout";
import TopicsFeature from "../../features/topics";
export default function SettingsPage() {
return (
<NavigationLayout
title="Topics"
mainClassName="pt-2"
className="from-white to-white"
isBack
>
<TopicsFeature />
</NavigationLayout>
);
}

View File

@@ -1,11 +1,27 @@
import { ENV } from "../../constants/env";
import api from "../../utils/axios"; import api from "../../utils/axios";
import { basicAuth } from "../../utils/basic-auth";
export const getDeviceStatus = async (params: DeviceParams): Promise<Device> => { export const getDeviceStatus = async (
const res = await api.get('/device/v1/status', { params }); params: DeviceParams,
): Promise<Device> => {
const res = await api.get("/device/v1/status", { params });
return res.data; return res.data;
}; };
export const postCommandStatus = async (payload: DevicePayload): Promise<Device> => { export const postCommandStatus = async (
const res = await api.post('/device/v1/command', payload); payload: DevicePayload,
): Promise<Device> => {
const res = await api.post("/device/v1/command", payload);
return res.data;
};
export const getTopicsState = async (): Promise<TopicData> => {
const res = await api.get("/topics/v1/commands", {
headers: {
Authorization: basicAuth(ENV.basicUsername, ENV.basicPassword),
},
});
return res.data; return res.data;
}; };

View File

@@ -5,21 +5,21 @@ type Device = {
}; };
type DeviceData = { type DeviceData = {
id?: string, id?: string;
floorName?: string; floorName?: string;
unitNumber?: string; unitNumber?: string;
deviceName?: string; deviceName?: string;
roomName?: string; roomName?: string;
deviceType?: string; deviceType?: string;
code?: string; code?: string;
towerNumber?: string; towerNumber?: string;
payload?: string; payload?: string;
deviceLabel?: string deviceLabel?: string;
deviceName?: string deviceName?: string;
active?: boolean active?: boolean;
status?: boolean status?: boolean;
refetch?: () => void refetch?: () => void;
}; };
type DeviceParams = { type DeviceParams = {
merchantName?: string; merchantName?: string;
@@ -33,15 +33,21 @@ type DeviceParams = {
}; };
type DevicePayload = { type DevicePayload = {
commandType?: string, commandType?: string;
deviceName?: string, deviceName?: string;
deviceType?: string, deviceType?: string;
floorName?: string, floorName?: string;
merchantName?: string, merchantName?: string;
payload?: { payload?: {
action?: string action?: string;
}, };
roomName?: string, roomName?: string;
towerNumber?: string, towerNumber?: string;
unitNumber?: string, unitNumber?: string;
} };
type TopicData = {
status: string;
message: string;
data: { topic: string }[];
};

View File

@@ -5,7 +5,7 @@ export const AppNavigationRoute = createRoute({
getParentRoute: () => RootRoute, getParentRoute: () => RootRoute,
id: "app", id: "app",
component: lazyRouteComponent( component: lazyRouteComponent(
() => import("../components/layout/main-navigation.layout") () => import("../components/layout/main-navigation.layout"),
), ),
}); });
@@ -20,5 +20,3 @@ export const AnalysisRoute = createRoute({
path: "/analysis", path: "/analysis",
component: lazyRouteComponent(() => import("../pages/analysis")), component: lazyRouteComponent(() => import("../pages/analysis")),
}); });

View File

@@ -5,7 +5,7 @@ export const AppRoute = createRoute({
getParentRoute: () => RootRoute, getParentRoute: () => RootRoute,
id: "general", id: "general",
component: lazyRouteComponent( component: lazyRouteComponent(
() => import("../components/layout/main.layout") () => import("../components/layout/main.layout"),
), ),
}); });
@@ -32,3 +32,9 @@ export const SettingsRoute = createRoute({
path: "/settings", path: "/settings",
component: lazyRouteComponent(() => import("../pages/settings")), component: lazyRouteComponent(() => import("../pages/settings")),
}); });
export const TopicsRoute = createRoute({
getParentRoute: () => AppRoute,
path: "/topics",
component: lazyRouteComponent(() => import("../pages/topics")),
});

View File

@@ -12,6 +12,7 @@ import {
DetailDeviceRoute, DetailDeviceRoute,
RoomsRoute, RoomsRoute,
SettingsRoute, SettingsRoute,
TopicsRoute,
} from "./app.route"; } from "./app.route";
const routeTree = RootRoute.addChildren([ const routeTree = RootRoute.addChildren([
@@ -22,6 +23,7 @@ const routeTree = RootRoute.addChildren([
RoomsRoute, RoomsRoute,
DetailDeviceRoute, DetailDeviceRoute,
SettingsRoute, SettingsRoute,
TopicsRoute,
]), ]),
]); ]);

View File

@@ -3,4 +3,5 @@ type MerchantForm = {
tower: string; tower: string;
floor: string; floor: string;
unit: string; unit: string;
credential: string;
}; };

View File

@@ -6,8 +6,7 @@ import { ENV } from "../constants/env";
import type { InternalAxiosRequestConfig } from "axios"; import type { InternalAxiosRequestConfig } from "axios";
interface SignedAxiosRequestConfig interface SignedAxiosRequestConfig extends InternalAxiosRequestConfig {
extends InternalAxiosRequestConfig {
signed?: boolean; signed?: boolean;
} }
@@ -16,7 +15,6 @@ const api = axios.create({
timeout: 15000, timeout: 15000,
}); });
api.interceptors.request.use( api.interceptors.request.use(
(config: SignedAxiosRequestConfig) => { (config: SignedAxiosRequestConfig) => {
if (config.signed === false) { if (config.signed === false) {
@@ -50,10 +48,9 @@ api.interceptors.request.use(
return config; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error),
); );
// RESPONSE INTERCEPTOR (Optional) // RESPONSE INTERCEPTOR (Optional)
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
@@ -62,7 +59,7 @@ api.interceptors.response.use(
console.warn("Unauthorized"); console.warn("Unauthorized");
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
export default api; export default api;

3
src/utils/basic-auth.ts Normal file
View File

@@ -0,0 +1,3 @@
export const basicAuth = (username: string, password: string) => {
return "Basic " + btoa(`${username}:${password}`);
};