fix setup form

This commit is contained in:
2026-01-23 15:27:42 +07:00
parent 505b0caeeb
commit 27a8f3ed0b
9 changed files with 273 additions and 159 deletions

View File

@@ -7,6 +7,9 @@ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: false, retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
}, },
}, },
}); });

View File

@@ -11,10 +11,10 @@ export default function BottomNavigation() {
console.log(location.pathname) console.log(location.pathname)
const tabs = [ const tabs = [
{ path: "/", icon: Home, label: "Dashboard" }, { path: "/", icon: Home, label: "Dashboard", disabled: false },
{ path: "/analysis", icon: BarChart3, label: "analysis" }, { path: "/analysis", icon: BarChart3, label: "analysis", disabled: true },
{ path: "/rooms", icon: DoorOpen, label: "Rooms" }, { path: "/rooms", icon: DoorOpen, label: "Rooms", disabled: true },
{ path: "/settings", icon: Settings, label: "Settings" }, { path: "/settings", icon: Settings, label: "Settings", disabled: false },
]; ];
return ( return (
@@ -24,7 +24,7 @@ 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-300', tab.path === location.pathname && 'text-neutral-900')} 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,27 +2,30 @@ import { useState } from "react";
import { import {
getDevices, getDevices,
saveDevices, saveDevices,
isDuplicateDevice, getMerchant,
} from "../../../utils/storage"; } from "../../../utils/storage";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
const initialState: DeviceData = {
towerNumber: "",
floorName: "",
unitNumber: "",
roomName: "",
deviceName: "",
deviceType: "",
code: "",
payload: "",
active: true,
status: false,
};
export default function AddDeviceFeature() { export default function AddDeviceFeature() {
const [form, setForm] = useState<DeviceData>(initialState); const [form, setForm] = useState<DeviceData | null >(null);
const navigate = useNavigate(); const navigate = useNavigate();
const merchant = getMerchant();
const isMerchantValid =
merchant?.merchantName &&
merchant?.tower &&
merchant?.floor &&
merchant?.unit;
const isFormValid =
form?.roomName?.trim() &&
form?.deviceName?.trim() &&
form?.deviceType?.trim() &&
form?.deviceLabel?.trim();
function handleChange( function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) { ) {
@@ -32,108 +35,90 @@ export default function AddDeviceFeature() {
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!isMerchantValid || !isFormValid) return;
const devices = getDevices(); const devices = getDevices();
const deviceToSave: DeviceData = { const deviceToSave: DeviceData = {
...form, ...form,
deviceLabel: form.deviceLabel || `${form.roomName} ${form.deviceName}`, towerNumber: merchant.tower,
floorName: merchant.floor,
unitNumber: merchant.unit,
deviceLabel: form?.deviceLabel,
}; };
if (isDuplicateDevice(devices, deviceToSave)) {
return;
}
saveDevices([...devices, deviceToSave]); saveDevices([...devices, deviceToSave]);
setForm(initialState); setForm(null);
navigate({ to: '/' }); navigate({ to: "/" });
} }
return ( return (
<form onSubmit={handleSubmit} className="p-4"> <form onSubmit={handleSubmit} className="p-4">
<h2 className="text-lg font-semibold mb-4">Add Device</h2> <h2 className="text-lg font-semibold mb-4">Add Device</h2>
{!isMerchantValid && (
<p className="text-sm text-red-500 mb-4">
Lengkapi data merchant (tower, floor, unit) terlebih dahulu
</p>
)}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> {/* ROOM */}
<label className="mb-1 text-neutral-900 block font-semibold">
Tower
</label>
<input
name="towerNumber"
value={form.towerNumber}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Floor
</label>
<input
name="floorName"
value={form.floorName}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div> <div>
<label className="mb-1 text-neutral-900 block font-semibold"> <label className="mb-1 text-neutral-900 block font-semibold">
Room Room
</label> </label>
<input <input
name="roomName" name="roomName"
value={form.roomName} placeholder="Room Name"
onChange={handleChange} value={form?.roomName}
className="p-2 border border-neutral-200 w-full rounded-md"
required
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Unit
</label>
<input
name="unitNumber"
value={form.unitNumber}
onChange={handleChange} onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md" className="p-2 border border-neutral-200 w-full rounded-md"
required required
/> />
</div> </div>
{/* DEVICE NAME */}
<div> <div>
<label className="mb-1 text-neutral-900 block font-semibold"> <label className="mb-1 text-neutral-900 block font-semibold">
Device Name Device Name
</label> </label>
<input <input
name="deviceName" name="deviceName"
value={form.deviceName} placeholder="Device Name"
value={form?.deviceName}
onChange={handleChange} onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md" className="p-2 border border-neutral-200 w-full rounded-md"
required required
/> />
</div> </div>
{/* DEVICE TYPE */}
<div> <div>
<label className="mb-1 text-neutral-900 block font-semibold"> <label className="mb-1 text-neutral-900 block font-semibold">
Device Type Device Type
</label> </label>
<input <input
name="deviceType" name="deviceType"
value={form.deviceType} placeholder="Device Type"
value={form?.deviceType}
onChange={handleChange} onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md" className="p-2 border border-neutral-200 w-full rounded-md"
required required
/> />
</div> </div>
{/* DEVICE LABEL */}
<div> <div>
<label className="mb-1 text-neutral-900 block font-semibold"> <label className="mb-1 text-neutral-900 block font-semibold">
Device Label Device Label
</label> </label>
<input <input
name="deviceLabel" name="deviceLabel"
value={form.deviceLabel} value={form?.deviceLabel}
onChange={handleChange} onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md" className="p-2 border border-neutral-200 w-full rounded-md"
placeholder={`${form?.roomName || "Room"} ${form?.deviceName || "Device"}`}
required required
/> />
</div> </div>
@@ -141,7 +126,13 @@ export default function AddDeviceFeature() {
<button <button
type="submit" type="submit"
className="w-full bg-orange-500 text-white py-2 rounded-lg mt-8" disabled={!isMerchantValid || !isFormValid}
className="
w-full py-2 rounded-lg mt-8 text-white cursor-pointer
bg-orange-500
disabled:bg-neutral-400
disabled:cursor-not-allowed
"
> >
Save Device Save Device
</button> </button>

View File

@@ -1,32 +1,36 @@
import { useQueries } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query";
import { getDeviceStatus } from "../../../repositories/device"; import { getDeviceStatus } from "../../../repositories/device";
import { DEVICES_DATA } from "../../../utils/data";
import { getDevices, getMerchant } from "../../../utils/storage"; import { getDevices, getMerchant } from "../../../utils/storage";
export function useDevices() { export function useDevices() {
const merchant = getMerchant();
const dataStorage = getDevices(); const dataStorage = getDevices();
const datas = [...DEVICES_DATA, ...dataStorage]; const datas = [...dataStorage];
const queries = useQueries({ const queries = useQueries({
queries: datas.map((device) => ({ queries: datas.map((device) => ({
queryKey: [ queryKey: [
"device-status", "device-status",
device.code, merchant?.merchantName,
device.towerNumber,
device.floorName,
device.unitNumber,
device.deviceName,
device.roomName, device.roomName,
device.deviceType, device.deviceType,
"S",
], ],
queryFn: async () => { queryFn: async () => {
const merchant = getMerchant();
const params = { const params = {
merchantName: merchant, merchantName: merchant?.merchantName,
towerNumber: device.towerNumber,
floorName: device.floorName, floorName: device.floorName,
unitNumber: device.unitNumber, unitNumber: device.unitNumber,
deviceName: device.deviceName, deviceName: device.deviceName,
roomName: device.roomName, roomName: device.roomName,
deviceType: device.deviceType, deviceType: device.deviceType,
commandType: "S", commandType: "S",
towerNumber: device.towerNumber,
}; };
const data = await getDeviceStatus(params); const data = await getDeviceStatus(params);
@@ -34,28 +38,28 @@ export function useDevices() {
...device, ...device,
code: data.data.code, code: data.data.code,
deviceName: device.deviceName, deviceName: device.deviceName,
payload: data.data.payload,
status: status:
data.data.payload?.toLowerCase().includes("on") || data.data.payload?.toLowerCase().includes("on") ||
data.data.payload?.toLowerCase().includes("lock") || data.data.payload?.toLowerCase().includes("lock") ||
data.data.payload?.toLowerCase().includes("Open") data.data.payload?.toLowerCase().includes("open")
? true ? true
: false, : false,
} as unknown as Device; } as unknown as Device;
}, },
staleTime: 10_000,
retry: 1,
})), })),
}); });
const devices = queries const devices = queries
.map((q, index) => ({ .map((q, index) => ({
...q.data, ...q.data,
isLoading: q.isLoading || q.isFetching, isLoading: q.isLoading || q.isFetching,
isError: q.isError, isError: q.isError,
refetch: q.refetch, // 👈 per device refetch: q.refetch, // 👈 per device
key: datas[index].code, key: datas[index].code,
})) }))
.filter((d) => !d?.isError); .filter((d) => !d?.isError);
console.log(devices);
return { return {
data: devices as unknown as DeviceData[], data: devices as unknown as DeviceData[],

View File

@@ -1,9 +1,19 @@
import { Lightbulb, Minus, Plus, SpeakerIcon, Tv, Wind } from "lucide-react"; import {
Blinds,
ChefHat,
DoorClosed,
Fan,
Lightbulb,
Minus,
Plus,
Wind,
WindArrowDown,
} from "lucide-react";
import { Switch } from "../../../components/ui/switch"; import { Switch } from "../../../components/ui/switch";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Card from "../../../components/ui/card"; import Card from "../../../components/ui/card";
import { useDevices } from "../hooks/queries"; import { useDevices } from "../hooks/queries";
import { getMerchant } from "../../../utils/storage"; import { getMerchant } from "../../../utils/storage";
import { useDeviceCommand } from "../hooks/mutations"; import { useDeviceCommand } from "../hooks/mutations";
export default function Devices() { export default function Devices() {
@@ -15,17 +25,16 @@ export default function Devices() {
const { isPending, mutate } = useDeviceCommand(); const { isPending, mutate } = useDeviceCommand();
const onSubmit = (payload: DeviceData, action = "") => { const onSubmit = (payload: DeviceData, action = "") => {
mutate( mutate(
{ {
towerNumber: payload.towerNumber,
merchantName: merchant?.merchantName,
commandType: "C", commandType: "C",
deviceName: payload.deviceName, deviceName: payload.deviceName,
deviceType: payload.deviceType, deviceType: payload.deviceType,
floorName: payload.floorName, floorName: payload.floorName,
merchantName: merchant,
payload: { action }, payload: { action },
roomName: payload.roomName, roomName: payload.roomName,
towerNumber: payload.towerNumber,
unitNumber: payload.unitNumber, unitNumber: payload.unitNumber,
}, },
{ {
@@ -35,20 +44,20 @@ export default function Devices() {
onError: () => { onError: () => {
payload?.refetch?.(); payload?.refetch?.();
}, },
} },
); );
}; };
const icons = [ const icons = {
{ icon: <Lightbulb size={24} />, label: "light" }, L: { icon: <Lightbulb size={24} />, label: "Lampu" },
{ icon: <Tv size={24} />, label: "tv" }, BL: { icon: <Blinds size={24} />, label: "Blind" },
{ icon: <SpeakerIcon size={24} />, label: "power" }, AC: { icon: <Wind size={24} />, label: "AC" },
{ icon: <Wind size={24} />, label: "ac" }, DL: { icon: <DoorClosed size={24} />, label: "Doorlock" },
{ icon: <Tv size={24} />, label: "device" }, ERV: { icon: <Fan size={24} />, label: "ERV" },
]; PM25: { icon: <WindArrowDown size={24} />, label: "Sensor udara" },
KC: { icon: <ChefHat size={24} />, label: "Kitchen Appliances" },
FT1: { icon: <WindArrowDown size={24} />, label: "IAQ" },
// console.log(data); };
return ( return (
<> <>
@@ -56,7 +65,7 @@ export default function Devices() {
<h1 className="text-base text-neutral-900 font-bold mb-4"> <h1 className="text-base text-neutral-900 font-bold mb-4">
Your Devices Your Devices
</h1> </h1>
<div className="flex items-center gap-3 flex-wrap justify-around"> <div className="flex items-center gap-3 flex-wrap justify-start">
<button <button
onClick={() => onClick={() =>
navigate({ navigate({
@@ -67,31 +76,58 @@ export default function Devices() {
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
{icons.map((item, idx) => ( {/* {icons.map((item, idx) => (
<button <button
key={idx} key={idx}
className="shrink-0 w-12 h-12 rounded-full flex items-center justify-center transition-all bg-white text-gray-700 hover:bg-gray-200" className="shrink-0 w-12 h-12 rounded-full flex items-center justify-center transition-all bg-white text-gray-700 hover:bg-gray-200"
> >
{item.icon} {item.icon}
</button> </button>
))} ))} */}
</div> </div>
</div> </div>
<div className="py-4 px-6"> <div className="py-4 px-6">
<div className="grid grid-cols-2 auto-rows-fr gap-4 grid-flow-dense"> <div className="grid grid-cols-2 auto-rows-fr gap-4 grid-flow-dense">
{isLoading && (
<>
{[...Array(4)].map((item) => (
<Card className={`animate-pulse `} key={item}>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="h-12 w-12 rounded-full bg-gray-200" />
<div className="h-6 w-10 rounded-full bg-gray-200" />
</div>
{/* Title */}
<div className="h-4 w-32 bg-gray-200 rounded mb-2" />
{/* Device */}
<div className="h-3 w-40 bg-gray-200 rounded mb-2" />
{/* Status */}
<div className="h-3 w-24 bg-gray-200 rounded mb-2" />
{/* Location */}
<div className="h-3 w-full bg-gray-200 rounded mb-4" />
</Card>
))}
</>
)}
{!isLoading && {!isLoading &&
data?.map((item, index) => ( data?.map((item, index) => (
<Card <Card
key={index} key={index}
className={`${ className={`${
item.deviceName === "AC" && "row-span-2 col-span-1" item.deviceName === "AC" && "row-span-2 col-span-1"
}`} } ${item.status && "bg-orange-100 border-orange-500"}`}
> >
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="h-12 w-12 bg-neutral-100 rounded-full flex justify-center items-center"> <div
icon className={`h-12 w-12 rounded-full flex justify-center items-center ${item.status ? "bg-orange-500 text-white" : "bg-gray-200 text-gray-700"}`}
>
{icons[item.deviceName as keyof typeof icons]?.icon}
</div> </div>
{!["FT1"].includes(item.deviceName) && ( {!["FT1"].includes(item.deviceName ?? "") && (
<Switch <Switch
disabled={isPending} disabled={isPending}
defaultChecked={item.status} defaultChecked={item.status}
@@ -109,11 +145,20 @@ export default function Devices() {
)} )}
</div> </div>
<h1 className="font-semibold text-gray-800 text-sm mb-1"> <h1 className="font-semibold text-gray-800 text-sm">
{item?.deviceLabel} {item?.deviceLabel}
</h1> </h1>
<p className="text-xs font-medium text-gray-700 mb-2">
Device:{" "}
{icons[item.deviceName as keyof typeof icons]?.label ??
item.deviceName}
</p>
<p className="text-xs font-medium text-gray-700 mb-2">
Status: {item?.payload?.toString()}
</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{item?.towerNumber}/{item?.floorName}/{item?.roomName} {item?.towerNumber}/{item?.floorName}/{item?.unitNumber}/
{item?.roomName}
</p> </p>
{item?.deviceName === "AC" && ( {item?.deviceName === "AC" && (

View File

@@ -4,32 +4,56 @@ import {
saveMerchant, saveMerchant,
getMerchant, getMerchant,
} from "../../utils/storage"; } from "../../utils/storage";
import { useNavigate } from "@tanstack/react-router";
export default function SettingsFeature() { export default function SettingsFeature() {
const [form, setForm] = useState<string>(''); const navigate = useNavigate();
const [form, setForm] = useState<MerchantForm>({
merchantName: "",
tower: "",
floor: "",
unit: "",
});
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const merchant = getMerchant();
function handleChange( function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> e: React.ChangeEvent<HTMLInputElement>
) { ) {
const { value } = e.target; const { name, value } = e.target;
setForm(value); setForm((prev) => ({
...prev,
[name]: value,
}));
} }
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!isFormValid) return;
setLoading(true); setLoading(true);
saveMerchant(form) saveMerchant(form);
setTimeout(() => { setTimeout(() => {
setLoading(false); setLoading(false);
navigate({ to: "/" });
}, 200); }, 200);
} }
useEffect(() => { useEffect(() => {
setForm(merchant || 'SAVY'); const merchant = getMerchant();
},[merchant]); if (merchant) {
setForm(merchant);
}
}, []);
const isFormValid =
form.merchantName?.trim() &&
form.tower?.trim() &&
form.floor?.trim() &&
form.unit?.trim();
return ( return (
<form onSubmit={handleSubmit} className="p-4"> <form onSubmit={handleSubmit} className="p-4">
@@ -40,21 +64,62 @@ export default function SettingsFeature() {
</label> </label>
<input <input
name="merchantName" name="merchantName"
value={form} value={form.merchantName}
onChange={handleChange} onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md" className="p-2 border border-neutral-200 w-full rounded-md"
required
/> />
</div> </div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Tower
</label>
<input
name="tower"
value={form.tower}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Floor
</label>
<input
name="floor"
value={form.floor}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
/>
</div>
<div>
<label className="mb-1 text-neutral-900 block font-semibold">
Unit
</label>
<input
name="unit"
value={form.unit}
onChange={handleChange}
className="p-2 border border-neutral-200 w-full rounded-md"
/>
</div>
</div>
</div> </div>
<button <button
type="submit" type="submit"
className="w-full bg-orange-500 text-white py-2 rounded-lg mt-8 disabled:bg-neutral-500" disabled={!isFormValid || loading}
disabled={loading} className="
w-full py-2 rounded-lg mt-8 text-white
bg-orange-500
disabled:bg-neutral-400
disabled:cursor-not-allowed
"
> >
Save Device {loading ? "Saving..." : "Save Device"}
</button> </button>
</form> </form>
); );

View File

@@ -5,13 +5,13 @@ type Device = {
}; };
type DeviceData = { type DeviceData = {
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
@@ -21,26 +21,26 @@ type DeviceData = {
}; };
type DeviceParams = { type DeviceParams = {
merchantName: string; merchantName?: string;
floorName: string; floorName?: string;
unitNumber: string; unitNumber?: string;
deviceName: string; deviceName?: string;
roomName: string; roomName?: string;
deviceType: string; deviceType?: string;
commandType: string; commandType?: string;
towerNumber: string; towerNumber?: string;
}; };
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,
} }

6
src/types/merchant.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
type MerchantForm = {
merchantName: string;
tower: string;
floor: string;
unit: string;
};

View File

@@ -28,14 +28,14 @@ export function isDuplicateDevice(
} }
export function getMerchant(): string { export function getMerchant(): MerchantForm | null {
try { try {
return JSON.parse(localStorage.getItem(MERCHANT_KEY) || ""); return JSON.parse(localStorage.getItem(MERCHANT_KEY) || "");
} catch { } catch {
return ''; return null;
} }
} }
export function saveMerchant(val: string) { export function saveMerchant(val: MerchantForm) {
localStorage.setItem(MERCHANT_KEY, JSON.stringify(val)); localStorage.setItem(MERCHANT_KEY, JSON.stringify(val));
} }