diff --git a/src/constants/env.ts b/src/constants/env.ts index e751f69..850a599 100644 --- a/src/constants/env.ts +++ b/src/constants/env.ts @@ -4,4 +4,5 @@ export const ENV = { secretKey: import.meta.env.VITE_SIGN_SECRET_KEY, basicUsername: import.meta.env.VITE_BASIC_AUTH_USERNAME, basicPassword: import.meta.env.VITE_BASIC_AUTH_PASSWORD, + credential: import.meta.env.VITE_CREDENTIAL, }; diff --git a/src/features/settings/index.tsx b/src/features/settings/index.tsx index 25ad484..18c8942 100644 --- a/src/features/settings/index.tsx +++ b/src/features/settings/index.tsx @@ -16,9 +16,13 @@ export default function SettingsFeature() { const [loading, setLoading] = useState(false); function isObjectValueDifferent(a: MerchantForm, b: MerchantForm): boolean { - return (Object.keys(a) as (keyof MerchantForm)[]).some( - (key) => a[key] !== b[key], - ); + const keys: (keyof MerchantForm)[] = [ + "merchantName", + "tower", + "floor", + "unit", + ]; + return keys.some((key) => a[key] !== b[key]); } function handleChange(e: React.ChangeEvent) { diff --git a/src/features/topics/hooks/mutations.ts b/src/features/topics/hooks/mutations.ts new file mode 100644 index 0000000..41df073 --- /dev/null +++ b/src/features/topics/hooks/mutations.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createTopic, deleteTopic } from "../../../repositories/device"; + +export function useCreateTopic() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: TopicPayload) => createTopic(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["topics/state"] }); + queryClient.invalidateQueries({ queryKey: ["topics/command"] }); + }, + }); +} + +export function useDeleteTopic() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: TopicPayload) => deleteTopic(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["topics/state"] }); + queryClient.invalidateQueries({ queryKey: ["topics/command"] }); + }, + }); +} diff --git a/src/features/topics/index.tsx b/src/features/topics/index.tsx index a23ed39..a6fee95 100644 --- a/src/features/topics/index.tsx +++ b/src/features/topics/index.tsx @@ -1,15 +1,100 @@ import { PlusIcon } from "lucide-react"; -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { cn } from "../../utils/classname"; import StateSection from "./sections/state"; import CommandSection from "./sections/command"; +import { useCreateTopic } from "./hooks/mutations"; +import { getMerchant } from "../../utils/storage"; +import { ENV } from "../../constants/env"; export default function TopicsFeature() { const [tab, setTab] = useState("state"); + const [isOpen, setIsOpen] = useState(false); + const [form, setForm] = useState({ + topic: "", + type: "state-reply", + }); + const [error, setError] = useState(""); + const createTopic = useCreateTopic(); + const merchant = getMerchant(); + const expectedCredential = ENV.credential?.trim(); + const hasCredential = Boolean(merchant?.credential?.trim()); + const isCredentialMatch = + Boolean(expectedCredential) && + merchant?.credential?.trim() === expectedCredential; + const isMerchantSetup = + Boolean(merchant?.merchantName?.trim()) && + Boolean(merchant?.tower?.trim()) && + Boolean(merchant?.floor?.trim()) && + Boolean(merchant?.unit?.trim()); + const isReady = isMerchantSetup && hasCredential && isCredentialMatch; + + const handleOpen = () => { + setError(""); + setForm({ + topic: "", + type: tab === "state" ? "state-reply" : "commands", + }); + setIsOpen(true); + }; + + const handleClose = () => { + if (createTopic.isPending) return; + setIsOpen(false); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const topic = form.topic.trim(); + if (!topic) { + setError("Topic wajib diisi."); + return; + } + + createTopic.mutate( + { topic, type: form.type }, + { + onSuccess: () => { + setIsOpen(false); + setForm({ + topic: "", + type: tab === "state" ? "state-reply" : "commands", + }); + }, + onError: () => { + setError("Gagal menambah topic."); + }, + }, + ); + }; + return (
+ {!isReady && ( +
+ {!isMerchantSetup && ( + + Silakan setup merchant terlebih dahulu di halaman Settings sebelum + mengakses Topics. + + )} + {isMerchantSetup && !hasCredential && ( + + Silakan isi credential terlebih dahulu di halaman Settings sebelum + mengakses Topics. + + )} + {isMerchantSetup && hasCredential && !isCredentialMatch && ( + Credential tidak sesuai. + )} +
+ )}
- @@ -36,6 +121,71 @@ export default function TopicsFeature() {
{tab === "state" && } {tab === "command" && } + {isOpen && ( +
+
+
+

Tambah Topic

+ +
+
+ + + {error && ( +

{error}

+ )} +
+ + +
+
+
+
+ )}
); } diff --git a/src/features/topics/sections/command.tsx b/src/features/topics/sections/command.tsx index 2de6bde..c51d512 100644 --- a/src/features/topics/sections/command.tsx +++ b/src/features/topics/sections/command.tsx @@ -1,10 +1,57 @@ +import { useState } from "react"; import { useTopicCommand } from "../hooks/queries"; import { Edit, Trash2 } from "lucide-react"; +import { useDeleteTopic } from "../hooks/mutations"; + +type PendingDelete = { + open: boolean; + topic: string; +}; export default function CommandSection() { - const { data } = useTopicCommand(); + const { data, isLoading } = useTopicCommand(); + const deleteTopic = useDeleteTopic(); + const [pending, setPending] = useState({ + open: false, + topic: "", + }); + + const handleDelete = (topic: string) => { + if (deleteTopic.isPending) return; + setPending({ open: true, topic }); + }; + + const handleConfirmDelete = () => { + if (!pending.topic || deleteTopic.isPending) return; + deleteTopic.mutate( + { topic: pending.topic, type: "commands" }, + { + onSuccess: () => setPending({ open: false, topic: "" }), + onError: () => setPending({ open: false, topic: "" }), + }, + ); + }; + + const handleClose = () => { + if (deleteTopic.isPending) return; + setPending({ open: false, topic: "" }); + }; + return ( <> + {isLoading && + Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} {data?.map((item, key) => (

{item.topic}

- - + +
))} + {pending.open && ( +
+
+

Hapus Topic

+

+ Yakin ingin menghapus topic "{pending.topic}"? +

+
+ + +
+
+
+ )} ); } diff --git a/src/features/topics/sections/state.tsx b/src/features/topics/sections/state.tsx index 685cd42..2111c73 100644 --- a/src/features/topics/sections/state.tsx +++ b/src/features/topics/sections/state.tsx @@ -1,10 +1,57 @@ +import { useState } from "react"; import { useTopics } from "../hooks/queries"; import { Edit, Trash2 } from "lucide-react"; +import { useDeleteTopic } from "../hooks/mutations"; + +type PendingDelete = { + open: boolean; + topic: string; +}; export default function StateSection() { - const { data } = useTopics(); + const { data, isLoading } = useTopics(); + const deleteTopic = useDeleteTopic(); + const [pending, setPending] = useState({ + open: false, + topic: "", + }); + + const handleDelete = (topic: string) => { + if (deleteTopic.isPending) return; + setPending({ open: true, topic }); + }; + + const handleConfirmDelete = () => { + if (!pending.topic || deleteTopic.isPending) return; + deleteTopic.mutate( + { topic: pending.topic, type: "state-reply" }, + { + onSuccess: () => setPending({ open: false, topic: "" }), + onError: () => setPending({ open: false, topic: "" }), + }, + ); + }; + + const handleClose = () => { + if (deleteTopic.isPending) return; + setPending({ open: false, topic: "" }); + }; + return ( <> + {isLoading && + Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} {data?.map((item, key) => (

{item.topic}

- - + +
))} + {pending.open && ( +
+
+

Hapus Topic

+

+ Yakin ingin menghapus topic "{pending.topic}"? +

+
+ + +
+
+
+ )} ); } diff --git a/src/repositories/device/index.ts b/src/repositories/device/index.ts index a245cf6..0d19c27 100644 --- a/src/repositories/device/index.ts +++ b/src/repositories/device/index.ts @@ -35,3 +35,28 @@ export const getTopicsCommand = async (): Promise => { return res.data; }; + +export const createTopic = async ( + payload: TopicPayload, +): Promise => { + const res = await api.post("/topics/v1/", payload, { + headers: { + Authorization: basicAuth(ENV.basicUsername, ENV.basicPassword), + }, + }); + + return res.data; +}; + +export const deleteTopic = async ( + payload: TopicPayload, +): Promise => { + const res = await api.delete("/topics/v1/", { + data: payload, + headers: { + Authorization: basicAuth(ENV.basicUsername, ENV.basicPassword), + }, + }); + + return res.data; +}; diff --git a/src/repositories/device/types.d.ts b/src/repositories/device/types.d.ts index e3eb95e..fe90435 100644 --- a/src/repositories/device/types.d.ts +++ b/src/repositories/device/types.d.ts @@ -51,3 +51,14 @@ type TopicData = { message: string; data: { topic: string }[]; }; + +type TopicPayload = { + topic: string; + type: "state-reply" | "commands"; +}; + +type TopicMutationResponse = { + status: string; + message: string; + data?: unknown; +}; diff --git a/src/utils/axios.ts b/src/utils/axios.ts index a26c786..ced53c4 100644 --- a/src/utils/axios.ts +++ b/src/utils/axios.ts @@ -3,6 +3,7 @@ import axios from "axios"; import { generateSignature, parseQueryParamsManual } from "./signer"; import { ENV } from "../constants/env"; +import { getMerchant } from "./storage"; import type { InternalAxiosRequestConfig } from "axios"; @@ -17,6 +18,18 @@ const api = axios.create({ api.interceptors.request.use( (config: SignedAxiosRequestConfig) => { + const isTopicsRequest = (config.url ?? "").includes("/topics/"); + if (isTopicsRequest) { + const merchant = getMerchant(); + const credential = merchant?.credential?.trim(); + const expectedCredential = ENV.credential?.trim(); + if (!credential || !expectedCredential || credential !== expectedCredential) { + return Promise.reject( + new Error("Credential tidak sesuai. Request dibatalkan."), + ); + } + } + if (config.signed === false) { return config; }