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

View File

@@ -1,19 +1,16 @@
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";
export default function BottomNavigation() {
const location = useRouterState({
select: (state) => state.location,
})
console.log(location.pathname)
});
const tabs = [
{ path: "/", icon: Home, label: "Dashboard", disabled: false },
{ 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 },
];
@@ -24,7 +21,11 @@ console.log(location.pathname)
<Link
key={key}
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} />
<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,
apiKey: import.meta.env.VITE_SIGN_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: "",
floor: "",
unit: "",
credential: "",
});
const [loading, setLoading] = useState<boolean>(false);
function isObjectValueDifferent(
a: MerchantForm,
b: MerchantForm
): boolean {
function isObjectValueDifferent(a: MerchantForm, b: MerchantForm): boolean {
return (Object.keys(a) as (keyof MerchantForm)[]).some(
(key) => a[key] !== b[key]
(key) => a[key] !== b[key],
);
}
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target;
@@ -31,21 +29,18 @@ function isObjectValueDifferent(
}));
}
function handleSubmit(e: React.FormEvent) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const merchant = getMerchant();
if(merchant){
if (merchant) {
const isDifferent = isObjectValueDifferent(merchant, form);
if (isDifferent) {
clearDevice(); // reset device kalau merchant berubah
}
}
setLoading(true);
saveMerchant(form);
@@ -53,7 +48,7 @@ function handleSubmit(e: React.FormEvent) {
setLoading(false);
navigate({ to: "/" });
}, 200);
}
}
useEffect(() => {
const merchant = getMerchant();
@@ -119,6 +114,18 @@ function handleSubmit(e: React.FormEvent) {
className="p-2 border border-neutral-200 w-full rounded-md"
/>
</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>

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 { basicAuth } from "../../utils/basic-auth";
export const getDeviceStatus = async (params: DeviceParams): Promise<Device> => {
const res = await api.get('/device/v1/status', { params });
export const getDeviceStatus = async (
params: DeviceParams,
): Promise<Device> => {
const res = await api.get("/device/v1/status", { params });
return res.data;
};
export const postCommandStatus = async (payload: DevicePayload): Promise<Device> => {
const res = await api.post('/device/v1/command', payload);
export const postCommandStatus = async (
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;
};

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ export const AppRoute = createRoute({
getParentRoute: () => RootRoute,
id: "general",
component: lazyRouteComponent(
() => import("../components/layout/main.layout")
() => import("../components/layout/main.layout"),
),
});
@@ -32,3 +32,9 @@ export const SettingsRoute = createRoute({
path: "/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,
RoomsRoute,
SettingsRoute,
TopicsRoute,
} from "./app.route";
const routeTree = RootRoute.addChildren([
@@ -22,6 +23,7 @@ const routeTree = RootRoute.addChildren([
RoomsRoute,
DetailDeviceRoute,
SettingsRoute,
TopicsRoute,
]),
]);

View File

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

View File

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