diff options
| author | IT Fixcomart <it@fixcomart.co.id> | 2025-10-01 10:07:28 +0000 |
|---|---|---|
| committer | IT Fixcomart <it@fixcomart.co.id> | 2025-10-01 10:07:28 +0000 |
| commit | d4688ee0f8590e54211548dd1cfd0faec87d04d0 (patch) | |
| tree | d874ad11b52eb9749a53af5d90958e3a76f219c0 | |
| parent | 0626907b555ead7991d03c374edc096254aced8d (diff) | |
| parent | 011c01a741f23734e4154342e9a560925687f152 (diff) | |
Merged in role (pull request #3)
<Miqdad> Major change: adding Role
| -rw-r--r-- | app/lib/api/clearOdooSession.ts | 25 | ||||
| -rw-r--r-- | app/login/page.tsx | 107 | ||||
| -rw-r--r-- | app/page.tsx | 224 |
3 files changed, 260 insertions, 96 deletions
diff --git a/app/lib/api/clearOdooSession.ts b/app/lib/api/clearOdooSession.ts new file mode 100644 index 0000000..0ad9a5d --- /dev/null +++ b/app/lib/api/clearOdooSession.ts @@ -0,0 +1,25 @@ +export async function clearOdooSession(baseUrl: string) { + try { + if (baseUrl) { + await fetch(`${baseUrl}/web/session/destroy`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + } + } catch { } + + // 2) hapus cookie session_id di browser + try { + const del = (name: string, domain?: string) => { + const d = domain ? `; domain=${domain}` : ""; + document.cookie = `${name}=; Max-Age=0; Path=/${d}`; + document.cookie = `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/${d}`; + }; + const host = window.location.hostname.replace(/^www\./, ""); + const parts = host.split("."); + const parent = parts.length >= 2 ? `.${parts.slice(-2).join(".")}` : undefined; + [undefined, host, parent].forEach(dom => del("session_id", dom)); + } catch { } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 3ea2190..b4b0ed0 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -6,13 +6,17 @@ import { FormLabel, TextField, Typography, + Select, + MenuItem, + SelectChangeEvent, } from "@mui/material"; import Header from "../lib/camera/component/hedear"; import odooApi from "../lib/api/odooApi"; import { getAuth, setAuth } from "../lib/api/auth"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; - +import { setCookie } from "cookies-next"; +import { clearOdooSession } from "../lib/api/clearOdooSession"; // Ambil tipe parameter untuk setAuth agar sesuai tepat dengan definisinya type AuthProps = Parameters<typeof setAuth>[0]; @@ -24,25 +28,38 @@ type LoginResult = { }; type LoginResponse = { status?: LoginStatus; result?: LoginResult }; +type Role = "driver" | "dispatch"; + const Login = () => { const router = useRouter(); - // state untuk validasi MUI helperText + // state input const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [role, setRole] = useState<"" | Role>(""); + // state error helperText const [emailError, setEmailError] = useState(false); const [emailErrorMessage, setEmailErrorMessage] = useState(""); const [passwordError, setPasswordError] = useState(false); const [passwordErrorMessage, setPasswordErrorMessage] = useState(""); + const [roleError, setRoleError] = useState(false); + const [roleErrorMessage, setRoleErrorMessage] = useState(""); + const [loading, setLoading] = useState(false); useEffect(() => { + void clearOdooSession(process.env.NEXT_PUBLIC_ODOO_API_HOST ?? ""); const token = getAuth(); - if (token) router.push("/"); + if (token) router.replace("/"); }, [router]); - const validateInputs = (e: string, p: string) => { + // useEffect(() => { + // const token = getAuth(); + // if (token) router.push("/"); + // }, [router]); + + const validateInputs = (e: string, p: string, r: "" | Role) => { let ok = true; if (!e || !/\S+@\S+\.\S+/.test(e)) { @@ -63,9 +80,27 @@ const Login = () => { setPasswordErrorMessage(""); } + if (!r) { + setRoleError(true); + setRoleErrorMessage("Please select a role."); + ok = false; + } else { + setRoleError(false); + setRoleErrorMessage(""); + } + return ok; }; + const handleRoleChange = (e: SelectChangeEvent) => { + const val = e.target.value as Role | ""; + setRole(val); + if (val) { + setRoleError(false); + setRoleErrorMessage(""); + } + }; + const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); @@ -73,27 +108,32 @@ const Login = () => { const fd = new FormData(event.currentTarget); const rawEmail = fd.get("email"); const rawPassword = fd.get("password"); + const rawRole = fd.get("role"); const emailStr = typeof rawEmail === "string" ? rawEmail.trim() : ""; const passwordStr = typeof rawPassword === "string" ? rawPassword : ""; + const roleStr: "" | Role = + rawRole === "driver" || rawRole === "dispatch" ? rawRole : ""; - if (!validateInputs(emailStr, passwordStr)) return; + if (!validateInputs(emailStr, passwordStr, roleStr)) return; try { setLoading(true); + // Kirim web_role juga (opsional, kalau backend abaikan juga tidak masalah) const res = (await odooApi("POST", "/api/v1/user/login", { email: emailStr, password: passwordStr, + web_role: roleStr, })) as unknown as LoginResponse; const auth = res?.result; if (res?.status?.code === 200 && auth?.is_auth) { - // Cast auth.user ke AuthProps → cocok dengan setAuth if (auth.user && typeof auth.user === "object") { setAuth(auth.user as AuthProps); } - router.push("/"); + setCookie("web_role", roleStr, { path: "/", sameSite: "lax" }); + router.replace("/"); return; } @@ -106,7 +146,9 @@ const Login = () => { alert("Akun anda belum aktif"); break; default: - alert(res?.status?.description || "Login gagal. Periksa email/password."); + alert( + res?.status?.description || "Login gagal. Periksa email/password." + ); } } catch (error) { console.error(error); @@ -120,11 +162,15 @@ const Login = () => { <div className="bg-[#fafeff] h-screen overflow-auto"> <Header /> <div className="py-4 px-4 sm:px-96 pt-20"> - <div className="bg-white py-6 px-4 sm:px-96 shadow-md rounded-sm"> + <div className="py-6 px-4 sm:px-96 shadow-md rounded-sm text-black"> <Typography component="h1" variant="h4" - sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)", mb: 4 }} + sx={{ + width: "100%", + fontSize: "clamp(2rem, 10vw, 2.15rem)", + mb: 4, + }} > Sign in </Typography> @@ -133,13 +179,19 @@ const Login = () => { component="form" onSubmit={handleSubmit} noValidate - sx={{ display: "flex", flexDirection: "column", width: "100%", gap: 2 }} + sx={{ + display: "flex", + flexDirection: "column", + width: "100%", + gap: 2, + }} > <FormControl> <FormLabel htmlFor="email">Email</FormLabel> <TextField error={emailError} helperText={emailErrorMessage} + disabled={loading} id="email" type="email" name="email" @@ -164,6 +216,7 @@ const Login = () => { <TextField error={passwordError} helperText={passwordErrorMessage} + disabled={loading} name="password" placeholder="••••••" type="password" @@ -179,7 +232,37 @@ const Login = () => { /> </FormControl> - <Button type="submit" fullWidth variant="contained" disabled={loading}> + {/* Role selection */} + <FormControl error={roleError}> + <FormLabel htmlFor="role">Role</FormLabel> + <Select + id="role" + name="role" + value={role} + disabled={loading} + onChange={handleRoleChange} + displayEmpty + size="small" + > + <MenuItem value=""> + <em>Pilih role</em> + </MenuItem> + <MenuItem value="driver">Driver</MenuItem> + <MenuItem value="dispatch">Dispatch</MenuItem> + </Select> + {roleError && ( + <Typography variant="caption" color="error" sx={{ mt: 0.5 }}> + {roleErrorMessage || "Please select a role."} + </Typography> + )} + </FormControl> + + <Button + type="submit" + fullWidth + variant="contained" + disabled={loading} + > {loading ? "Loading..." : "Sign in"} </Button> </Box> diff --git a/app/page.tsx b/app/page.tsx index fe94498..af5ada3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,66 +6,32 @@ import SjCamera from "./lib/camera/component/sjCamera"; import DispatchCamera from "./lib/camera/component/dispatchCamera"; import useCameraStore from "./lib/camera/hooks/useCameraStore"; import Header from "./lib/camera/component/hedear"; -import { Button } from "@mui/material"; +import { + Button, + FormControl, + InputLabel, + MenuItem, + Select, + FormHelperText, +} from "@mui/material"; import { SaveAsOutlined } from "@mui/icons-material"; import axios from "axios"; import odooApi from "./lib/api/odooApi"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { getAuth } from "./lib/api/auth"; +import { getCookie } from "cookies-next"; +import { clearOdooSession } from "./lib/api/clearOdooSession"; -type AuthLike = { email?: string; token?: string } | string | null; - -const DRIVER_EMAILS = new Set( - ["driverindoteknik@gmail.com", "sulistianaridwan8@gmail.com"].map( - (e) => e.toLowerCase() - ) -); -const DISPATCH_EMAILS = new Set( - ["rahmat.afiudin@gmail.com", "indraprtama60@gmail.com", "it@fixcomart.co.id"].map((e) => e.toLowerCase()) -); - -function extractEmailFromAuth(auth: AuthLike): string | null { - // object with email - if (auth && typeof auth === "object" && "email" in auth && typeof auth.email === "string") { - return auth.email; - } - // object with token (JWT) - if (auth && typeof auth === "object" && "token" in auth && typeof auth.token === "string") { - const t = auth.token; - const parts = t.split("."); - if (parts.length === 3) { - try { - const payload = JSON.parse(atob(parts[1])); - const email: unknown = - payload?.email ?? payload?.preferred_username ?? payload?.sub ?? null; - return typeof email === "string" ? email : null; - } catch { - return null; - } - } - } - // JWT string - if (typeof auth === "string") { - const parts = auth.split("."); - if (parts.length === 3) { - try { - const payload = JSON.parse(atob(parts[1])); - const email: unknown = - payload?.email ?? payload?.preferred_username ?? payload?.sub ?? null; - return typeof email === "string" ? email : null; - } catch { - return null; - } - } - } - return null; -} +type Role = "driver" | "dispatch"; +type ShipMethod = "" | "self_pickup" | "indoteknik_delivery" | "ekspedisi"; export default function Home() { - const [isLogin, setIsLogin] = useState<boolean>(true); + const [isLogin, setIsLogin] = useState<boolean>(false); // start false biar nggak nge-flash const [isDriver, setIsDriver] = useState<boolean>(false); const [isDispatch, setIsDispatch] = useState<boolean>(false); + const [shippingMethod, setShippingMethod] = useState<ShipMethod>(""); + const [shipTouched, setShipTouched] = useState(false); const { barcode, @@ -81,19 +47,20 @@ export default function Home() { const [isLoading, setIsLoading] = useState<boolean>(false); const router = useRouter(); + // Single effect: auth gate + set role useEffect(() => { - const token = getAuth() as AuthLike; - if (!token) { - router.push("/login"); - } else { - setIsLogin(true); - const email = extractEmailFromAuth(token); - const lower = (email ?? "").toLowerCase(); - const dispatchFlag = DISPATCH_EMAILS.has(lower); - const driverFlag = DRIVER_EMAILS.has(lower) && !dispatchFlag; - setIsDispatch(dispatchFlag); - setIsDriver(driverFlag); + const auth = getAuth(); + if (!auth) { + void clearOdooSession(process.env.NEXT_PUBLIC_ODOO_API_HOST ?? ""); + router.replace("/login"); + return; } + const roleCookie = ( + getCookie("web_role") as string | undefined + )?.toLowerCase() as Role | undefined; + setIsDriver(roleCookie === "driver"); + setIsDispatch(roleCookie === "dispatch"); + setIsLogin(true); }, [router]); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { @@ -106,13 +73,24 @@ export default function Home() { return; } + // Dispatch: shipping method wajib + if (isDispatch) setShipTouched(true); + if (isDispatch && !shippingMethod) { + alert("Shipping Method wajib dipilih."); + setIsLoading(false); + return; + } + + // Validasi foto sesuai role & shipping method if (isDispatch) { if (!imageDispatch) { alert("Foto Dispatch Wajib Diisi"); setIsLoading(false); return; } + // SJ opsional untuk self_pickup & ekspedisi } else { + // Driver: SJ & Penerima wajib if (!imageSj || !imagePackage) { alert("Barcode, Foto SJ, dan Foto Penerima harus tersedia."); setIsLoading(false); @@ -121,41 +99,41 @@ export default function Home() { } try { - // siapkan base64 tanpa prefix, kalau kosong biarkan undefined const newSjImage = imageSj ? imageSj.replace(/^.*?,/, "") : undefined; - const newPackageImage = imagePackage ? imagePackage.replace(/^.*?,/, "") : undefined; + const newPackageImage = imagePackage + ? imagePackage.replace(/^.*?,/, "") + : undefined; const newDispatchImage = imageDispatch && imageDispatch.startsWith("data:") ? imageDispatch.replace(/^.*?,/, "") - : imageDispatch || undefined; // kalau sudah base64 tanpa prefix + : imageDispatch || undefined; - // tandai mana yang BENAR-BENAR akan dikirim + // Kirim hanya yang ada const submittedSj = !!newSjImage; const submittedPackage = !!newPackageImage; const submittedDispatch = !!newDispatchImage && !isDriver; - // bangun payload: hanya isi yang ada const data: Record<string, string> = {}; if (submittedSj) data.sj_document = newSjImage!; if (submittedPackage) data.paket_document = newPackageImage!; if (submittedDispatch) data.dispatch_document = newDispatchImage!; + if (isDispatch && shippingMethod) data.shipping_method = shippingMethod; const response = (await odooApi( "PUT", `/api/v1/stock-picking/${barcode}/documentation`, data - )) as { status: { code: number } }; + )) as { status?: { code?: number } }; if (response?.status?.code === 200) { alert("Berhasil Submit Data"); - // barcode bebas: kalau mau tetap kosongkan setBarcode(""); - + // Bersihkan HANYA yang dikirim if (submittedSj) setImageSj(""); if (submittedPackage) setImagePackage(""); if (submittedDispatch) setImageDispatch(""); } else if (response?.status?.code === 404) { - alert("Gagal Submit Data, Picking Code Tidak Ditemukan "); + alert("Gagal Submit Data, Picking Code Tidak Ditemukan"); } else { alert("Gagal Submit Data, Silahkan Coba Lagi"); } @@ -172,31 +150,112 @@ export default function Home() { } }; + // === UI helpers === + // dispatch: SJ hanya utk self_pickup & ekspedisi + const showSjForDispatch = + isDispatch && + (shippingMethod === "self_pickup" || shippingMethod === "ekspedisi"); + // dispatch: kamera dispatch tampil utk semua method (asal sudah dipilih) + const showDispatchForDispatch = isDispatch && shippingMethod !== ""; + // preview SJ: sembunyikan kalau dispatch belum pilih method + const showSjPreview = !!imageSj && (!isDispatch || showSjForDispatch); + // preview Dispatch: sembunyikan kalau dispatch belum pilih method + const showDispatchPreview = + !!imageDispatch && (!isDispatch || showDispatchForDispatch); + return ( <div className="bg-white h-screen overflow-auto"> <Header /> - {isLogin && ( + {isLogin ? ( <div className="py-4 px-4 sm:px-96 pt-20"> <form onSubmit={handleSubmit}> <div> <BarcodeScanner /> </div> + + {/* Shipping Method (khusus dispatch) */} + {isDispatch && ( + <div className="mt-4"> + <FormControl + fullWidth + size="small" + required + error={shipTouched && !shippingMethod} + > + <InputLabel id="shipping-label" shrink> + Shipping Method + </InputLabel> + <Select + labelId="shipping-label" + id="shipping" + label="Shipping Method" + value={shippingMethod} + displayEmpty + onChange={(e) => + setShippingMethod(e.target.value as ShipMethod) + } + onBlur={() => setShipTouched(true)} + renderValue={(selected) => { + if (!selected) + return ( + <span style={{ opacity: 0.6 }}> + Pilih Shipping Method + </span> + ); + const map: Record<string, string> = { + self_pickup: "Self Pickup", + indoteknik_delivery: "Indoteknik Delivery", + ekspedisi: "Ekspedisi", + }; + return map[selected as string] ?? ""; + }} + > + <MenuItem value=""> + <em>Pilih Shipping Method</em> + </MenuItem> + <MenuItem value="self_pickup">Self Pickup</MenuItem> + <MenuItem value="indoteknik_delivery"> + Indoteknik Delivery + </MenuItem> + <MenuItem value="ekspedisi">Ekspedisi</MenuItem> + </Select> + {shipTouched && !shippingMethod && ( + <FormHelperText> + Wajib pilih shipping method. + </FormHelperText> + )} + </FormControl> + </div> + )} + <div className="h-4" /> + <div className="flex justify-between"> - <SjCamera /> - {!isDispatch && <PackageCamera />} - {!isDriver && <DispatchCamera />} + {isDispatch ? ( + <> + {showSjForDispatch && <SjCamera />} + {showDispatchForDispatch && <DispatchCamera />} + </> + ) : ( + <> + {/* driver / non-dispatch */} + <SjCamera /> + <PackageCamera /> + </> + )} </div> + <div className="h-2" /> - {imageSj && ( + {/* Preview SJ */} + {showSjPreview && ( <> <label className="block mt-2 text-sm font-medium text-gray-700 text-center"> Gambar Foto Surat Jalan </label> <div className="relative w-full h-[300px] border-2 border-gray-200 p-2 rounded-sm"> <Image - src={imageSj} + src={imageSj!} alt="Captured" fill unoptimized @@ -207,9 +266,8 @@ export default function Home() { </> )} - <div className="h-2" /> - - {imagePackage && ( + {/* Preview Penerima (hanya non-dispatch) */} + {!isDispatch && imagePackage && ( <> <label className="block mt-2 text-sm font-medium text-gray-700 text-center"> Gambar Foto Penerima @@ -227,16 +285,15 @@ export default function Home() { </> )} - <div className="h-2" /> - - {!isDriver && imageDispatch && ( + {/* Preview Dispatch */} + {showDispatchPreview && ( <> <label className="block mt-2 text-sm font-medium text-gray-700 text-center"> Gambar Foto Dispatch </label> <div className="relative w-full h-[300px] border-2 border-gray-200 p-2 rounded-sm"> <Image - src={imageDispatch} + src={imageDispatch!} alt="Captured" fill unoptimized @@ -260,8 +317,7 @@ export default function Home() { </Button> </form> </div> - )} - {!isLogin && ( + ) : ( <div className="py-4 px-4 sm:px-96 pt-20"> <div className="text-center"> <p className="text-2xl font-bold">Loading...</p> |
