initial code
This commit is contained in:
101
src/features/analysis/index.tsx
Normal file
101
src/features/analysis/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import Card from "../../components/ui/card";
|
||||
import { BarChart2, RefreshCwIcon, Thermometer } from "lucide-react";
|
||||
import ProgressBar from "../../components/ui/progress-bar";
|
||||
|
||||
const tabs = [
|
||||
{ id: 0, label: "January" },
|
||||
{ id: 1, label: "February" },
|
||||
{ id: 2, label: "March" },
|
||||
{ id: 3, label: "April" },
|
||||
{ id: 4, label: "May" },
|
||||
{ id: 5, label: "June" },
|
||||
{ id: 6, label: "July" },
|
||||
{ id: 7, label: "August" },
|
||||
{ id: 8, label: "September" },
|
||||
{ id: 9, label: "October" },
|
||||
{ id: 10, label: "November" },
|
||||
{ id: 11, label: "December" },
|
||||
];
|
||||
|
||||
export default function AnalysisFeature() {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const handleChange = (index: number) => {
|
||||
setActive(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* TABS */}
|
||||
<div className="relative flex items-center overflow-y-auto gap-10 mx-4 scrollbar-none ">
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleChange(i)}
|
||||
className="relative py-2 text-sm text-neutral-900"
|
||||
>
|
||||
{active === i && (
|
||||
<motion.div
|
||||
layoutId="tab-indicator"
|
||||
className="absolute inset-x-0 bottom-0 h-0.5 bg-orange-500"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className={active === i ? "text-neut" : ""}>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative p-4 mt-2">
|
||||
<Card title="Consumed" to="/" className="mb-4">
|
||||
<div className="mt-4 flex justify-start items-center gap-4">
|
||||
<div className="h-12 w-12 flex items-center justify-center bg-neutral-100 rounded-full">
|
||||
<BarChart2 size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-1">
|
||||
385 kWh
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-400">Costs: $22.31</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Compared the most" to="/" className="mb-4">
|
||||
<ProgressBar
|
||||
title="Most efficient homes"
|
||||
subTitle="292 kWh"
|
||||
className="mt-4 bg-ne"
|
||||
color="#d4d4d4"
|
||||
value={80}
|
||||
/>
|
||||
<ProgressBar title="Your Home" subTitle="292 kWh" className="mt-4" value={50} />
|
||||
<ProgressBar
|
||||
title="Average Homes"
|
||||
subTitle="292 kWh"
|
||||
className="mt-4 bg-ne"
|
||||
color="#d4d4d4"
|
||||
value={80}
|
||||
/>
|
||||
</Card>
|
||||
<Card title="Compared to other" to="/" className="mb-4">
|
||||
<ProgressBar
|
||||
title="Always on"
|
||||
subTitle="292 kWh"
|
||||
className="mt-4"
|
||||
color="#00c950"
|
||||
value={80}
|
||||
icon={<RefreshCwIcon />}
|
||||
/>
|
||||
<ProgressBar
|
||||
title="Heating / Cooling"
|
||||
subTitle="292 kWh"
|
||||
className="mt-4"
|
||||
value={90}
|
||||
icon={<Thermometer />}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/features/analysis/utils.ts
Normal file
14
src/features/analysis/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const contentVariants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? "100%" : "-100%",
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction > 0 ? "-100%" : "100%",
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
150
src/features/device/add/index.tsx
Normal file
150
src/features/device/add/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
getDevices,
|
||||
saveDevices,
|
||||
isDuplicateDevice,
|
||||
} from "../../../utils/storage";
|
||||
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() {
|
||||
const [form, setForm] = useState<DeviceData>(initialState);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const devices = getDevices();
|
||||
|
||||
const deviceToSave: DeviceData = {
|
||||
...form,
|
||||
deviceLabel: form.deviceLabel || `${form.roomName} ${form.deviceName}`,
|
||||
};
|
||||
|
||||
if (isDuplicateDevice(devices, deviceToSave)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveDevices([...devices, deviceToSave]);
|
||||
setForm(initialState);
|
||||
navigate({ to: '/' });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Add Device</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<label className="mb-1 text-neutral-900 block font-semibold">
|
||||
Room
|
||||
</label>
|
||||
<input
|
||||
name="roomName"
|
||||
value={form.roomName}
|
||||
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">
|
||||
Unit
|
||||
</label>
|
||||
<input
|
||||
name="unitNumber"
|
||||
value={form.unitNumber}
|
||||
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">
|
||||
Device Name
|
||||
</label>
|
||||
<input
|
||||
name="deviceName"
|
||||
value={form.deviceName}
|
||||
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">
|
||||
Device Type
|
||||
</label>
|
||||
<input
|
||||
name="deviceType"
|
||||
value={form.deviceType}
|
||||
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">
|
||||
Device Label
|
||||
</label>
|
||||
<input
|
||||
name="deviceLabel"
|
||||
value={form.deviceLabel}
|
||||
onChange={handleChange}
|
||||
className="p-2 border border-neutral-200 w-full rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-orange-500 text-white py-2 rounded-lg mt-8"
|
||||
>
|
||||
Save Device
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
39
src/features/device/add/utils.ts
Normal file
39
src/features/device/add/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
function distance(a: Point, b: Point) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
export function generateUniquePoints(
|
||||
count: number,
|
||||
minRadius: number,
|
||||
maxRadius: number,
|
||||
minDistance: number
|
||||
): Point[] {
|
||||
const points: Point[] = []
|
||||
let attempts = 0
|
||||
|
||||
while (points.length < count && attempts < 500) {
|
||||
attempts++
|
||||
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const r =
|
||||
minRadius +
|
||||
Math.sqrt(Math.random()) * (maxRadius - minRadius)
|
||||
|
||||
const point = {
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
}
|
||||
|
||||
const isTooClose = points.some(
|
||||
(p) => distance(p, point) < minDistance
|
||||
)
|
||||
|
||||
if (!isTooClose) {
|
||||
points.push(point)
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
42
src/features/device/detail/components/configs.tsx
Normal file
42
src/features/device/detail/components/configs.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Calendar, LineChart, MoreHorizontal, Timer } from "lucide-react";
|
||||
|
||||
export default function Configs() {
|
||||
const configList = [
|
||||
{
|
||||
id: 1,
|
||||
icon: <Timer size={20} className="text-white" />,
|
||||
name: "Timer",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: <Calendar size={20} className="text-white" />,
|
||||
name: "Schedule",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <LineChart size={20} className="text-white" />,
|
||||
name: "Graph",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <MoreHorizontal size={20} className="text-white" />,
|
||||
name: "More",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className=" flex justify-center gap-8">
|
||||
{configList?.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-center justify-center gap-1"
|
||||
>
|
||||
<div className="h-11 w-11 rounded-full bg-white/20 flex items-center justify-center">
|
||||
{item.icon}
|
||||
</div>
|
||||
<p className="text-xs text-white">{item.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/features/device/detail/components/controll.tsx
Normal file
70
src/features/device/detail/components/controll.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ChevronRight, Minus, Plug, Plus } from "lucide-react";
|
||||
import { IMAGES } from "../../../../constants/images";
|
||||
import { useState } from "react";
|
||||
import { cn } from "../../../../utils/classname";
|
||||
|
||||
export default function Controll() {
|
||||
const [suhu, setSuhu] = useState(23);
|
||||
const [mode, setMode] = useState(1);
|
||||
|
||||
const modeList = [
|
||||
{
|
||||
id: 1,
|
||||
icon: IMAGES.ModeAuto,
|
||||
name: "Mode",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: IMAGES.ModeFan,
|
||||
name: "Fan",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: IMAGES.ModeSwing,
|
||||
name: "Swing",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center flex-1 items-center gap-4 pt-20">
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSuhu((prev) => prev - 1)}
|
||||
className="h-14 w-14 flex justify-center items-center bg-white rounded-full shadow-sm shadow-neutral-100 active:bg-neutral-200 transition-all duration-200 ease-in disabled:text-neutral-900/30"
|
||||
disabled={suhu <= 0}
|
||||
>
|
||||
<Minus size={24} />
|
||||
</button>
|
||||
<div className="h-38 w-38 bg-white/30 rounded-full flex justify-center items-center flex-col">
|
||||
<h1 className="text-neutral-900 font-bold text-5xl">{suhu}°</h1>
|
||||
<p className="text-sm font-medium text-neutral-500">Turn On</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSuhu((prev) => prev + 1)}
|
||||
className="h-14 w-14 flex justify-center items-center bg-white rounded-full shadow-sm shadow-neutral-100 active:bg-neutral-200 transition-all duration-200 ease-in disabled:text-neutral-900/30"
|
||||
disabled={suhu >= 25}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
{modeList?.map((item, index) => (
|
||||
<button onClick={() => setMode(item.id)} className="flex flex-col justify-center items-center gap-2">
|
||||
<div
|
||||
key={index}
|
||||
className="h-11 w-11 bg-white rounded-full shadow-sm shadow-neutral-100 active:bg-neutral-200 transition-all duration-200 ease-in flex justify-center items-center"
|
||||
>
|
||||
<img src={item.icon} className={cn('max-h-5 max-w-5 opacity-100', mode !== item.id && 'opacity-30')} />
|
||||
</div>
|
||||
<p className="text-xs text-white">{item.name}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-linear-to-r from-dark-2 to-dark-3 rounded-4xl py-3 px-4 flex justify-center items-center gap-2 mt-8">
|
||||
<Plug size={24} className="text-orange-500" />
|
||||
<p className="text-base font-bold text-white">220 kWh</p>
|
||||
<ChevronRight size={24} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
src/features/device/detail/index.tsx
Normal file
31
src/features/device/detail/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Wind } from "lucide-react";
|
||||
import Card from "../../../components/ui/card";
|
||||
import { Switch } from "../../../components/ui/switch";
|
||||
import Controll from "./components/controll";
|
||||
import Configs from "./components/configs";
|
||||
|
||||
export default function DetailDeviceFeature() {
|
||||
return (
|
||||
<div className="pt-4 px-6 h-full flex flex-col">
|
||||
<Card className="shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="h-12 w-12 bg-neutral-100 rounded-full flex justify-center items-center">
|
||||
<Wind size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 text-sm mb-1">
|
||||
Air Conditioner
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">Entrance - Off</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch />
|
||||
</div>
|
||||
</Card>
|
||||
<Controll/>
|
||||
<Configs/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/features/device/pairing/index.tsx
Normal file
89
src/features/device/pairing/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Bluetooth, Lightbulb, SpeakerIcon, Tv, Wind } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { generateUniquePoints } from "./utils";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export default function AddDevice() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const devices = [
|
||||
{
|
||||
id: 1,
|
||||
icon: <Wind size={18} />,
|
||||
name: "Air Conditioner",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: <SpeakerIcon size={18} />,
|
||||
name: "Speaker",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <Tv size={18} />,
|
||||
name: "Smart TV",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: <Lightbulb size={18} />,
|
||||
name: "Ceiling Light",
|
||||
},
|
||||
];
|
||||
|
||||
const positions = useMemo(
|
||||
() =>
|
||||
generateUniquePoints(
|
||||
5, // jumlah device
|
||||
60, // radius area orange (inner)
|
||||
160, // batas spiral (outer)
|
||||
80 // ↔ minimal jarak antar device
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex flex-col items-center justify-center shrink-0">
|
||||
<h1 className="text-2xl text-white font-semibold mb-2">
|
||||
Add new device
|
||||
</h1>
|
||||
<p className="text-white/60 text-sm">Searching devices...</p>
|
||||
</div>
|
||||
<div className="flex h-full justify-center items-center relative">
|
||||
<div className="p-12 rounded-full border border-dashed animate-[ringStep3_1.6s_linear_infinite]">
|
||||
<div className="p-12 rounded-full border border-dashed animate-[ringStep2_1.6s_linear_infinite]">
|
||||
<div className="p-12 rounded-full border border-dashed animate-[ringStep1_1.6s_linear_infinite]">
|
||||
<div className="bg-orange-500 h-12 w-12 rounded-full text-white flex items-center justify-center">
|
||||
<Bluetooth size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{devices.map((device, i) => (
|
||||
<button
|
||||
key={device.id}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/",
|
||||
state: {},
|
||||
});
|
||||
}}
|
||||
className="absolute text-xs text-center flex flex-col items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${positions[i].x}px, ${positions[i].y}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="h-10 w-10 bg-white rounded-full flex items-center justify-center text-shadow-neutral-900">
|
||||
{device.icon}
|
||||
</div>
|
||||
<p className="text-xs text-white/60 py-2">{device.name}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex justify-center">
|
||||
<p className="text-white/50 text-sm">Tap on device to add</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/features/device/pairing/utils.ts
Normal file
39
src/features/device/pairing/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
function distance(a: Point, b: Point) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
export function generateUniquePoints(
|
||||
count: number,
|
||||
minRadius: number,
|
||||
maxRadius: number,
|
||||
minDistance: number
|
||||
): Point[] {
|
||||
const points: Point[] = []
|
||||
let attempts = 0
|
||||
|
||||
while (points.length < count && attempts < 500) {
|
||||
attempts++
|
||||
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const r =
|
||||
minRadius +
|
||||
Math.sqrt(Math.random()) * (maxRadius - minRadius)
|
||||
|
||||
const point = {
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
}
|
||||
|
||||
const isTooClose = points.some(
|
||||
(p) => distance(p, point) < minDistance
|
||||
)
|
||||
|
||||
if (!isTooClose) {
|
||||
points.push(point)
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
9
src/features/home/hooks/mutations.ts
Normal file
9
src/features/home/hooks/mutations.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { postCommandStatus } from "../../../repositories/device";
|
||||
|
||||
export function useDeviceCommand() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: DevicePayload) =>
|
||||
postCommandStatus(payload),
|
||||
});
|
||||
}
|
||||
60
src/features/home/hooks/queries.ts
Normal file
60
src/features/home/hooks/queries.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { getDeviceStatus } from "../../../repositories/device";
|
||||
import { DEVICES_DATA } from "../../../utils/data";
|
||||
import { getMerchant } from "../../../utils/storage";
|
||||
|
||||
export function useDevices() {
|
||||
const queries = useQueries({
|
||||
queries: DEVICES_DATA.map((device) => ({
|
||||
queryKey: [
|
||||
"device-status",
|
||||
device.code,
|
||||
device.roomName,
|
||||
device.deviceType,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const merchant = getMerchant();
|
||||
const params = {
|
||||
merchantName: merchant,
|
||||
floorName: device.floorName,
|
||||
unitNumber: device.unitNumber,
|
||||
deviceName: device.code,
|
||||
roomName: device.roomName,
|
||||
deviceType: device.deviceType,
|
||||
commandType: "S",
|
||||
towerNumber: device.towerNumber,
|
||||
};
|
||||
const data = await getDeviceStatus(params);
|
||||
|
||||
return {
|
||||
...device,
|
||||
code: data.data.code,
|
||||
deviceName: device.code,
|
||||
status:
|
||||
data.data.payload?.toLowerCase().includes("on") ||
|
||||
data.data.payload?.toLowerCase().includes("lock") ||
|
||||
data.data.payload?.toLowerCase().includes("Open")
|
||||
? true
|
||||
: false,
|
||||
} as unknown as Device;
|
||||
},
|
||||
staleTime: 10_000,
|
||||
retry: 1,
|
||||
})),
|
||||
});
|
||||
|
||||
const devices = queries
|
||||
.map((q, index) => ({
|
||||
...q.data,
|
||||
isLoading: q.isLoading || q.isFetching,
|
||||
isError: q.isError,
|
||||
refetch: q.refetch, // 👈 per device
|
||||
key: DEVICES_DATA[index].code,
|
||||
}))
|
||||
.filter((d) => !d?.isError);
|
||||
|
||||
return {
|
||||
data: devices as unknown as DeviceData[],
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
};
|
||||
}
|
||||
11
src/features/home/index.tsx
Normal file
11
src/features/home/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Devices from "./sections/devices";
|
||||
import Weather from "./sections/weather";
|
||||
|
||||
export default function HomeFeature() {
|
||||
return (
|
||||
<>
|
||||
<Weather />
|
||||
<Devices />
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
src/features/home/sections/devices.tsx
Normal file
152
src/features/home/sections/devices.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Lightbulb, Minus, Plus, SpeakerIcon, Tv, Wind } from "lucide-react";
|
||||
import { Switch } from "../../../components/ui/switch";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Card from "../../../components/ui/card";
|
||||
import { useDevices } from "../hooks/queries";
|
||||
import { getDevices, getMerchant } from "../../../utils/storage";
|
||||
import { useDeviceCommand } from "../hooks/mutations";
|
||||
|
||||
export default function Devices() {
|
||||
const navigate = useNavigate();
|
||||
const dataStorage = getDevices();
|
||||
const merchant = getMerchant();
|
||||
|
||||
const { data, isLoading } = useDevices();
|
||||
const { isPending, mutate } = useDeviceCommand();
|
||||
|
||||
const onSubmit = (payload: DeviceData, action = "") => {
|
||||
mutate(
|
||||
{
|
||||
commandType: "C",
|
||||
deviceName: payload.deviceName,
|
||||
deviceType: payload.deviceType,
|
||||
floorName: payload.floorName,
|
||||
merchantName: merchant,
|
||||
payload: { action },
|
||||
roomName: payload.roomName,
|
||||
towerNumber: payload.towerNumber,
|
||||
unitNumber: payload.unitNumber,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
payload?.refetch?.();
|
||||
},
|
||||
onError: () => {
|
||||
payload?.refetch?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const icons = [
|
||||
{ icon: <Lightbulb size={24} />, label: "light" },
|
||||
{ icon: <Tv size={24} />, label: "tv" },
|
||||
{ icon: <SpeakerIcon size={24} />, label: "power" },
|
||||
{ icon: <Wind size={24} />, label: "ac" },
|
||||
{ icon: <Tv size={24} />, label: "device" },
|
||||
];
|
||||
|
||||
const datas = [...data, ...dataStorage];
|
||||
|
||||
console.log(data);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-4 px-6">
|
||||
<h1 className="text-base text-neutral-900 font-bold mb-4">
|
||||
Your Devices
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 flex-wrap justify-around">
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/device/add",
|
||||
})
|
||||
}
|
||||
className="shrink-0 w-12 h-12 rounded-full flex items-center justify-center transition-all bg-orange-500 text-white"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
{icons.map((item, idx) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-4 px-6">
|
||||
<div className="grid grid-cols-2 auto-rows-fr gap-4 grid-flow-dense">
|
||||
{!isLoading &&
|
||||
datas?.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={`${
|
||||
item.deviceName === "AC" && "row-span-2 col-span-1"
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
icon
|
||||
</div>
|
||||
{!["FT1"].includes(item.deviceName) && (
|
||||
<Switch
|
||||
disabled={isPending}
|
||||
defaultChecked={item.status}
|
||||
checked={item.status}
|
||||
onChange={() => {
|
||||
if (item.deviceName === "DL") {
|
||||
onSubmit(item, item.status ? "Unlock" : "Lock");
|
||||
} else if (item.deviceName === "BL") {
|
||||
onSubmit(item, item.status ? "Closed" : "Open");
|
||||
} else {
|
||||
onSubmit(item, item.status ? "Off" : "On");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="font-semibold text-gray-800 text-sm mb-1">
|
||||
{item?.deviceLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
T{item?.towerNumber}/L{item?.floorName}/R{item?.roomName}
|
||||
</p>
|
||||
|
||||
{item?.deviceName === "AC" && (
|
||||
<>
|
||||
<div className="mb-3 border-t border-neutral-200 py-4 mt-4">
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{item.deviceName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Unit {item.unitNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<button
|
||||
onClick={() => onSubmit(item, "Down")}
|
||||
className="flex-1 flex bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg items-center justify-center"
|
||||
>
|
||||
<Minus size={24} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(item, "Up")}
|
||||
className="flex-1 flex bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 rounded-lg items-center justify-center"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/features/home/sections/weather.tsx
Normal file
15
src/features/home/sections/weather.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IMAGES } from "../../../constants/images";
|
||||
|
||||
export default function Weather() {
|
||||
return (
|
||||
<div className="px-6 py-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={IMAGES.Sun} className="w-12 h-12" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Sunny, 24°C</h2>
|
||||
<p className="text-sm text-gray-600">Stockholm, Sweden</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/features/rooms/index.tsx
Normal file
72
src/features/rooms/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Lightbulb, SpeakerIcon, Tv, Wind } from "lucide-react";
|
||||
import Card from "../../components/ui/card";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { cn } from "../../utils/classname";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export default function RoomsFeature() {
|
||||
const deviceData = [
|
||||
{
|
||||
id: 1,
|
||||
icon: <Wind size={18} />,
|
||||
name: "Air Conditioner",
|
||||
device: 2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: <SpeakerIcon size={18} />,
|
||||
name: "Speaker",
|
||||
device: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: <Tv size={18} />,
|
||||
name: "Smart TV",
|
||||
device: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: <Lightbulb size={18} />,
|
||||
name: "Lamp",
|
||||
device: 4,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-end h-full w-full">
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl text-white font-bold">Living Room</h1>
|
||||
<p className="text-sm text-white">4 Devices</p>
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-x-auto scrollbar-none">
|
||||
{deviceData?.map((item, index) => {
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
"p-2",
|
||||
index === 0 && "ml-4",
|
||||
index + 1 === deviceData.length && "mr-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 gap-6">
|
||||
<div className="h-10 w-10 bg-neutral-100 rounded-full flex justify-center items-center">
|
||||
{item.icon}
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/device/${item.id}`}
|
||||
className="font-semibold text-gray-800 text-sm mb-1"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className="text-xs text-gray-500">{item.device} Devices</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/features/settings/index.tsx
Normal file
61
src/features/settings/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
saveMerchant,
|
||||
getMerchant,
|
||||
} from "../../utils/storage";
|
||||
|
||||
export default function SettingsFeature() {
|
||||
const [form, setForm] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const merchant = getMerchant();
|
||||
|
||||
function handleChange(
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) {
|
||||
const { value } = e.target;
|
||||
setForm(value);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
saveMerchant(form)
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 200);
|
||||
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setForm(merchant || 'SAVY');
|
||||
},[merchant]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="mb-1 text-neutral-900 block font-semibold">
|
||||
Merchant Name
|
||||
</label>
|
||||
<input
|
||||
name="merchantName"
|
||||
value={form}
|
||||
onChange={handleChange}
|
||||
className="p-2 border border-neutral-200 w-full rounded-md"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-orange-500 text-white py-2 rounded-lg mt-8 disabled:bg-neutral-500"
|
||||
disabled={loading}
|
||||
>
|
||||
Save Device
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user