feat(topic state and command) - add & delete topic

This commit is contained in:
2026-02-07 09:58:17 +07:00
parent f12f34352a
commit 4384c355f9
9 changed files with 405 additions and 11 deletions

View File

@@ -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,
};

View File

@@ -16,9 +16,13 @@ export default function SettingsFeature() {
const [loading, setLoading] = useState<boolean>(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<HTMLInputElement>) {

View File

@@ -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"] });
},
});
}

View File

@@ -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<TopicPayload>({
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<HTMLFormElement>) => {
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 (
<div className="flex flex-col gap-4">
{!isReady && (
<div className="mx-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{!isMerchantSetup && (
<span>
Silakan setup merchant terlebih dahulu di halaman Settings sebelum
mengakses Topics.
</span>
)}
{isMerchantSetup && !hasCredential && (
<span>
Silakan isi credential terlebih dahulu di halaman Settings sebelum
mengakses Topics.
</span>
)}
{isMerchantSetup && hasCredential && !isCredentialMatch && (
<span>Credential tidak sesuai.</span>
)}
</div>
)}
<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">
<button
onClick={handleOpen}
className="shrink-0 px-4 py-2 rounded-md flex items-center justify-center transition-all bg-orange-500 text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={!isReady}
>
<PlusIcon />
Tambah Topic
</button>
@@ -36,6 +121,71 @@ export default function TopicsFeature() {
</div>
{tab === "state" && <StateSection />}
{tab === "command" && <CommandSection />}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md rounded-md bg-white p-4 shadow-lg">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tambah Topic</h2>
<button
onClick={handleClose}
className="text-sm text-neutral-500"
type="button"
>
Tutup
</button>
</div>
<form onSubmit={handleSubmit} className="mt-4 flex flex-col gap-3">
<label className="text-sm font-medium text-neutral-700">
Topic
<input
className="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm outline-none focus:border-amber-500"
placeholder="Masukkan topic"
value={form.topic}
onChange={(event) =>
setForm((prev) => ({ ...prev, topic: event.target.value }))
}
/>
</label>
<label className="text-sm font-medium text-neutral-700">
Type
<select
className="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm outline-none focus:border-amber-500"
value={form.type}
onChange={(event) =>
setForm((prev) => ({
...prev,
type: event.target.value as TopicPayload["type"],
}))
}
>
<option value="state-reply">state-reply</option>
<option value="commands">commands</option>
</select>
</label>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="mt-2 flex justify-end gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-md border border-neutral-300 px-4 py-2 text-sm"
disabled={createTopic.isPending}
>
Batal
</button>
<button
type="submit"
className="rounded-md bg-amber-500 px-4 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-70"
disabled={createTopic.isPending}
>
{createTopic.isPending ? "Menyimpan..." : "Simpan"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<PendingDelete>({
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) => (
<div
key={`loading-${index}`}
className="bg-white border-b border-neutral-200 p-4 flex items-center justify-between"
>
<div className="h-4 w-40 animate-pulse rounded bg-neutral-200" />
<div className="flex gap-2">
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
</div>
</div>
))}
{data?.map((item, key) => (
<div
key={key}
@@ -12,11 +59,47 @@ export default function CommandSection() {
>
<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} />
<Edit className="text-neutral-300" size={18} />
<button
type="button"
onClick={() => handleDelete(item.topic)}
className="text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
disabled={deleteTopic.isPending}
aria-label={`Hapus topic ${item.topic}`}
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
{pending.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-sm rounded-md bg-white p-4 shadow-lg transition-all duration-200 ease-out">
<h3 className="text-base font-semibold">Hapus Topic</h3>
<p className="mt-2 text-sm text-neutral-600">
Yakin ingin menghapus topic "{pending.topic}"?
</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-md border border-neutral-300 px-4 py-2 text-sm"
disabled={deleteTopic.isPending}
>
Batal
</button>
<button
type="button"
onClick={handleConfirmDelete}
className="rounded-md bg-red-500 px-4 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-70"
disabled={deleteTopic.isPending}
>
{deleteTopic.isPending ? "Menghapus..." : "Hapus"}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -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<PendingDelete>({
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) => (
<div
key={`loading-${index}`}
className="bg-white border-b border-neutral-200 p-4 flex items-center justify-between"
>
<div className="h-4 w-40 animate-pulse rounded bg-neutral-200" />
<div className="flex gap-2">
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
</div>
</div>
))}
{data?.map((item, key) => (
<div
key={key}
@@ -12,11 +59,47 @@ export default function StateSection() {
>
<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} />
<Edit className="text-neutral-300" size={18} />
<button
type="button"
onClick={() => handleDelete(item.topic)}
className="text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
disabled={deleteTopic.isPending}
aria-label={`Hapus topic ${item.topic}`}
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
{pending.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-sm rounded-md bg-white p-4 shadow-lg transition-all duration-200 ease-out">
<h3 className="text-base font-semibold">Hapus Topic</h3>
<p className="mt-2 text-sm text-neutral-600">
Yakin ingin menghapus topic "{pending.topic}"?
</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-md border border-neutral-300 px-4 py-2 text-sm"
disabled={deleteTopic.isPending}
>
Batal
</button>
<button
type="button"
onClick={handleConfirmDelete}
className="rounded-md bg-red-500 px-4 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-70"
disabled={deleteTopic.isPending}
>
{deleteTopic.isPending ? "Menghapus..." : "Hapus"}
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -35,3 +35,28 @@ export const getTopicsCommand = async (): Promise<TopicData> => {
return res.data;
};
export const createTopic = async (
payload: TopicPayload,
): Promise<TopicMutationResponse> => {
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<TopicMutationResponse> => {
const res = await api.delete("/topics/v1/", {
data: payload,
headers: {
Authorization: basicAuth(ENV.basicUsername, ENV.basicPassword),
},
});
return res.data;
};

View File

@@ -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;
};

View File

@@ -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;
}